SQL logoQuickstart

Your First DDL Transform Should Write Nothing: a SQL Quickstart

The most accurate description of your data is almost certainly a `CREATE TABLE` statement sitting in a migrations folder. It is reviewed, versioned, and executable — and it is legible to exactly one kind of consumer. Getting it out of that folder and into a data contract, a JSON Schema, or another vendor's dialect is usually where the accuracy stops.

Your First DDL Transform Should Write Nothing: a SQL Quickstart

The most accurate description of your data is almost certainly a CREATE TABLE statement sitting in a migrations folder. It is reviewed, versioned, and executable — and it is legible to exactly one kind of consumer. Getting it out of that folder and into a data contract, a JSON Schema, or another vendor's dialect is usually where the accuracy stops.

This quickstart does that in a single HTTP call, and the call writes nothing. On the CoreModels transform surface, SQL DDL is the format key sql, and it works in both directions: CREATE TABLE text decodes into the model, and the model encodes back out as CREATE TABLE text. We are going to use the stateless mapping route, so you can run this against any project you can read, on the first day, without touching a single stored node.

What you need

  • The API host — https://coremodels.example.com below; substitute your deployment. Transform routes carry no api/ prefix.
  • A bearer token in $TOKEN, and a project id in $PROJECT_ID (32 hex characters). On this route the project scopes authorization only; Viewer is enough.
  • curl and jq.

Every request needs Authorization: Bearer $TOKEN and Content-Type: application/json.

The table

Save this as shipment.sql. Five columns and one table-level constraint — enough to see how the surface treats structure, requiredness, precision, and the things SQL knows that a portable model does not.

CREATE TABLE shipment (
  shipment_id INTEGER NOT NULL,
  tracking_code VARCHAR(64) NOT NULL,
  carrier VARCHAR(120),
  shipped_at TIMESTAMP,
  weight_kg DECIMAL(8,2),
  PRIMARY KEY (shipment_id)
);

You do not have to tell us which database this came from. Decode is dialect-tolerant: one parser reads PostgreSQL, MySQL, and SQL Server spellings — double quotes, backticks, or square brackets; IF NOT EXISTS; -- and /* */ comments; inline COMMENT clauses; ENUM(...) columns — without a dialect declaration.

The one call

We will turn the table into an ODCS data contract (odcs — a Bitol Open Data Contract Standard v3 YAML contract, one of the encode formats we implement). The route is schema/map: it decodes the source, produces a mapping plan, validates that plan through the engine's gate, executes it, and encodes the result.

Inference matches the source against a target hint — a schema to map toward — and it refuses to run without one. For a straight format conversion there is nothing to map toward except the source itself, so the source is its own hint. That is the one non-obvious move in this whole article, and it is worth knowing early: omit the hint and the call returns success: false with { "path": "inference", "message": "The inference resolver requires a target IR to match against." } in errors.

jq -n --rawfile s shipment.sql '{
  sourceFormat: "sql",
  sourceSchema: $s,
  targetFormat: "odcs",
  targetHintFormat: "sql",
  targetHintSchema: $s,
  mapping: { kind: "inferred" }
}' > map.json

curl -sS -X POST \
  "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @map.json > response.json

jq -n --rawfile is doing real work here: the DDL travels as a JSON string, and hand-escaping newlines is how quickstarts go wrong.

The output

Every transform response uses the same envelope — success, lossiness, errors, a payload key (schema here), and on the mapping routes a plan. Text formats arrive as a string, so read them with -r:

jq -r '.schema' response.json
apiVersion: v3.1.0
kind: DataContract
id: sql-schema
version: 1.0.0
status: active
schema:
  - name: shipment
    properties:
      - name: shipment_id
        logicalType: integer
        required: true
      - name: tracking_code
        logicalType: string
        required: true
      - name: carrier
        logicalType: string
      - name: shipped_at
        logicalType: timestamp
      - name: weight_kg
        logicalType: number

Column names survived exactly. NOT NULL became required: true on the two columns that had it. TIMESTAMP became timestamp and DECIMAL(8,2) became number — the contract's own vocabulary, not a guess.

Read the ledger before you celebrate

jq '.lossiness' response.json
[
  {
    "kind": "ConstraintRelaxation",
    "path": "Type[shipment]",
    "explanation": "SQL PRIMARY constraint not modelled by the IR."
  },
  {
    "kind": "ConstraintRelaxation",
    "path": "Type[shipment]",
    "explanation": "SQL PRIMARY constraint not modelled by the IR."
  }
]

success: true means the call ran. It does not mean nothing changed. lossiness is the account of everything whose fidelity shifted, and reading it is the entire discipline of using this surface well. Each record carries a kind — one of StructuralDrop, TypeApproximation, ConstraintRelaxation, SemanticNarrowing — a path naming the exact construct, and a plain-English explanation.

Two things to take from this ledger.

First, the finding itself: the model is made of types, elements, requiredness, references, and vocabularies. It has no concept of a primary key, so PRIMARY KEY (shipment_id) was dropped and said so, at the table, rather than letting you discover the absence three tools downstream. Table-level UNIQUE and CHECK clauses are reported the same way. Treat this as a review checklist: re-establish the key wherever this contract becomes a database again.

Second, the reason it appears twice: the ledger is aggregated across every stage of the call — source decode, hint decode, plan, gate, execute, encode. We decoded the same DDL twice, once as the source and once as the hint, so the same relaxation is reported once per decode. Nothing is deduplicated, because deduplication would mean deciding which report you did not need.

The same call, a different target

Change two fields and the same source comes out as MySQL DDL:

jq -n --rawfile s shipment.sql '{
  sourceFormat: "sql",
  sourceSchema: $s,
  targetFormat: "sql",
  vendor: "mysql",
  targetHintFormat: "sql",
  targetHintSchema: $s,
  mapping: { kind: "inferred" }
}' | curl -sS -X POST \
    "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    --data-binary @- | jq -r '.schema'
CREATE TABLE `shipment` (
  `shipment_id` INTEGER NOT NULL,
  `tracking_code` VARCHAR(64) NOT NULL,
  `carrier` VARCHAR(120),
  `shipped_at` TIMESTAMP,
  `weight_kg` DECIMAL(8,2)
);

vendor applies to SQL output only and takes postgres (the default), mysql, or sqlserver (the alias mssql is accepted). It changes identifier quoting, where column comments go, whether a controlled vocabulary can be expressed as an inline ENUM(...), and — for models that do not already carry a SQL type spelling — which concrete types get emitted.

Notice what did not change: VARCHAR(64), DECIMAL(8,2), and TIMESTAMP came through verbatim. When a model enters as SQL, we keep each column's original type spelling and re-emit it exactly, because a round-trip that silently rewrites DECIMAL(8,2) into something "equivalent" is not a round-trip. Vendor-idiomatic types are chosen only when there is no original spelling to honor — a model that arrived as JSON Schema, Avro, or LinkML, or one that came out of a CoreModels project.

What the conversion actually did

Underneath both calls, the same structural map ran: the table became a Type (labeled shipment), each column became an Element with a primitive kind, NOT NULL became the required flag, a foreign key would have become a typed reference to another table, and an ENUM(...) column would have become a governed Taxonomy with one term per value. Column comments are the annotation carrier — a comment that parses as a JSON object carrying x-maps-to or x-sia-role is lifted into formal semantic mappings and roles, while a comment that is not JSON is preserved as a description.

The response also carried a plan: the executed mapping, operation by operation, as a reviewable and replayable artifact.

{
  "kind": "ElementMapping",
  "origin": "Inferred",
  "sourceElementIds": ["shipmentShippedAt"],
  "targetElementIds": ["shipmentShippedAt"]
}

Those ids are ours — node ids must be alphanumeric, so shipment plus shipped_at becomes shipmentShippedAt while the label keeps your original shipped_at. Store the plan and the same conversion can be replayed later, byte for byte, without asking inference to guess again.

Three habits carry over to every other format on this surface: pick the format key, read the ledger, keep the plan. The full route list, roles, and ready-to-paste bodies for every format are in the transform API guide in the CoreModels docs.