SchemaSmith Concept

Data Delivery

Declare where a table's rows come from and how they should merge. SchemaQuench delivers the data in foreign-key order, across every platform, with no hand-rolled MERGE scripts.

By the SchemaSmith Team · Last reviewed

Rows flowing from a source file into a target database table in foreign-key-aware order

Declare how a table's data gets merged into the target database and let SchemaQuench handle the rest.

Overview

Lookup tables, configuration rows, the reference data every environment needs to run — that data is a schema-management problem as much as the tables themselves. The DataDelivery block declares how those rows land in the target database, alongside the table definition, so reference data travels with the schema it depends on.

Declare how a table's data gets merged into the target database and let SchemaQuench handle the rest.

Tables without a DataDelivery block are left alone. Tables that declare one are picked up automatically during the data delivery step — see the SchemaQuench Table Data Delivery section on your platform for the runtime behavior: SQL Server, PostgreSQL, MySQL.

Properties

Add a DataDelivery block to the table JSON, point it at a .tabledata file, tell it what a "match" looks like, and SchemaQuench delivers the data in foreign-key order — no hand-rolled merge scripts.

Property Type Default Description
ContentFile string Path to the row data, relative to the template root. Typically produced by DataTongs as a .tabledata file (raw JSON array).
MergeType string One of Insert, Insert/Update, Insert/Update/Delete. See MergeType below.
MatchColumns string Comma-separated column names that identify a row. Prefix a column with * for NULL-safe comparison on nullable keys. Matches the KeyColumns concept in DataTongs.
MergeFilter string "" Optional SQL WHERE clause (without the WHERE keyword). Scopes both the rows considered for matching and, when delete is enabled, the rows eligible for deletion.
MergeDisableTriggers bool false Wrap the merge with platform-appropriate trigger disable/enable.
MergeDisableRules bool false PostgreSQL. Disable rewrite rules on the table during the merge.
MergeUpdateDescendents bool false PostgreSQL. When true, the merge targets descendant partitions as well as the specified table. When false (the default), the merge uses ONLY so descendant tables are left untouched.

MergeType

Value Behavior
Insert Missing rows inserted. Existing rows and extra rows left alone. The seed-data pattern.
Insert/Update Missing rows inserted, changed rows updated. Extra rows left alone. Good for reference tables where environments can append local rows.
Insert/Update/Delete Full sync. Missing rows inserted, changed rows updated, and target rows that don't exist in the source data are deleted. The demo products use this. When MergeFilter is set, deletes are scoped by the filter so rows outside it are never removed.

The chosen idiom is platform-specific — MERGE on SQL Server and PostgreSQL, INSERT ... ON DUPLICATE KEY UPDATE with a conditional delete step on MySQL — but the declarative contract is the same on every platform.

PostgreSQL version note

On PostgreSQL, the delete branch of an Insert/Update/Delete merge adapts to the target's engine version. PostgreSQL 17 and later delete inside the MERGE itself with a WHEN NOT MATCHED BY SOURCE clause; PostgreSQL 15 and 16 run the MERGE for the inserts and updates, then a follow-on DELETE … WHERE NOT EXISTS keyed identically and honoring the same MergeFilter and NULL-safe matching. The end state is identical on every supported version. For the full version-adaptive matrix, see the SchemaQuench Engine Version Compatibility section on your platform: SQL Server, PostgreSQL, MySQL.

Multiple deliveries

A table's DataDelivery is either a single object (the form above, unchanged) or an array of independently-gated deliveries. Each array entry is a full DataDelivery object — its own ContentFile, MergeType, MergeFilter, and the rest. This is the mechanism behind three common patterns:

  • Environment-gated seed/test data. Ship fixture or test rows that only land in dev or test databases.
  • Per-environment variants. A rich dataset for dev, a minimal reference set for production — same table, mutually exclusive gates.
  • Additive patch slices. Several deliveries with disjoint MergeFilters, each covering its own slice of the table, all applying together.

Unlike the single-match variant pattern used elsewhere in the schema package, data deliveries are not "one match wins": at quench time, every delivery whose ShouldApplyExpression passes applies, in declared order. That's what makes additive patch slices possible — and it's why an array of two or more deliveries requires a ShouldApplyExpression on every entry (an ungated entry alongside others would always apply, defeating the point of gating); loading an array that omits one on any entry fails with a clear error before any deployment work begins.

Pattern 1 — environment-gated seed data (SQL Server)

A single delivery can carry a ShouldApplyExpression on its own — no array required. This table's fixture rows only merge into databases whose name ends _dev or _test; everywhere else, delivery is skipped for this table.

"DataDelivery": {
  "ContentFile": "data/dbo.TestFixtures.tabledata",
  "MergeType": "Insert/Update/Delete",
  "MatchColumns": "[FixtureId]",
  "ShouldApplyExpression": "DB_NAME() LIKE '%_dev' OR DB_NAME() LIKE '%_test'",
  "VariantName": "Dev/test fixtures"
}

Pattern 2 — per-environment variants (PostgreSQL)

The two gates are mutually exclusive, so exactly one variant applies per target — a full catalog in dev, a lean reference set everywhere else — from one table definition.

"DataDelivery": [
  {
    "ContentFile": "data/public.product_catalog.dev.tabledata",
    "MergeType": "Insert/Update/Delete",
    "MatchColumns": "product_id",
    "ShouldApplyExpression": "current_database() = 'app_dev'",
    "VariantName": "Rich dev catalog"
  },
  {
    "ContentFile": "data/public.product_catalog.prod.tabledata",
    "MergeType": "Insert/Update",
    "MatchColumns": "product_id",
    "ShouldApplyExpression": "current_database() <> 'app_dev'",
    "VariantName": "Minimal prod reference set"
  }
]

Pattern 3 — additive patch slices (MySQL)

Both deliveries share the same gate — on app_main, both apply. Each is scoped to its own Category slice by MergeFilter, so the two additive deliveries never fight over the same rows.

"DataDelivery": [
  {
    "ContentFile": "data/StatusCodes.core.tabledata",
    "MergeType": "Insert/Update/Delete",
    "MatchColumns": "StatusCodeId",
    "MergeFilter": "Category = 'Core'",
    "ShouldApplyExpression": "DATABASE() = 'app_main'",
    "VariantName": "Core status codes"
  },
  {
    "ContentFile": "data/StatusCodes.regional.tabledata",
    "MergeType": "Insert/Update/Delete",
    "MatchColumns": "StatusCodeId",
    "MergeFilter": "Category = 'Regional'",
    "ShouldApplyExpression": "DATABASE() = 'app_main'",
    "VariantName": "Regional status codes"
  }
]

Gate evaluation, logging, and WhatIf

SchemaQuench evaluates every delivery's gate against the target once per quench, before any content file is read, and every delivery whose gate passes applies — not just the first match. A delivery whose gate evaluates false is logged as skipped and never touches the table; a delivery whose gate expression itself errors aborts the deployment (fail-closed), the same way a folder-level gate does.

A gated-off delivery is logged as skipped, distinct from a delivered or a failed one — the message names the table and the delivery's VariantName, so a scan of the progress log tells you which deliveries ran and which were gated out.

Gate evaluation also runs during WhatIf, so a dry run reports the same deliver-vs-skip decisions a real quench would make. Skipped deliveries are still logged individually with their VariantName, exactly as in a real run; applied deliveries are reported as a single Would DELIVER: <table> line for the table, not broken out per delivery.

The Insert/Update/Delete CASCADE-FK check considers only deliveries whose gate is currently passing — a Delete variant that's gated off this run can't abort the deployment over a CASCADE FK it will never execute.

Overlapping deletes are an authoring responsibility

When two or more deliveries apply together to the same table and more than one uses Insert/Update/Delete, each delivery's delete pass removes any target row outside its own MergeFilter — including rows another applying delivery just wrote. Give every Insert/Update/Delete delivery in a multi-delivery table a MergeFilter disjoint from every other applying delivery's filter, as in Pattern 3 above. There is no engine-side guard against overlapping deletes across deliveries.

Gates are not token-resolved

See Conditional Application for the general ShouldApplyExpression behavior. Data delivery gates are evaluated the same way — once per delivery instead of once per table — with one difference worth knowing: component-level gates and script-folder gates have their tokens (including {{SchemaName}} on schema templates) resolved before evaluation, but a DataDelivery.ShouldApplyExpression does not currently go through that resolution step. Write it in terms of things you can query directly on the target — database or server name, catalog lookups — rather than a {{Token}} placeholder.

FK-aware delivery

When multiple tables declare DataDelivery, SchemaQuench orders them by their declared foreign keys:

  • Pass 1 runs every table whose required (NOT NULL) FK parents are already loaded. Nullable FK columns that point to tables still awaiting delivery are deferred — the pass-1 merge writes NULL into those columns and records the table for a second pass.
  • Pass 2 revisits each deferred table and back-fills the deferred columns with their actual values, now that every parent row exists.

This is automatic. You don't order the tables yourself; SchemaQuench computes the dependency graph from the ForeignKeys arrays in the table JSON. A cycle among NOT NULL foreign keys fails delivery with a clear log message — break the cycle by making one side nullable, or separate the data load into explicit phases.

Example

{
  "Name": "[Employee]",
  "Schema": "HumanResources",
  "Columns": [
    {
      "Name": "[EmployeeID]",
      "DataType": "INT",
      "Identity": true,
      "Nullable": false
    },
    { "Name": "[ManagerID]", "DataType": "INT", "Nullable": true },
    { "Name": "[DepartmentID]", "DataType": "INT", "Nullable": false },
    { "Name": "[FullName]", "DataType": "NVARCHAR(100)", "Nullable": false }
  ],
  "Indexes": [
    {
      "Name": "[PK_Employee]",
      "PrimaryKey": true,
      "Unique": true,
      "IndexColumns": "[EmployeeID]"
    }
  ],
  "ForeignKeys": [
    {
      "Name": "[FK_Employee_Manager]",
      "Columns": "[ManagerID]",
      "RelatedTable": "[Employee]",
      "RelatedColumns": "[EmployeeID]"
    },
    {
      "Name": "[FK_Employee_Department]",
      "Columns": "[DepartmentID]",
      "RelatedTable": "[Department]",
      "RelatedColumns": "[DepartmentID]"
    }
  ],
  "DataDelivery": {
    "ContentFile": "data/HumanResources.Employee.tabledata",
    "MergeType": "Insert/Update",
    "MatchColumns": "[EmployeeID]",
    "MergeDisableTriggers": true
  }
}

The self-referential ManagerID is nullable, so pass 1 loads every employee with ManagerID = NULL, and pass 2 back-fills the manager chain once every row exists. The mandatory DepartmentID forces Department to deliver first.

Generating DataDelivery blocks with DataTongs

You don't have to write these blocks by hand. Point DataTongs at a source database with --ConfigureDataDelivery and it writes the DataDelivery section into each table JSON, including the match columns and merge type — see the DataTongs --ConfigureDataDelivery reference for your platform: SQL Server, PostgreSQL, MySQL.

DataTongs output defaults

DataTongs writes both a merge script and a sibling .tabledata content file for every table, and the content files are what the declarative DataDelivery pipeline reads. Two ShouldCast flags govern that output, and both are on by default.

Flag Default Behavior
OutputContentFiles true Write the raw row data to sibling .tabledata content files. Set to false to skip the content files — and, transitively, to skip ConfigureDataDelivery, since the configurator needs a content-file path to record in the DataDelivery block.
TokenizeScripts true SQL Server only. Replace the source database name with a script token in the generated merge scripts, matching SchemaTongs' tokenization behavior. Set to false to keep the literal database name.

Schema-template data extraction

Schema templates need per-iteration data delivery — each tenant gets the same reference data, scaffolded from a canonical source schema. DataTongs extracts data scoped to a single source schema and writes content files and merge scripts that use {{SchemaName}} as the destination schema reference, so SchemaQuench can resolve it per iteration at deploy time. The common use: extract seed data from a canonical tenant schema so every future onboarding starts from the same known state. See Multi-Tenant Deployments for the end-to-end workflow, and Schema Templates for the Template.json field reference.

Activation

Schema-template mode is detected by two signals, and both must be present:

  • The target Template.json has a non-empty SchemaIdentificationScript field. DataTongs walks up from the content path to find the nearest Template.json, then reads it for this field.
  • Source:Schema is set in DataTongs.settings.json, naming the single source schema DataTongs will extract data from.

If the template is a schema template but Source:Schema is not set, DataTongs stops with an error asking you to set it (or to point at a regular template instead). If Source:Schema is set but the target is a regular template, DataTongs logs a warning and proceeds in regular extraction mode, ignoring the schema. Neither signal means ordinary extraction, unchanged.

What it writes

Schema-template mode changes the name and content of every output file so the results drop straight into a schema template:

Output Regular mode Schema-template mode
Merge script filename Populate <schema>.<table>.sql Populate <table>.sql
Content file filename <schema>.<table>.tabledata <table>.tabledata
Destination schema in merge body Literal source schema name {{SchemaName}}

The unqualified filenames match the naming convention for table JSON files in a schema template. SchemaQuench resolves {{SchemaName}} to the active schema when it runs the merge script for each iteration — see how the per-iteration schema token substitutes across script slots. A minimal schema-template extraction adds Schema to the Source block:

{
  "Source": {
    "Server": "localhost",
    "Database": "TenantSeedDB",
    "Schema": "tenant_seed",
    "Platform": "SqlServer"
  },
  "ContentPath": "./Templates/TenantBody/Table Data",
  "ScriptPath": "./Templates/TenantBody/Table Data",
  "Tables": [
    { "Name": "Customers", "KeyColumns": "CustomerID" },
    { "Name": "Plans" }
  ],
  "ShouldCast": {
    "OutputContentFiles": true,
    "ConfigureDataDelivery": true
  }
}

One schema per run

Each schema-template run targets exactly one source schema, and every entry in the Tables array is resolved against it — so table names must be unqualified, with the schema coming from Source:Schema rather than the individual entry. If a product's data spans more than one schema (per-tenant tables in {{SchemaName}} plus shared lookups in dbo), run DataTongs twice: once in schema-template mode for the per-tenant tables, once in regular mode for the shared ones.

Tokenization composes orthogonally. With TokenizeScripts on (SQL Server), both transformations apply in sequence — the database name becomes its token and the source schema becomes {{SchemaName}} — so the merge scripts carry both a database token and a per-iteration schema reference.

Hands-on lab

Build a DataDelivery block to declare reference-table merges, and let SchemaQuench deliver them in foreign-key order automatically.

Start the data delivery lab