Summary
t.nodes.constraint() does not set is_enforced. pgsql-deparser@18.x interprets an absent is_enforced as false and emits NOT ENFORCED.
The result is that building a CHECK or FOREIGN KEY constraint with the project's own AST builder, and rendering it with the project's own deparser, produces a constraint that PostgreSQL will never enforce. No error is raised at any stage — the SQL is valid and executes successfully.
This is a disagreement between two first-party packages rather than a bug in either one alone. The deparser's interpretation is correct for parser output; constructed ASTs don't follow the same convention, and the deparser has no way to tell the two apart.
Affected versions
| Package |
Version |
|
@pgsql/utils |
18.2.8 |
builder omits is_enforced |
pgsql-deparser |
18.0.0 → 18.3.6 |
reads absent as NOT ENFORCED |
pgsql-deparser |
17.18.5 |
✅ unaffected |
Present from the first pgsql-deparser@18 release through current latest.
Reproduction
Using only first-party packages, following the pattern in the pgsql-deparser README:
import * as t from "@pgsql/utils";
import { deparseSync as deparse } from "pgsql-deparser";
const stmt = t.nodes.alterTableStmt({
relation: t.nodes.rangeVar({ relname: "t", inh: true, relpersistence: "p" }).RangeVar,
objtype: "OBJECT_TABLE",
cmds: [t.nodes.alterTableCmd({
subtype: "AT_AddConstraint",
behavior: "DROP_RESTRICT",
def: t.nodes.constraint({
contype: "CONSTR_CHECK",
conname: "x_positive",
raw_expr: t.nodes.aExpr({
kind: "AEXPR_OP",
name: [t.nodes.string({ sval: ">" })],
lexpr: t.nodes.columnRef({ fields: [t.nodes.string({ sval: "x" })] }),
rexpr: t.nodes.aConst({ ival: t.ast.integer({ ival: 0 }) }),
}),
}),
})],
});
console.log(deparse([{ RawStmt: { stmt } }]));
Expected:
ALTER TABLE t ADD CONSTRAINT x_positive CHECK (x > 0)
Actual:
ALTER TABLE t ADD CONSTRAINT x_positive CHECK (x > 0) NOT ENFORCED
The builder output is:
t.nodes.constraint({ contype: "CONSTR_CHECK", conname: "c" })
// { Constraint: { contype: 'CONSTR_CHECK', conname: 'c' } } <- no is_enforced
Passing is_enforced: true explicitly is preserved by the builder and produces correct SQL.
Scope
Affects the two constraint types PostgreSQL permits NOT ENFORCED on:
CHECK, builder default ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) NOT ENFORCED ❌
CHECK, is_enforced: true ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) ✅
FK, builder default ALTER TABLE t ADD CONSTRAINT f FOREIGN KEY(x) REFERENCES o (id) NOT ENFORCED ❌
FK, is_enforced: true ALTER TABLE t ADD CONSTRAINT f FOREIGN KEY(x) REFERENCES o (id) ✅
CONSTR_UNIQUE and CONSTR_PRIMARY are unaffected. Inline constraints in CREATE TABLE are affected the same way as ALTER TABLE ... ADD CONSTRAINT.
skip_validation is not part of the trigger — is_enforced alone determines it. Where skip_validation: true is set, NOT ENFORCED replaces the expected NOT VALID rather than accompanying it:
skip_validation |
is_enforced |
Output |
| absent |
absent |
NOT ENFORCED ❌ |
| absent |
true |
(none) ✅ |
true |
absent |
NOT ENFORCED ❌ — expected NOT VALID |
true |
true |
NOT VALID ✅ |
true |
false |
NOT ENFORCED ✅ |
Why the deparser's logic is not itself wrong
libpg_query omits false booleans from its JSON output, so for parsed ASTs an absent is_enforced genuinely does mean not-enforced. Confirmed with pgsql-parser@18.2.6:
| Parsed SQL |
is_enforced |
skip_validation |
CHECK (x > 0) |
true |
— |
CHECK (x > 0) NOT ENFORCED |
undefined |
true |
CHECK (x > 0) ENFORCED |
true |
— |
CHECK (x > 0) NOT VALID |
true |
true |
So parse() → deparse() round-trips correctly today, including NOT ENFORCED, and the deparser's is_enforced !== true check is right for that path.
Changing the deparser to treat absent as enforced would break this. Verified by applying is_enforced === false to 18.3.6 and re-running round-trips:
✗ in : ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) NOT ENFORCED
out: ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) NOT VALID
That silently converts a non-enforced constraint into an enforced one — the same class of failure in the opposite direction. The deparser cannot distinguish "false, omitted by convention" from "not specified by the caller"; both arrive as undefined.
Why this matters
NOT ENFORCED (PG18) means the constraint applies to neither existing nor new rows. Both plausible caller intents are silently violated:
- A bare CHECK or FK constraint should be fully enforced. It is inert instead.
NOT VALID should enforce for new rows while deferring validation of existing ones — the standard pattern for adding a constraint to a large table. It is inert instead, and the intended clause is dropped.
Nothing surfaces the problem: the SQL is valid, applies cleanly, and enforces nothing. For a migration tool this means shipped constraints that never take effect, discoverable only by inspecting the database afterwards.
This is reachable through the documented path — the pgsql-deparser README's primary example constructs an AST with @pgsql/utils and notes it "could have been obtained from any JSON or AST."
Suggested fix
Preferred: default is_enforced: true in the @pgsql/utils constraint builder for CONSTR_CHECK and CONSTR_FOREIGN. This aligns the builder with the serialization convention the deparser already assumes, fixes existing callers with no code change on their side, and leaves the deparser's parser-output handling untouched. It's clear that this is a large, invasive change given how pgsql/utils is built, so it's understandable if you don't want to pursue it.
Alternatives, if that isn't desirable:
- Add a Typescript type constraint that requires an explicit decision in the builder signature for
CONSTR_CHECK and CONSTR_FOREIGN. This would be a breaking change for consumers (though probably fixing a bug in their construction). I can provide a PR for this if you're interested.
- Add a deparser option (e.g.
deparse(ast, { assumeEnforced: true })) for callers constructing ASTs rather than round-tripping.
- Document in
packages/deparser/README.md under ## Options, and as TSDoc on Constraint.is_enforced, that omitting the field on a constructed AST yields NOT ENFORCED.
Documentation alone would leave every consumer needing to know an implicit serialization convention in order to avoid emitting non-enforcing constraints, so a default in the builder seems the more robust resolution.
Summary
t.nodes.constraint()does not setis_enforced.pgsql-deparser@18.xinterprets an absentis_enforcedasfalseand emitsNOT ENFORCED.The result is that building a CHECK or FOREIGN KEY constraint with the project's own AST builder, and rendering it with the project's own deparser, produces a constraint that PostgreSQL will never enforce. No error is raised at any stage — the SQL is valid and executes successfully.
This is a disagreement between two first-party packages rather than a bug in either one alone. The deparser's interpretation is correct for parser output; constructed ASTs don't follow the same convention, and the deparser has no way to tell the two apart.
Affected versions
@pgsql/utilsis_enforcedpgsql-deparserNOT ENFORCEDpgsql-deparserPresent from the first
pgsql-deparser@18release through currentlatest.Reproduction
Using only first-party packages, following the pattern in the
pgsql-deparserREADME:Expected:
Actual:
The builder output is:
Passing
is_enforced: trueexplicitly is preserved by the builder and produces correct SQL.Scope
Affects the two constraint types PostgreSQL permits
NOT ENFORCEDon:CONSTR_UNIQUEandCONSTR_PRIMARYare unaffected. Inline constraints inCREATE TABLEare affected the same way asALTER TABLE ... ADD CONSTRAINT.skip_validationis not part of the trigger —is_enforcedalone determines it. Whereskip_validation: trueis set,NOT ENFORCEDreplaces the expectedNOT VALIDrather than accompanying it:skip_validationis_enforcedNOT ENFORCED❌truetrueNOT ENFORCED❌ — expectedNOT VALIDtruetrueNOT VALID✅truefalseNOT ENFORCED✅Why the deparser's logic is not itself wrong
libpg_queryomits false booleans from its JSON output, so for parsed ASTs an absentis_enforcedgenuinely does mean not-enforced. Confirmed withpgsql-parser@18.2.6:is_enforcedskip_validationCHECK (x > 0)trueCHECK (x > 0) NOT ENFORCEDundefinedtrueCHECK (x > 0) ENFORCEDtrueCHECK (x > 0) NOT VALIDtruetrueSo
parse()→deparse()round-trips correctly today, includingNOT ENFORCED, and the deparser'sis_enforced !== truecheck is right for that path.Changing the deparser to treat absent as enforced would break this. Verified by applying
is_enforced === falseto 18.3.6 and re-running round-trips:That silently converts a non-enforced constraint into an enforced one — the same class of failure in the opposite direction. The deparser cannot distinguish "false, omitted by convention" from "not specified by the caller"; both arrive as
undefined.Why this matters
NOT ENFORCED(PG18) means the constraint applies to neither existing nor new rows. Both plausible caller intents are silently violated:NOT VALIDshould enforce for new rows while deferring validation of existing ones — the standard pattern for adding a constraint to a large table. It is inert instead, and the intended clause is dropped.Nothing surfaces the problem: the SQL is valid, applies cleanly, and enforces nothing. For a migration tool this means shipped constraints that never take effect, discoverable only by inspecting the database afterwards.
This is reachable through the documented path — the
pgsql-deparserREADME's primary example constructs an AST with@pgsql/utilsand notes it "could have been obtained from any JSON or AST."Suggested fix
Preferred: default
is_enforced: truein the@pgsql/utilsconstraint builder forCONSTR_CHECKandCONSTR_FOREIGN. This aligns the builder with the serialization convention the deparser already assumes, fixes existing callers with no code change on their side, and leaves the deparser's parser-output handling untouched. It's clear that this is a large, invasive change given howpgsql/utilsis built, so it's understandable if you don't want to pursue it.Alternatives, if that isn't desirable:
CONSTR_CHECKandCONSTR_FOREIGN. This would be a breaking change for consumers (though probably fixing a bug in their construction). I can provide a PR for this if you're interested.deparse(ast, { assumeEnforced: true })) for callers constructing ASTs rather than round-tripping.packages/deparser/README.mdunder## Options, and as TSDoc onConstraint.is_enforced, that omitting the field on a constructed AST yieldsNOT ENFORCED.Documentation alone would leave every consumer needing to know an implicit serialization convention in order to avoid emitting non-enforcing constraints, so a default in the builder seems the more robust resolution.