No Annotation Slot: How the SQL DDL Coder Carries Meaning, and Where It Stops
Every other format on the CoreModels transform surface has somewhere to put meaning. JSON Schema has `x-` keywords. Avro has custom attributes. LinkML has `slot_uri` and `meaning`. OWL *is* meaning. SQL DDL has a name, a type, and a handful of constraints — and no annotation slot at all.
No Annotation Slot: How the SQL DDL Coder Carries Meaning, and Where It Stops
Every other format on the CoreModels transform surface has somewhere to put meaning. JSON Schema has x- keywords. Avro has custom attributes. LinkML has slot_uri and meaning. OWL is meaning. SQL DDL has a name, a type, and a handful of constraints — and no annotation slot at all.
That austerity shapes the whole coder. Its answer comes in three parts: structure maps to structure, exact spellings ride in a dotted extras namespace, and semantics ride in comments — the one free-text channel every dialect offers. What follows is the tour of both directions: what maps, what rides along, every condition that writes a lossiness record, what round-trips, and the edges we know about.
The format key is sql. Decode is dialect-tolerant: one parser reads PostgreSQL, MySQL, and SQL Server spellings without a dialect declaration. Encode is dialect-specific: postgres (default), mysql, or sqlserver selects quoting, type defaults, enum handling, and comment placement.
The structural map
| SQL construct | Model construct | Notes |
|---|---|---|
CREATE TABLE t (...) | Type | id sanitized to camelCase alphanumerics; the original name kept verbatim as the label; provenance stamped SQL |
| column | Element | label is the original column name; id is table + column, camelCased |
| column type | primitive kind | four buckets, plus the verbatim spelling in extras |
NOT NULL | required | round-trips in both directions |
inline REFERENCES t(c), or table-level FOREIGN KEY (c) REFERENCES t (c) | type reference | the element's value type points at the referenced table's Type |
ENUM('a','b') or SET(...) column | Taxonomy + reference | taxonomy id is the element id plus Enum; one term per value |
column comment, inline or COMMENT ON COLUMN | semantic mappings, role, or description | the carrier — below |
Ids are sanitized because node ids must be alphanumeric; separators fold away and camel-case the next word. sensor_reading becomes sensorReading, and its taken_at column becomes sensorReadingTakenAt. Sanitization does not lower-case what you shouted: FULL_NAME in Customer becomes CustomerFULLNAME. Labels always keep the original text, and encode writes from labels, so your naming survives the trip — but mapping guides and plans address constructs by id, so copy ids from a returned plan rather than deriving them by eye.
Schema-qualified table names reduce to their local part: analytics.web_event imports as web_event.
One structural reduction to know: a foreign key records which table is referenced, not which column. REFERENCES invoice(invoice_id) becomes a reference to the invoice type, and re-encoding emits REFERENCES "invoice" with no column list.
Types: four buckets in, verbatim spelling back out
Decode strips any (args), lower-cases the base name, and sorts it into one of four recognized kinds, with String as the everything-else fallback:
| Kind | Recognized spellings |
|---|---|
| Integer | int, integer, bigint, smallint, tinyint, serial, bigserial, smallserial, int4, int8 |
| Double | decimal, numeric, float, double, real, money, number, dec |
| Boolean | bool, boolean, bit |
| DateTime | date, datetime, datetime2, timestamp, timestamptz, smalldatetime, time |
| String | everything else — varchar, char, text, uuid, json, blob, … |
The bucket is only half the story. The original spelling, arguments included, is preserved verbatim under the extras key sql.type. DECIMAL(12,2) buckets as Double for the benefit of every other format and stays DECIMAL(12,2) in the bag. Precision and scale never become model structure; they survive as the verbatim spelling or not at all.
On encode, a preserved spelling wins. Only an element with no sql.type — a model that arrived as JSON Schema, Avro, LinkML, or came out of a CoreModels project — gets the vendor default:
| Kind | postgres | mysql | sqlserver |
|---|---|---|---|
| Integer | INTEGER | INTEGER | INTEGER |
| Double | NUMERIC | NUMERIC | DECIMAL(18,2) |
| Boolean | BOOLEAN | TINYINT(1) | BIT |
| DateTime | TIMESTAMP | DATETIME | DATETIME2 |
| String and the rest | VARCHAR(255) | VARCHAR(255) | NVARCHAR(255) |
That rule defines what a stateless SQL-to-SQL "dialect conversion" actually is: for a model that entered as SQL, re-encoding to another vendor changes quoting, enum treatment, and comment placement — but the column types come back in their original spellings. A T-SQL NVARCHAR(200) exported as PostgreSQL stays NVARCHAR(200). It is a deliberate trade of speculative rewriting for round-trip fidelity. Route the model through a project when you want the vendor table applied, since a project stores the modeled type rather than the source text.
The carrier: comments that are secretly JSON
Semantics travel in column comments, and the rules are worth knowing exactly.
Decoding. MySQL's inline COMMENT '...' clause is read during column parsing; standalone COMMENT ON COLUMN table.column IS '...' statements are applied in a second pass after all tables exist. Either way the text is inspected: if it parses as a JSON object, x-maps-to entries become formal semantic mappings (standard → URI) and x-sia-role becomes the element's role. If it is not JSON — plain prose, or JSON that fails to parse — it is kept as a description under the extras key sql.comment.
Encoding. An element carrying mappings or a role gets a JSON-object comment rebuilt from them; otherwise a stored description is written back as-is. MySQL output carries comments inline on the column; postgres and sqlserver output collects them into COMMENT ON COLUMN statements appended after the tables. One table in, two dialects out:
CREATE TABLE patient (
mrn VARCHAR(32) COMMENT '{"x-sia-role":"Identifier","x-maps-to":{"schema.org":"https://schema.org/identifier"}}',
enrolled_on TIMESTAMP
);
CREATE TABLE `patient` (
`mrn` VARCHAR(32) COMMENT '{"x-sia-role":"Identifier","x-maps-to":{"schema.org":"https://schema.org/identifier"}}',
`enrolled_on` TIMESTAMP
);
CREATE TABLE "patient" (
"mrn" VARCHAR(32),
"enrolled_on" TIMESTAMP
);
COMMENT ON COLUMN patient.mrn IS '{"x-sia-role":"Identifier","x-maps-to":{"schema.org":"https://schema.org/identifier"}}';
Between them, mrn is a String element with role Identifier, one mapping to https://schema.org/identifier, and sql.type of VARCHAR(32). The dotted extras namespace for this format is exactly two keys — sql.type for the verbatim column type and sql.comment for a non-semantic comment — with lifted mappings and roles living in the annotation bag proper.
The lossiness inventory
The coder itself writes exactly two patterns of record:
- Decode. A table-level
PRIMARY KEY,UNIQUE, orCHECKclause produces aConstraintRelaxationatType[<table>]:SQL PRIMARY constraint not modelled by the IR.(orUNIQUE, orCHECK). The model has types, elements, requiredness, references, and vocabularies — not keys or check expressions. - Encode. A taxonomy-backed element targeted at
postgresorsqlserverproduces aConstraintRelaxationatElement[<id>]: "has no inline enum; emitted as VARCHAR (the allowed-value constraint is not enforced)." MySQL expresses the vocabulary as a realENUM(...)and writes no record.
Everything else you see in a SQL conversion's ledger comes from the engine, not the coder — most often a StructuralDrop at Type[<type>].<element> reading "Element is a member of the mapped type but no operation maps or drops it.", which means the mapping plan had nothing to say about that column.
Equally important is what is dropped without a record, because the column tokenizer recognizes only NOT NULL, REFERENCES, and COMMENT among column attributes: DEFAULT clauses, auto-increment markers, and inline column-level PRIMARY KEY / UNIQUE pass through silently, as do table-level KEY / INDEX lines. Move keys to table-level clauses if you want them itemized, and diff an export against its source once if your DDL leans on defaults.
What round-trips
Decode → encode → decode → encode over MySQL is stable: the second pass produces text identical to the first, with structure, requiredness, references, vocabularies, and any x-maps-to mapping intact. The test suite pins the same claims — the structural map from a reference table, the x-maps-to lift out of a column comment, a MySQL round-trip whose signature is identical across the loop, and the vendor defaults from a fresh model.
Two inputs fail outright rather than reporting lossiness: an empty payload (The SQL DDL is empty.) and text with no CREATE TABLE at all (No CREATE TABLE statement was found.).
Every edge we know about
- Bracket-quoted schema-qualified names.
CREATE TABLE [dbo].[Patient]binds the table name todbo, because the bracket form matches the first bracketed token. Unquoteddbo.Patientreduces correctly toPatient. Strip the schema qualifier, or leave it unquoted. CHARACTER VARYING(50)parses asCHARACTER: the second word is only re-attached when it stands alone, as inDOUBLE PRECISION. PreferVARCHAR(50)in DDL you intend to round-trip.- Enum values are sanitized as term ids and re-emitted as ids.
ENUM('in progress','on-hold','DONE')comes back asENUM('inProgress', 'onHold', 'DONE'); the originals are kept as term labels. Machine-shaped enum values round-trip exactly. - A JSON-object comment with no SIA keys is consumed.
COMMENT '{"owner":"team-a"}'parses as JSON, offers nox-maps-toorx-sia-role, and is not kept as a description. Prose comments and malformed JSON are kept. - Apostrophes double on the inline-comment loop.
COMMENT 'it''s fine're-emits asCOMMENT 'it''''s fine'; theCOMMENT ON COLUMNpath unescapes correctly. Keep inline comments apostrophe-free. COMMENT ON COLUMNstatements are emitted unquoted, in PostgreSQL syntax, for bothpostgresandsqlserveroutput. Identifiers containing spaces, and SQL Server targets generally, need a hand edit.ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEYis not read. OnlyCREATE TABLEbodies contribute; declare foreign keys inline or as table-level clauses.- Other statements are ignored without error —
CREATE INDEX,CREATE VIEW,INSERT, and anything else that is notCREATE TABLEorCOMMENT ON COLUMN. - A
COMMENT ON COLUMNnaming a column that does not exist is ignored, silently. - A truncated
CREATE TABLE Broken (succeeds with nothing in it. The statement header matched, the body did not parse, and the result is an empty model — check that the type count is what you expected.CREATE TABLE e1 ();likewise yields a type with no elements. - Generated DDL is a description, not a migration. No key constraints are emitted, and tables are written in model order rather than dependency order.
- An
ENUMcolumn mapped through inference stops execution withReferenced taxonomy '<id>' does not resolve., because a SQL enum arrives unnamed and inference matches by label. Add aTaxonomyMappingto the plan, or move the table through a project's import and export, which do not run the mapping engine.
The stance
One promise runs through all of it: never guess silently where you can preserve verbatim or report honestly. Types the model cannot hold structurally are carried as spellings; meaning the format cannot hold natively goes through the one channel SQL offers, named so you can find it; constraints that cannot be represented are reported at the exact path. That is what makes a format with no annotation slot a full citizen of a transform surface built on semantic fidelity.
For the routes and tools that drive this coder — import, export, stateless mapping, and replayable plans — see the transform API guide in the CoreModels docs.