New rule: do something about the workarounds to "no subqueries in check constraints"
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
Research direction
No repository files or tests are named in the issue. Start by reviewing the existing check-constraint linting discussed in #1226 and validating the proposed PostgreSQL catalog and dependency query. Done means agreeing on a precise rule for detecting unsafe workarounds and defining implementation and regression coverage for it.
Written by the indexing model from the issue text.
Description
Extremely amusingly, I found #1226 which is suggesting linting against the case postgres catches of doing silly business in check constraints. This issue is about stopping the workarounds to that runtime error, because it turns out those are also a problem: they fail in pg_restore.
I'm raising this issue primarily to highlight that it's a way you can mess up (I blame AI for this landing in our prod environment though!), rather than a remotely important feature request, though I think it is actionable.
Quoth the documentation: https://www.postgresql.org/docs/current/ddl-constraints.html
PostgreSQL does not support CHECK constraints that reference table data other than the new or updated row being checked. While a CHECK constraint that violates this rule may appear to work in simple tests, it cannot guarantee that the database will not reach a state in which the constraint condition is false (due to subsequent changes of the other row(s) involved). This would cause a database dump and restore to fail. The restore could fail even when the complete database state is consistent with the constraint, due to rows not being loaded in an order that will satisfy the constraint. If possible, use UNIQUE, EXCLUDE, or FOREIGN KEY constraints to express cross-row and cross-table restrictions.
This syntactic restriction by Postgres can be dodged by using a function:
CREATE TABLE invoices (id uuid PRIMARY KEY, organization_id uuid);
CREATE TABLE payout_accounts (id uuid PRIMARY KEY, organization_id uuid);
CREATE FUNCTION org_matches(p_invoice_id uuid, p_destination_account_id uuid)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT p_invoice_id IS NULL
OR EXISTS (SELECT 1 FROM invoices inv
JOIN payout_accounts pa ON pa.id = p_destination_account_id
WHERE inv.id = p_invoice_id AND inv.organization_id = pa.organization_id);
$$;
CREATE TABLE invoice_line_items (
id uuid PRIMARY KEY,
invoice_id uuid,
destination_account_id uuid,
CONSTRAINT org_check CHECK (org_matches(invoice_id, destination_account_id))
);
INSERT INTO invoices VALUES ('11111111-1111-1111-1111-111111111111','99999999-9999-9999-9999-999999999999');
INSERT INTO payout_accounts VALUES ('22222222-2222-2222-2222-222222222222','99999999-9999-9999-9999-999999999999');
INSERT INTO invoice_line_items VALUES ('33333333-3333-3333-3333-333333333333','11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222');
Unfortunately, to block this with perfect precision and recall, the linter needs to see all functions that are defined, and probably still is busted with dynamic accesses of some kind.
However, one thing that could work is to ban non-builtin functions in check constraints. Thoughts about this idea? It would cause false positives for sure, but it might be okay.
An alternative is runtime analysis in the migrator (by refining the carelessly vibecoded query below), but based on my research on a very large codebase, the simpler "forbid all non-builtin functions" path should have <10 false positives on thousands of migrations for us at least.
extremely vibecoded query to soundly find them all, maybe?? not tested!
WITH RECURSIVE "check_constraints" /* Step 1: All CHECK constraints with their home table */ AS (
SELECT
"con"."oid" AS "constraint_oid",
"con"."conname" AS "constraint_name",
"con"."conrelid" AS "home_table_oid",
"c"."relname" AS "home_table_name",
"n"."nspname" AS "home_schema_name",
PG_GET_CONSTRAINTDEF("con"."oid") AS "constraint_definition"
FROM "pg_constraint" AS "con"
JOIN "pg_class" AS "c"
ON "c"."oid" = "con"."conrelid"
JOIN "pg_namespace" AS "n"
ON "n"."oid" = "c"."relnamespace"
WHERE
"con"."contype" = 'c'
AND NOT "n"."nspname" IN ('pg_catalog', 'information_schema')
), "constraint_functions" /* Step 2: Functions directly referenced by those CHECK constraints (non-builtin only) */ AS (
SELECT
"cc"."constraint_oid",
"p"."oid" AS "func_oid",
"p"."proname" AS "func_name",
"fn"."nspname" AS "func_schema"
FROM "check_constraints" AS "cc"
JOIN "pg_depend" AS "dep"
ON "dep"."objid" = "cc"."constraint_oid"
AND "dep"."classid" = CAST('pg_constraint' AS REGCLASS)
AND "dep"."refclassid" = CAST('pg_proc' AS REGCLASS)
JOIN "pg_proc" AS "p"
ON "p"."oid" = "dep"."refobjid"
JOIN "pg_namespace" AS "fn"
ON "fn"."oid" = "p"."pronamespace"
WHERE
NOT "fn"."nspname" IN ('pg_catalog', 'pg_internal')
), "all_funcs" /* Step 3: Recursively expand function -> function dependencies */ /* so we catch functions that call other custom functions */ AS (
/* Base: functions directly referenced by constraints */
SELECT
"cf"."constraint_oid",
"cf"."func_oid",
"cf"."func_name",
"cf"."func_schema"
FROM "constraint_functions" AS "cf"
UNION
/* Recursive: functions called by those functions */
SELECT
"af"."constraint_oid",
"p"."oid",
"p"."proname",
"fn"."nspname"
FROM "all_funcs" AS "af"
JOIN "pg_depend" AS "dep"
ON "dep"."objid" = "af"."func_oid"
AND "dep"."classid" = CAST('pg_proc' AS REGCLASS)
AND "dep"."refclassid" = CAST('pg_proc' AS REGCLASS)
JOIN "pg_proc" AS "p"
ON "p"."oid" = "dep"."refobjid"
JOIN "pg_namespace" AS "fn"
ON "fn"."oid" = "p"."pronamespace"
WHERE
NOT "fn"."nspname" IN ('pg_catalog', 'pg_internal')
), "func_table_deps" /* Step 4: Tables that each (transitively-reachable) function depends on */ AS (
SELECT
"af"."constraint_oid",
"af"."func_oid",
"af"."func_name",
"af"."func_schema",
"rel"."oid" AS "dep_table_oid",
"rel"."relname" AS "dep_table_name",
"tn"."nspname" AS "dep_table_schema"
FROM "all_funcs" AS "af"
JOIN "pg_depend" AS "dep"
ON "dep"."objid" = "af"."func_oid"
AND "dep"."classid" = CAST('pg_proc' AS REGCLASS)
AND "dep"."refclassid" = CAST('pg_class' AS REGCLASS)
JOIN "pg_class" AS "rel"
ON "rel"."oid" = "dep"."refobjid"
AND "rel"."relkind" = 'r' /* ordinary tables only */
JOIN "pg_namespace" AS "tn"
ON "tn"."oid" = "rel"."relnamespace"
WHERE
NOT "tn"."nspname" IN ('pg_catalog', 'information_schema', 'pg_internal')
)
/* Step 5: Flag constraints where a referenced table != the constraint's home table */
SELECT
"cc"."home_schema_name" AS "schema",
"cc"."home_table_name" AS "table",
"cc"."constraint_name",
"ftd"."func_schema" || '.' || "ftd"."func_name" AS "offending_function",
"ftd"."dep_table_schema" || '.' || "ftd"."dep_table_name" AS "cross_table_reference",
"cc"."constraint_definition"
FROM "check_constraints" AS "cc"
JOIN "func_table_deps" AS "ftd"
ON "ftd"."constraint_oid" = "cc"."constraint_oid"
WHERE
"ftd"."dep_table_oid" <> "cc"."home_table_oid" /* the cross-table condition */
ORDER BY
"cc"."home_schema_name",
"cc"."home_table_name",
"cc"."constraint_name"
- Dominant language
- Rust
- Stars
- 1.2k
- Forks
- 70
- Avg merge
- 52m
- Merged PRs (30d)
- 47
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from sbdchd/squawk
-
enhancement
Difficulty 4/5 3-5 days Newbie friendliness 48/100
-
enhancement
Difficulty 3/5 1-2 days Newbie friendliness 58/100
-
enhancement
Difficulty 3/5 1-2 days Newbie friendliness 56/100
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
-
enhancement
Difficulty 3/5 1-2 days Newbie friendliness 58/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
kwakseongjae/auto-hwp#319 ·
-
area:cli bug filter-quality good first issue priority:medium
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
bevyengine/bevy#25861 ·
-
comp-datalake
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
ClickHouse/ClickHouse#121222 ·
-
enhancement remote
Difficulty 2/5 1-3 hours Newbie friendliness 68/100