Skip to content
PtahDocs
v0.12.0

Graphic preview

100%
Page type: how-to

SQL schema

Use plain SQL DDL files as Ptah's desired schema.

Use SQL schema files when the desired schema is already written as local DDL (Data Definition Language). Ptah parses the file through its compatibility SQL parser; unsupported DDL fails explicitly instead of being skipped.

Create schema.sql:

CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
);

PostgreSQL extension placement is preserved too. In a separate extensions.sql, Ptah accepts both the optional WITH spelling and the bare SCHEMA clause:

CREATE SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions VERSION '1.3';

Render that PostgreSQL-specific file with the PostgreSQL dialect:

Terminal window
ptah schema render --schema-file extensions.sql --dialect postgres

Expected output includes the schema precondition before the extension:

CREATE SCHEMA IF NOT EXISTS "extensions";
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions" VERSION '1.3';

A column type may name its schema, quoted or not, the way pg_dump writes a type outside the search path: m app.mood, m "app"."Mood"[], g public.geometry(Point, 4326). A comparison matches it to the type the database reports by schema and name. A column moved to a type of the same name in another schema plans the change.

See PostgreSQL defaults.

Terminal window
ptah schema render --schema-file schema.sql --dialect sqlite

Expected output includes:

CREATE TABLE "users" (
"id" INTEGER PRIMARY KEY,
"email" TEXT NOT NULL
);

Rendering a SQL file back out proves the parser understood every statement, and can retarget the schema at another dialect. --schema-file is accepted wherever Ptah needs a desired schema: ptah schema render, ptah schema compare, ptah schema drift, the migration commands (ptah migrations plan / ptah migrations generate), and every target of ptah schema export except hcl. That includes the two documentation targets, so a Markdown or HTML reference can be generated from this file.

Path confinement is shared by every --schema-file source; see Schema file paths.

For PostgreSQL application roles, see Conditional role bootstrap.

A column can be added with ALTER TABLE ... ADD COLUMN in the same file, a schema directory, or an ordered atlas.hcl SQL src list. Columns follow declaration order.

CREATE TABLE users (id integer PRIMARY KEY);
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at timestamptz;

IF NOT EXISTS makes the statement do nothing for a column the table already declares the same way. A column declared again is refused otherwise: without IF NOT EXISTS the server refuses it, and with it the server keeps the first declaration, so either reading would drop what the other one says.

PostgreSQL 15 and later take a column list after ON DELETE SET NULL and ON DELETE SET DEFAULT. The action then changes only the listed columns, so the other columns of a composite key may be NOT NULL:

CREATE TABLE parents (tenant integer, id integer, PRIMARY KEY (tenant, id));
CREATE TABLE children (
id integer PRIMARY KEY,
tenant integer NOT NULL,
parent_id integer,
FOREIGN KEY (tenant, parent_id) REFERENCES parents (tenant, id)
ON DELETE SET NULL (parent_id)
);

Deleting a parent row sets parent_id to NULL and keeps tenant. The list is compared as a set of columns, and a list that names every column of the key means the same as no list.

A declaration the server would refuse, or would accept and then fail on when a parent row is deleted, is refused:

  • a NOT NULL column in the list, or a NOT NULL key column with no list;
  • a list after any action other than SET NULL or SET DEFAULT, or after ON UPDATE;
  • a column that is not part of the key. A column-level REFERENCES can list only its own column.

A target without the clause refuses the list instead of widening the action to every column: PostgreSQL before 15, YugabyteDB 2024.2, CockroachDB, and the other engines. The Go annotation and Atlas HCL exports refuse such a key for the same reason, because neither format can write the list.

Defer a check to the end of the transaction

Section titled “Defer a check to the end of the transaction”

A foreign key, a primary key, a UNIQUE and an EXCLUDE can put their check off. The clauses follow the constraint, on the table or on its column, in any order:

CREATE TABLE orders (id integer PRIMARY KEY);
CREATE TABLE lines (
id integer PRIMARY KEY,
order_id integer REFERENCES orders (id) DEFERRABLE INITIALLY DEFERRED,
position integer UNIQUE DEFERRABLE,
replaces integer,
FOREIGN KEY (replaces) REFERENCES lines (id) INITIALLY DEFERRED
);

INITIALLY DEFERRED alone makes the constraint deferrable, as on PostgreSQL. The reader refuses what PostgreSQL 18.6 refuses: DEFERRABLE beside NOT DEFERRABLE, two timings, INITIALLY DEFERRED beside NOT DEFERRABLE, a clause after a CHECK, and a clause after a column that declares no key, as in a integer NOT NULL DEFERRABLE.

A deferrable key on a column is read as the table’s key over the column, as PostgreSQL reports it. A key that defers is a different key from one that does not, so changing the clause plans a drop and an add. A foreign key cannot reference a deferrable key; PostgreSQL refuses it.

A render for a target without the clause refuses the constraint rather than write one that checks at once:

Target Foreign key Primary key, UNIQUE
PostgreSQL yes yes, and EXCLUDE
Oracle yes yes
YugabyteDB yes no
SQLite yes, DEFERRABLE first no
CockroachDB, MySQL, MariaDB no no

The reader refuses the clauses on MySQL, MariaDB and CockroachDB, which refuses even NOT DEFERRABLE, and on SQLite after anything but a foreign key. The Atlas HCL and Go annotation exports refuse a deferrable key, because neither format can write one. The clauses PostgreSQL takes for the index behind a key, WITH (...) and USING INDEX TABLESPACE, are refused by name.

These clauses after a table element change nothing the server builds, and the reader takes them:

  • ENFORCED after a CHECK, and after a foreign key on PostgreSQL. MySQL takes it after a CHECK only, and MariaDB not at all.
  • NOT VALID after a CHECK or a foreign key in a PostgreSQL CREATE TABLE, where the server records the constraint as validated.
  • MATCH SIMPLE after REFERENCES and its columns, before ON DELETE and ON UPDATE.
  • On MySQL and MariaDB, the index options VISIBLE, MariaDB’s NOT IGNORED, and USING BTREE or USING HASH after the parts of a key, the primary key included. A method written there is the one KEY k USING HASH (a) asks for, and the later clause wins.

The PostgreSQL and MySQL pages cover NOT ENFORCED, MATCH types, index COMMENT, INVISIBLE, IGNORED and KEY_BLOCK_SIZE [=] n. Primary keys keep COMMENT and KEY_BLOCK_SIZE too. NOT VALID after ALTER TABLE ... ADD is preserved.

The index options ENGINE_ATTRIBUTE and SECONDARY_ENGINE_ATTRIBUTE have no model fields and are refused by name. The reader also refuses a clause the dialect’s server rejects, such as ENFORCED after a UNIQUE.

A schema file is read as a script. An ALTER TABLE after the CREATE TABLE, in the same file, a later file of a schema directory or an imported file, changes the table the way the server would:

CREATE TABLE items (id integer NOT NULL, qty integer, note text, legacy text);
ALTER TABLE items ADD PRIMARY KEY (id);
ALTER TABLE items ALTER COLUMN qty SET DEFAULT 1, ALTER COLUMN qty SET NOT NULL;
ALTER TABLE items ALTER COLUMN note TYPE varchar(200);
ALTER TABLE items DROP COLUMN legacy;

DROP TABLE and DROP INDEX remove the object and what the server drops with it. A drop the server refuses is refused: an undeclared object without IF EXISTS, a table a foreign key or a PostgreSQL view still reads, the index behind a constraint, and the last index a MySQL or MariaDB foreign key needs.

On PostgreSQL, ALTER INDEX ... RENAME TO renames an index, and the constraint it backs. A column’s own UNIQUE becomes a named one. A name another relation holds is refused.

These operations are read:

  • ADD COLUMN, ADD PRIMARY KEY and ADD CONSTRAINT (UNIQUE, CHECK, FOREIGN KEY);
  • ALTER [COLUMN] ... SET DEFAULT, DROP DEFAULT, SET NOT NULL, DROP NOT NULL and [SET DATA] TYPE, with an optional USING;
  • DROP [COLUMN] [IF EXISTS] and RENAME [COLUMN] ... TO;
  • DROP CONSTRAINT [IF EXISTS] and RENAME CONSTRAINT, by the name the declaration gave the constraint or, for a column’s own UNIQUE, the name the server gives it;
  • the MySQL family’s MODIFY [COLUMN], DROP PRIMARY KEY, DROP FOREIGN KEY, DROP CHECK, DROP INDEX and RENAME INDEX, and SQL Server’s ALTER COLUMN with a new definition.

MODIFY keeps the column’s keys, as the server does. UNIQUE in it adds a key, so on a column with its own key it adds a second one, such as x_2 beside x.

Anything else is refused by name rather than read as nothing: an ALTER COLUMN action other than those above, RENAME TO, CASCADE, and an operation on a table the document does not declare. So is an operation the server would refuse:

  • a second primary key;
  • DROP NOT NULL or DROP COLUMN on a primary key column;
  • a missing column or constraint, or a name already held.

Dropping or renaming a column that an index, a constraint, an expression or another table’s foreign key still names is refused too, naming that object. PostgreSQL drops or follows the object with the column, but the schema file keeps its text. Drop the object first, or declare the column under its final name.

A comment can be set with a separate COMMENT ON, the way pg_dump writes one, in the same file or in a later file of a schema directory:

CREATE TABLE notes (id integer PRIMARY KEY, body text);
COMMENT ON TABLE notes IS 'what users wrote';
COMMENT ON COLUMN notes.body IS 'the text, as typed';

COMMENT ON is read in the forms pg_dump writes for the objects Ptah models:

COMMENT ON FUNCTION app.score(integer, text) IS 'ranks a note';
COMMENT ON MATERIALIZED VIEW app.totals IS 'refreshed nightly';
COMMENT ON TRIGGER stamp_notes ON app.notes IS 'sets updated_at';
COMMENT ON POLICY own_notes ON app.notes IS 'owners only';
COMMENT ON CONSTRAINT positive ON app.notes IS 'ids start at one';
COMMENT ON TYPE app.mood IS 'how a user feels';

That covers a table, a column, an index, a constraint, a schema, a role, a view, a materialized view, a sequence, a domain, a composite, range or enum type, an extension, a function, a procedure, a trigger and a policy, and the plan writes each of them to the database. A function or a procedure may be named with its argument list, with or without argument names, or by its name alone when the document declares one routine of that name. A trigger, a policy and a constraint are named ON their table.

A statement that cannot be applied is refused by name, with the file it came from:

  • a comment on an object the document does not declare, or on a trigger or a policy of another table;
  • a routine named by its name alone when the document declares more than one overload of it, or a function named as a procedure;
  • a comment on a domain’s constraint (ON DOMAIN) or on any other kind of object, such as a foreign table or a cast;
  • IS NULL.

A document may declare several overloads of a function or a procedure, as pg_dump writes them. Each is a routine of its own, told apart by its input argument types, so COMMENT ON FUNCTION app.f(integer, text) names one of them.

A PostgreSQL schema file declares row-level security with the statements a migration would run:

CREATE TABLE sites (id uuid PRIMARY KEY, tenant_id uuid NOT NULL);
ALTER TABLE sites ENABLE ROW LEVEL SECURITY;
ALTER TABLE sites FORCE ROW LEVEL SECURITY;
CREATE POLICY sites_tenant ON sites
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY sites_scope ON sites AS RESTRICTIVE
USING (current_setting('app.site_scope', true) = 'all');

FORCE binds the table’s owner to its policies; without it the owner reads and writes past every one of them. ENABLE and FORCE may come in either order. AS RESTRICTIVE narrows what the permissive policies admit, and AS PERMISSIVE is the default. Both flags are compared with the database and planned in both directions; PostgreSQL has the details.

Three spellings are refused rather than read. DISABLE ROW LEVEL SECURITY and NO FORCE ROW LEVEL SECURITY take a protection away, and a schema file says a table has none by not declaring it. A FORCE for a table the file never enables is refused too: PostgreSQL accepts it, and it changes nothing until the table enables row-level security.

SQL DDL cannot author Ptah’s export-only api_name, openapi_name, graphql_name, proto_name, api_type, or api_expose metadata. Exports work from a SQL schema, with public names, types, and exposure derived from the persistence schema. Use YAML, HCL, or Go annotations when the published contract must differ from database names and types.

--dialect decides how the file is read, not only how it is written

Section titled “--dialect decides how the file is read, not only how it is written”

The dialect selects the tokenizer as well as the renderer: whether a backslash escapes inside a string, whether E'...' is an escape string, whether a string continues on the next line, whether --x without a space is a comment, and whether [name] is an identifier.

A file that the named engine would reject is rejected here. PostgreSQL runs with standard_conforming_strings on, so a backslash is an ordinary character and DEFAULT 'a\'b' is an unterminated string — PostgreSQL 18 answers unterminated bit string literal. Read with --dialect postgres, Ptah refuses it too. Read with --dialect mysql, where a backslash escapes, the same bytes are a valid default.

A PostgreSQL string constant is read in every spelling the server reads. With --dialect postgres, a comment, a column or domain default and an enum label may each be written as a dollar-quoted string, an E'...' escape string, a U&'...' Unicode escape string with an optional UESCAPE clause, or a string continued on the next line:

COMMENT ON COLUMN notes.body IS 'the text, '
'as typed';
COMMENT ON TABLE notes IS $c$what users wrote, apostrophes and all$c$;

Each reads as the one string the server stores, so the file compares clean against a database it was applied to. The continuation follows PostgreSQL’s rule: only whitespace between the two strings, with at least one newline in it, and a -- comment counts as whitespace. Two strings on one line, or with a block comment between them, are refused, as PostgreSQL refuses them, and so is an escape the server rejects, such as U&'\0000'. With --dialect cockroachdb, yugabytedb or spanner, a comment, default or label written as a continued string or a U&'...' string is refused: CockroachDB applies a different continuation rule and has no U&'...' strings, and the other two have not been measured.

A version-guarded span is stepped over, and the one clause a schema needs is read out of it. mysqldump writes a full-text index’s parser as FULLTEXT KEY `ft` (`bio`) /*!50100 WITH PARSER `ngram` */, and that clause reaches the schema. The rest of a guard is not read: those spans hold version-conditional fragments Ptah does not model, and mariadb-dump opens every file with /*M!999999\- enable the sandbox mode */ — a guard no server executes, because no server is version 999999.

An unquoted name folds the way the engine folds it. PostgreSQL and YugabyteDB lower the ASCII letters of an unquoted name, so CREATE TABLE Docs (Id integer) declares the table docs with the column id, and a later ALTER TABLE DOCS or CREATE POLICY p ON docs names that table. CockroachDB lowers every letter of an unquoted name, past ASCII too. A quoted name keeps its case on all three. The rule covers every name the file writes: tables, columns, indexes, constraints, policies, roles, and the object a statement such as GRANT or CREATE INDEX names. Other dialects, and a read with no dialect, keep a name as written.

An ALTER TABLE reaches the table and column its dialect’s server would find for the names it writes. "Docs" and docs are two tables on PostgreSQL, and a file may declare both: each keeps its own columns, and ALTER TABLE docs changes docs. In a file that declares only "Docs", the same statement is refused, as PostgreSQL refuses it. ClickHouse compares names exactly too. SQLite ignores the case of ASCII letters, quoted or not, so ALTER TABLE docs reaches Docs there, and ärger does not reach Ärger. SQL Server, under its default case-insensitive collation, ignores case past ASCII as well. MySQL and MariaDB compare column names without case. A MySQL table name follows the server’s lower_case_table_names, which a schema file does not carry, so a spelling reaches a table that matches it exactly, or else the one table that differs from it only in case. A read with no dialect compares exactly.

Role names differ on CockroachDB, which lowers every role name, quoted or not: read with --dialect cockroachdb, "App_Reader" names the role app_reader, and "PUBLIC" is the PUBLIC keyword.

Omitting --dialect keeps a permissive reader. No dialect means no dialect’s rules, which is what lets one file mixing conventions be read at all. Name the dialect when the file belongs to one engine.

Everything a desired schema is for is the same for every source and lives on Work with a desired schema. For SQL the flag is --schema-file. What follows is specific to this source.

A SQL schema file can pull in other SQL files with an atlas:import comment, one per line:

-- schema/main.sql
-- atlas:import ./tables/orders.sql
-- atlas:import ./tables/users.sql

Point --schema-file at the entry point and the declarations merge in the order the file lists them. The entry point may declare objects of its own, and an imported file may import in turn. ptah-compat schema inspect writes this layout when its output goes through split, so an export reads back as the schema it was taken from.

Each path is relative to the file that writes it and must stay inside the entry point’s directory. An absolute path, a path that climbs out with .., a missing file, one that is not .sql, and a cycle are each refused by name.

ptah schema diff compares local SQL files directly. With old.sql describing the deployed shape and schema.sql adding a pets table, a dev database replays both sides:

Terminal window
ptah schema diff \
--from old.sql \
--to schema.sql \
--dev-url "sqlite://dev?mode=memory"

Expected output includes:

CREATE TABLE "pets" (
"id" INTEGER PRIMARY KEY,
"name" TEXT NOT NULL,
"user_id" INTEGER NOT NULL CONSTRAINT "fk_pets_user_id" REFERENCES "users" ("id")
);
  • A change that a SQLite dev database cannot express as an in-place ALTER is refused loudly rather than turned into an incomplete diff. For example, adding a NOT NULL column to an existing table exits with sqlite: adding column email to table users requires a table rebuild plan.

  • Unsupported DDL constructs fail with a parse error naming the statement. Treat the error as a compatibility gap and check the conformance reports.

  • A table element ends at a comma or at the closing parenthesis. A clause the reader does not take after a column or constraint is refused by its first word rather than read as another column:

    CREATE TABLE t (a integer, CHECK (a > 0) NO INHERIT);
    unexpected NO after a table element at position 41: expected ',' or ')'
  • A constraint name on DEFAULT is refused. Ptah keeps a name on NOT NULL, CHECK, REFERENCES, UNIQUE and PRIMARY KEY where the dialect’s grammar takes one; the last two are read as the table constraint they describe, which is the level a name lives at. A default has no such level and no engine Ptah supports records one:

    CREATE TABLE t (b INTEGER CONSTRAINT c_x DEFAULT 1);
    named column constraint "c_x" at position 41: Ptah has nowhere to keep a name
    on DEFAULT, and does not read one back from a database, so write the
    constraint without a name; a name is kept on NOT NULL, CHECK, REFERENCES,
    UNIQUE and PRIMARY KEY

    Write b INTEGER DEFAULT 1 instead. A name Ptah accepts and cannot read back would make every later comparison report a difference no apply can settle.

  • An index name between FOREIGN KEY and its column list is read under --dialect mysql and --dialect mariadb, and refused elsewhere. On MySQL it names the index the server builds for the key, which is not built where another index covers the key and gives way to a later index that begins with its columns. On MariaDB it is the key’s own name, and the key’s index takes it. No other engine has the syntax:

    CREATE TABLE child (a INT, FOREIGN KEY zidx (a) REFERENCES parents (id));
    an index name after FOREIGN KEY at position 39 is the MySQL family's alone;
    postgres has no such syntax, so write the key as FOREIGN KEY (columns) and
    declare the index "zidx" separately

    A name written beside an explicit CONSTRAINT symbol is accepted and ignored, because both engines record the symbol for the backing index too.

  • CONSTRAINT without a name is read under --dialect mysql and --dialect mariadb, where the name is optional, and refused elsewhere. On those engines CONSTRAINT FOREIGN KEY (a) REFERENCES parents (id) is the same unnamed key as FOREIGN KEY (a) REFERENCES parents (id), and it takes the same name. Every other dialect requires the name:

    CREATE TABLE child (a INT, CONSTRAINT FOREIGN KEY (a) REFERENCES parents (id));
    CONSTRAINT at position 27 is followed by FOREIGN, not by a name: postgres
    requires a name after CONSTRAINT, and only MySQL and MariaDB accept the
    keyword without one; name the constraint, or drop the CONSTRAINT keyword

    The MySQL family accepts the form at table level before PRIMARY KEY, UNIQUE, FOREIGN KEY and CHECK. On a column, MySQL accepts it only before CHECK and MariaDB only before REFERENCES. The engine answers ERROR 1064 to every other column spelling, and Ptah refuses it too. A CONSTRAINT in front of KEY, INDEX, FULLTEXT or SPATIAL is refused on every dialect, with a name or without one, because an index takes no CONSTRAINT keyword.

  • A named column constraint is read under --dialect mysql only before CHECK, and under --dialect mariadb only before REFERENCES. On a column, each engine takes CONSTRAINT before that one kind alone, with a name or without one. MySQL 8.4, MySQL 26.7 and MariaDB 11.8 answer ERROR 1064 to every other kind, and Ptah refuses it too:

    CREATE TABLE t (a INT CONSTRAINT uq UNIQUE);
    CONSTRAINT uq at position 22 is followed by UNIQUE: on a column, mysql
    accepts CONSTRAINT with a name only before CHECK, and answers ERROR 1064
    (42000) to this; drop CONSTRAINT uq

    Drop the name, or declare the constraint at table level to keep it, as in CONSTRAINT uq UNIQUE (a). PostgreSQL takes a name before every column constraint, so --dialect postgres reads a named UNIQUE, PRIMARY KEY, CHECK, REFERENCES and NOT NULL on a column.

  • A column-level REFERENCES clause is refused under --dialect mysql. What MySQL builds from it depends on the server version: MySQL 8.4 accepts the syntax and builds nothing, while MySQL 9.7 and 26.7 build a foreign key and its index. Ptah reads a SQL file with its dialect alone: neither --server-version nor the version of the database it connects to reaches the reader, so either reading would be wrong on one of those lines:

    CREATE TABLE child (a INT REFERENCES parents (id));
    a column-level REFERENCES clause at position 26: MySQL 8.4 builds nothing from
    the clause, while MySQL 9.7 and 26.7 build a foreign key and its index, and
    the SQL file is read without the server version, so Ptah refuses the clause
    rather than guess which schema it declares; write a table-level FOREIGN KEY
    clause, which every MySQL line builds

    Write the relationship as a table-level FOREIGN KEY (a) REFERENCES parents (id) instead. MariaDB enforces the column-level spelling and builds a backing index for it, so --dialect mariadb reads it unchanged.

  • A REFERENCES clause in the column definition of ALTER TABLE ... MODIFY is refused under --dialect mysql and --dialect mariadb, with CONSTRAINT in front or without it. MySQL 8.4, 9.7 and 26.7 and MariaDB 10.11, 11.8 and 12.3 each answer ERROR 1064 to it, while ADD COLUMN takes the same clause. MariaDB takes no CONSTRAINT at all in a MODIFY column definition, so Ptah refuses a CONSTRAINT ... CHECK there too:

    ALTER TABLE c MODIFY a INT REFERENCES p (id);
    REFERENCES at position 27 in ALTER TABLE ... MODIFY: mariadb takes no
    REFERENCES clause in a MODIFY column definition, and answers ERROR 1064
    (42000) to one; add the key with ALTER TABLE ... ADD FOREIGN KEY

    Add the key with ALTER TABLE c ADD FOREIGN KEY (a) REFERENCES p (id).

  • ALTER TABLE ... ADD KEY adds a secondary index on MySQL and MariaDB, in every spelling the engines take: ADD KEY, ADD INDEX, ADD SPATIAL KEY and ADD FULLTEXT KEY, with a key part’s prefix length and direction. A UNIQUE key stays a constraint, because it is a uniqueness guarantee rather than an index alone. On ClickHouse, ADD INDEX declares a data-skipping index, as the same keyword does inside a table body.

  • ALTER TABLE ... ADD PRIMARY KEY is read onto the table it names, with its prefix length and direction, exactly as the same key written inside the CREATE TABLE would be. A statement naming a table the document does not declare is refused rather than dropped:

    ALTER TABLE nosuch ADD PRIMARY KEY (a);
    the schema model has no place for this statement: ALTER TABLE nosuch ADD
    PRIMARY KEY names a table this schema does not declare

    A primary key has nowhere to live without its table, and the document is not one any engine would run either. Declare the table first, in the same file or in an earlier one, or drop the statement. Every other ALTER TABLE operation is refused the same way.

  • A routine whose body Ptah did not parse is refused rather than dropped. The parser understands the outer boundary of every CREATE PROCEDURE and CREATE FUNCTION it accepts; where it cannot model the body, it keeps the text — and text nothing read cannot be compared, so it has no place in a desired schema:

    CREATE PROCEDURE bump() SET @counter = @counter + 1;
    the schema model has no place for this statement: a mysql procedure whose
    body was kept as text rather than parsed, so nothing here can compare it:
    CREATE PROCEDURE bump() SET @counter = @counter + 1

    Write the body in a form Ptah reads — a BEGIN ... END block, or a RETURN — or keep the routine out of the desired schema and manage it separately. Carried silently, the routine would be missing from the desired schema: a comparison against a database that has it reports no difference, and a migration against one that does not plans it out of existence.

  • A constraint name on NOT NULL is carried where the target persists it. The distinction is not whether the syntax parses: PostgreSQL 17 accepts CONSTRAINT c_x NOT NULL and stores nothing, while PostgreSQL 18 records one row per NOT NULL in pg_constraint with contype = 'n', keyed to the column through conkey, and can drop, add and rename it by name. MySQL and MariaDB answer ERROR 1064 (42000) for the syntax outright, so --dialect mysql and --dialect mariadb refuse it when the file is read. Elsewhere the name is gated on the target’s measured capability, and a target that cannot keep it refuses the declaration rather than silently dropping the name.