From 09a2a4d755da6cf4aacb282d3cd55d8b7663e022 Mon Sep 17 00:00:00 2001 From: sanketsahu Date: Tue, 6 Oct 2026 12:48:34 -0700 Subject: [PATCH 1/5] fix: Postgres parity gaps found by diffing 150 real project migrations against PGlite Adds tools/corpus-diff, which runs real migrations + seeds through pg-mem and PGlite side by side (catalogue, data, RLS, triggers, cascades, and generated reject/DDL probes). Across 150 projects, divergence went from every project to 1-4 per 50 (remaining: cosmetic default text, FK action ordering, non-transactional DDL). Validator strictness (pg-mem accepted what Postgres rejects): - typed values no longer cross type categories implicitly (uuid = text, bool = text, ...); untyped literals and parameters still coerce; date/timestamptz and int/bigint now widen - CREATE POLICY binds its predicates (unknown columns, bad operators, non-boolean, USING on INSERT / WITH CHECK on SELECT|DELETE) - ALTER COLUMN TYPE / DROP COLUMN refuse columns a policy (on any table) or FK depends on - CREATE TRIGGER resolves its function; GRANT / CREATE POLICY ... TO check the role exists - int4/int2/int8 range checks; strict text-to-number parsing Things pg-mem rejected or got wrong: - BEGIN ... ROLLBACK across query calls actually rolls back; aborted blocks behave like Postgres - CREATE TABLE IF NOT EXISTS on an existing table no longer "Not supported" - unnamed constraints/indexes get Postgres names (t_col_check, t_a_b_key, t_a_b_idx) - NULL IN (...) is NULL, so CHECK (col IN (...)) accepts NULL - now()/current_timestamp/current_date are transaction-stable; current_date is a date - SECURITY DEFINER functions run as their owner - plpgsql: multi-char operators (->>) in bodies, %ROWTYPE / %TYPE variables - timestamp - timestamp, date - date (integer), interval::text, record::text, jsonb ? ?| ?& - text->numeric stores the canonical decimal string (fixes "text.trim is not a function") - information_schema.columns reports Postgres type names; DROP TABLE frees index names and removes dependent FKs from the catalogues; DROP VIEW; Postgres FK error messages Needs @tinbase/pgsql-ast-parser with the matching grammar changes (SET search_path, DROP VIEW). Co-Authored-By: Claude Opus 5.5 --- bun.lockb | Bin 478686 -> 479075 bytes package.json | 1 + src/column.ts | 26 +- src/constraints/foreign-key.ts | 15 +- src/datatypes/datatypes.ts | 128 ++++- src/datatypes/t-interval.ts | 23 +- src/datatypes/t-record.ts | 25 +- src/datatypes/t-timestamp.ts | 11 + src/db.ts | 2 + src/evaluator.ts | 12 +- src/execution/plpgsql.ts | 101 +++- src/execution/policy-deps.ts | 99 ++++ src/execution/roles.ts | 27 + src/execution/schema-amends/alter.ts | 24 + .../schema-amends/create-function.ts | 26 +- src/execution/schema-amends/create-policy.ts | 31 +- src/execution/schema-amends/create-table.ts | 10 +- src/execution/schema-amends/create-trigger.ts | 11 +- src/execution/schema-amends/create-view.ts | 3 + src/execution/statement-exec.ts | 39 +- src/execution/transaction-statements.ts | 2 +- src/functions/date.ts | 13 +- src/functions/string.ts | 3 +- src/interfaces-private.ts | 29 +- src/parser/expression-builder.ts | 46 +- src/parser/function-call.ts | 8 +- src/schema/information-schema/columns-list.ts | 55 +- src/schema/overload-resolver.ts | 19 +- src/schema/pg-catalog/binary-operators.ts | 66 ++- src/schema/prepared.ts | 149 ++++-- src/schema/view.ts | 3 +- src/table.ts | 71 ++- src/tests/conversions.spec.ts | 3 +- src/tests/corpus-parity.spec.ts | 307 +++++++++++ src/tests/insert.queries.spec.ts | 3 +- src/tests/rls-ddl.spec.ts | 2 + src/transaction.ts | 20 +- src/utils.ts | 5 + tools/corpus-diff/README.md | 54 ++ tools/corpus-diff/bootstrap.ts | 80 +++ tools/corpus-diff/engines.ts | 68 +++ tools/corpus-diff/run.ts | 478 ++++++++++++++++++ 42 files changed, 1979 insertions(+), 119 deletions(-) create mode 100644 src/execution/policy-deps.ts create mode 100644 src/tests/corpus-parity.spec.ts create mode 100644 tools/corpus-diff/README.md create mode 100644 tools/corpus-diff/bootstrap.ts create mode 100644 tools/corpus-diff/engines.ts create mode 100644 tools/corpus-diff/run.ts diff --git a/bun.lockb b/bun.lockb index 935deb7a0a9cbb7b7b9837ba68598b96ee9bfb43..66c738d50fe769119c97c29e62bfcc97be610464 100755 GIT binary patch delta 95720 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 Gtoc8+K)jg% diff --git a/package.json b/package.json index 65fa7826..cac95df3 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@babel/cli": "^7.10.5", "@babel/core": "^7.10.5", "@babel/preset-env": "^7.10.4", + "@electric-sql/pglite": "0.5.4", "@hot-loader/react-dom": "^16.13.0", "@mikro-orm/core": "^5.8.1", "@mikro-orm/postgresql": "^5.8.1", diff --git a/src/column.ts b/src/column.ts index 8facf359..822727fa 100644 --- a/src/column.ts +++ b/src/column.ts @@ -1,7 +1,8 @@ import { _Column, IValue, _IIndex, NotSupported, _Transaction, QueryError, _IType, SchemaField, ChangeHandler, nil, ISubscription, DropHandler } from './interfaces-private'; import type { MemoryTable } from './table'; import { Evaluator } from './evaluator'; -import { ColumnConstraint, AlterColumn, Expr, toSql } from 'pgsql-ast-parser'; +import { ColumnConstraint, AlterColumn, Expr, toSql, DataTypeDef } from 'pgsql-ast-parser'; +import { policiesDependingOn } from './execution/policy-deps'; import { ignore, nullIsh } from './utils'; import { columnEvaluator } from './transforms/selection'; import { BIndex } from './schema/btree-index'; @@ -29,10 +30,18 @@ export class ColRef implements _Column { usedInIndexes = new Set(); private drophandlers = new Set(); + /** + * The type as written in the DDL. pg-mem folds smallint into integer, real into double and + * char(n) into text, but information_schema reports the declared type, and a generator + * reading data_type should see `smallint`, not `integer`. + */ + declaredType: DataTypeDef | nil; + constructor(readonly table: MemoryTable , public expression: Evaluator , _schema: SchemaField , public name: string) { + this.declaredType = (_schema as any)?.declaredType ?? (_schema as any)?.dataType ?? null; } addConstraints(clist: ColumnConstraint[], t: _Transaction): this { @@ -178,6 +187,10 @@ export class ColRef implements _Column { break; case 'set type': const newType = this.table.ownerSchema.getType(alter.dataType); + if (policiesDependingOn(this.table, this.expression.id!).length) { + // postgres stores policies as bound expressions over the column's type + throw new QueryError('cannot alter type of a column used in a policy definition', '0A000'); + } // USING gives an explicit per-row conversion (referencing the // column and any others); without it, an implicit cast is used. const conv = alter.using @@ -235,15 +248,24 @@ export class ColRef implements _Column { } checkConstraints(toInsert: any, t: _Transaction) { + const col = this.expression.get(toInsert, t); + if (this.isSmallint && typeof col === 'number' && (col < -32768 || col > 32767)) { + // smallint is stored as integer here; enforce int2's range like postgres does + throw new QueryError('smallint out of range', '22003'); + } if (!this.notNull) { return; } - const col = this.expression.get(toInsert, t); if (nullIsh(col)) { throw new QueryError(`null value in column "${this.expression.id}" violates not-null constraint`); } } + private get isSmallint() { + const n = (this.declaredType as any)?.name?.toLowerCase(); + return n === 'smallint' || n === 'int2' || n === 'smallserial'; + } + setDefaults(toInsert: any, t: _Transaction) { const col = this.expression.get(toInsert, t); if (col !== undefined) { diff --git a/src/constraints/foreign-key.ts b/src/constraints/foreign-key.ts index 6d70d937..d06cb6cd 100644 --- a/src/constraints/foreign-key.ts +++ b/src/constraints/foreign-key.ts @@ -46,6 +46,11 @@ export class ForeignKey implements _IConstraint { return this.foreignTable.name; } + /** The referenced table, for dependency checks (DROP TABLE / DROP COLUMN refuse to orphan this FK). */ + get referencedTable(): _ITable { + return this.foreignTable; + } + constructor(readonly name: string) { } @@ -130,7 +135,7 @@ export class ForeignKey implements _IConstraint { switch (neu ? onUpdate : onDelete) { case 'no action': case 'restrict': - throw new QueryError(`update or delete on table "${ftable.name}" violates foreign key constraint on table "${this.name}"`); + throw new QueryError(`update or delete on table "${ftable.name}" violates foreign key constraint "${this.name}" on table "${table.name}"`, '23503'); case 'cascade': if (neu) { for (let i = 0; i < fcols.length; i++) { @@ -191,7 +196,7 @@ export class ForeignKey implements _IConstraint { yielded = true; } if (!yielded) { - throw new QueryError(`insert or update on table "${ftable.name}" violates foreign key constraint on table "${this.name}"`); + throw new QueryError(`insert or update on table "${table.name}" violates foreign key constraint "${this.name}"`, '23503'); } }; if (deferred) { @@ -243,8 +248,14 @@ export class ForeignKey implements _IConstraint { return this; } + /** set by the owning table: drops this FK from its constraint list (and so the catalogues) */ + onUninstalled?: () => void; + uninstall(t: _Transaction): void { this.unsubs.forEach(x => x.unsubscribe()); this.unsubs = []; + // also when uninstalled from the other side (DROP TABLE CASCADE), not only + // via the table's own ConstraintWrapper - or the catalogues keep listing a dead FK + this.onUninstalled?.(); } } \ No newline at end of file diff --git a/src/datatypes/datatypes.ts b/src/datatypes/datatypes.ts index bb942743..56467c9a 100644 --- a/src/datatypes/datatypes.ts +++ b/src/datatypes/datatypes.ts @@ -101,6 +101,26 @@ export function isInteger(t: DataType | IType) { return integers.has(type); } +const INT4_MIN = -2147483648; +const INT4_MAX = 2147483647; +const INT8_MIN = BigInt('-9223372036854775808'); +const INT8_MAX = BigInt('9223372036854775807'); + +/** Postgres stores integer as int4: a value outside it is an error, not a silently wider number. */ +export function checkInt4(n: number): number { + if (n < INT4_MIN || n > INT4_MAX) { + throw new QueryError('integer out of range', '22003'); + } + return n; +} + +export function checkInt8(n: bigint): bigint { + if (n < INT8_MIN || n > INT8_MAX) { + throw new QueryError('bigint out of range', '22003'); + } + return n; +} + class NumberType extends TypeBase { constructor(readonly primary: DataType, typeId: number) { @@ -122,12 +142,16 @@ class NumberType extends TypeBase { } doPrefer(type: _IType): _IType | null { + // the wider of the two, as postgres' implicit numeric promotion does: + // int4 < int8 < numeric < float8 (so int = bigint compares as bigint, whichever side it is on) switch (type.primary) { case DataType.integer: - case DataType.bigint: return this; - case DataType.float: + case DataType.bigint: + return this.primary === DataType.integer ? type : this; case DataType.decimal: + return this.primary === DataType.float ? this : type; + case DataType.float: return type; } return null; @@ -157,9 +181,11 @@ class NumberType extends TypeBase { , value , (raw, t) => { const got = value.get(raw, t); - return typeof got === 'number' - ? Math.round(got) - : got; + if (typeof got !== 'number') { + return got; + } + const r = Math.round(got); + return to.primary === DataType.integer ? checkInt4(r) : r; } ); } @@ -244,7 +270,7 @@ class BigIntType extends TypeBase { case DataType.text: return value.setType(to); case DataType.integer: - return value.setType(to).setConversion((s: string) => Number(BigInt(s)), toInt => ({ toInt })); + return value.setType(to).setConversion((s: string) => checkInt4(Number(BigInt(s))), toInt => ({ toInt })); case DataType.float: return value.setType(to).setConversion((s: string) => Number(s), toFloat => ({ toFloat })); case DataType.decimal: @@ -296,7 +322,7 @@ class DecimalType extends TypeBase { case DataType.float: return value.setType(to).setConversion((s: string) => Number(s), toFloat => ({ toFloat })); case DataType.integer: - return value.setType(to).setConversion((s: string) => Number(Decimal.fromText(s).round(0).toString()), toInt => ({ toInt })); + return value.setType(to).setConversion((s: string) => checkInt4(Number(Decimal.fromText(s).round(0).toString())), toInt => ({ toInt })); case DataType.bigint: return value.setType(to).setConversion((s: string) => Decimal.fromText(s).round(0).toString(), toBig => ({ toBig })); } @@ -516,17 +542,46 @@ class TextType extends TypeBase { } if (numbers.has(to.primary)) { - const isInt = integers.has(to.primary); + // parse like postgres' input functions: the whole string (surrounding blanks aside) + // must be a number - parseFloat('12abc') = 12 is not - and produce the representation + // the target type stores (numeric and bigint are digit strings, not JS numbers) + const pgName = to.primary === DataType.decimal ? 'numeric' : to.primary === DataType.float ? 'double precision' : to.primary; + const scale = (to as any).scale as number | null | undefined; return value .setConversion(str => { - const val = Number.parseFloat(str); - if (!Number.isFinite(val)) { - throw new QueryError(`invalid input syntax for ${to.primary}: ${str}`); - } - if (isInt && Math.floor(val) !== val) { - throw new QueryError(`invalid input syntax for ${to.primary}: ${str}`) + const s = String(str).trim(); + const bad = () => new QueryError(`invalid input syntax for type ${pgName}: "${str}"`, '22P02'); + switch (to.primary) { + case DataType.integer: + if (!/^[+-]?\d+$/.test(s)) { + throw bad(); + } + return checkInt4(Number(s)); + case DataType.bigint: + if (!/^[+-]?\d+$/.test(s)) { + throw bad(); + } + return checkInt8(BigInt(s)).toString(); + case DataType.decimal: { + if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(s) && !/^nan$/i.test(s)) { + throw bad(); + } + const d = Decimal.fromText(s); + return (scale === null || scale === undefined ? d : d.round(scale)).toString(); + } + default: { + if (/^[+-]?inf(inity)?$/i.test(s)) { + return s.startsWith('-') ? -Infinity : Infinity; + } + if (/^nan$/i.test(s)) { + return NaN; + } + if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(s)) { + throw bad(); + } + return Number(s); + } } - return val; } , castNum => ({ castNum, to: to.primary })); } @@ -906,6 +961,46 @@ export function reconciliateTypes(values: IValue[], nullIfNoMatch?: boolean, str +/** + * Postgres type category (pg_type.typcategory) of the built-in types pg-mem models. + * Postgres has no implicit cast between these categories: a typed value only crosses one + * through an explicit cast, so `uuid_col = text_col` is "operator does not exist: uuid = text". + * Untyped literals and bind parameters are exempt - they are 'unknown' and coerce to anything. + */ +function typeCategory(t: _IType): string | null { + switch (t.primary) { + case DataType.text: + case DataType.citext: + return 'string'; + case DataType.uuid: + return 'uuid'; + case DataType.bool: + return 'bool'; + case DataType.json: + case DataType.jsonb: + return 'json'; + case DataType.integer: + case DataType.bigint: + case DataType.float: + case DataType.decimal: + return 'numeric'; + case DataType.date: + case DataType.timestamp: + case DataType.timestamptz: + return 'datetime'; + case DataType.bytea: + return 'bytea'; + } + return null; +} + +/** True when Postgres refuses to implicitly convert a typed value of `from` into `to`. */ +export function crossesTypeCategory(from: _IType, to: _IType): boolean { + const a = typeCategory(from); + const b = typeCategory(to); + return !!a && !!b && a !== b; +} + /** Finds a common type by implicit conversion */ function reconciliateTypesRaw(values: IValue[], nullIfNoMatch?: false, stricterType?: boolean): _IType; function reconciliateTypesRaw(values: IValue[], nullIfNoMatch: true, stricterType?: boolean): _IType | nil; @@ -926,7 +1021,8 @@ function reconciliateTypesRaw(values: IValue[], nullIfNoMatch?: boolean, stricte // check that all constant literals are matching this. for (const x of values) { - if (!x.isConstantLiteral && !x.type.canConvertImplicit(foundType)) { + const unknownLiteral = x.isConstantLiteral && x.type.primary === DataType.text; + if (!x.isConstantLiteral && !x.type.canConvertImplicit(foundType) || !unknownLiteral && crossesTypeCategory(x.type, foundType)) { if (nullIfNoMatch) { return null; } diff --git a/src/datatypes/t-interval.ts b/src/datatypes/t-interval.ts index 1313ee25..116bde71 100644 --- a/src/datatypes/t-interval.ts +++ b/src/datatypes/t-interval.ts @@ -1,5 +1,5 @@ import { DataType, nil, _IType } from '../interfaces-private'; -import { Interval, normalizeInterval, parseIntervalLiteral } from 'pgsql-ast-parser'; +import { Interval, normalizeInterval, parseIntervalLiteral, intervalToString } from 'pgsql-ast-parser'; import { TypeBase } from './datatype-base'; import { Evaluator } from '../evaluator'; import { intervalToSec } from '../utils'; @@ -31,6 +31,19 @@ export class IntervalType extends TypeBase { return null; } + doCanCast(to: _IType) { + return to.primary === DataType.text; + } + + doCast(value: Evaluator, to: _IType): Evaluator | nil { + if (to.primary !== DataType.text) { + return null; + } + return value + .setType(to) + .setConversion((i: Interval) => pgIntervalText(i), intervalToText => ({ intervalToText })); + } + doEquals(a: Interval, b: Interval): boolean { return intervalToSec(a) === intervalToSec(b); } @@ -41,3 +54,11 @@ export class IntervalType extends TypeBase { return intervalToSec(a) < intervalToSec(b); } } + +/** postgres' default (IntervalStyle = postgres) text form: "1 year 2 mons 3 days 04:05:06", "00:00:00" for zero */ +function pgIntervalText(i: Interval): string { + const txt = String(intervalToString(i)) + .replace(/(-?\d+) months?\b/, (_, n) => `${n} ${Math.abs(Number(n)) === 1 ? 'mon' : 'mons'}`) + .trim(); + return txt || '00:00:00'; +} diff --git a/src/datatypes/t-record.ts b/src/datatypes/t-record.ts index 69b442fa..75f6ca40 100644 --- a/src/datatypes/t-record.ts +++ b/src/datatypes/t-record.ts @@ -59,10 +59,33 @@ export class RecordType extends TypeBase { doCanCast(to: _IType): boolean | nil { // lets say that any type can cast to a record with no columns // this is a hack ... see row_to_json() UT - return to instanceof RecordType && !to.columns.length; + return to instanceof RecordType && !to.columns.length + || to.primary === DataType.text; } doCast(value: Evaluator, to: _IType): Evaluator | nil { + if (to.primary === DataType.text) { + // record_out: (a,b,"c d") - what `'x' || some_row` concatenates in postgres + const cols = this.columns; + return value + .setType(to) + .setConversion((row: any) => '(' + cols.map(c => recordField(row?.[c.name])).join(',') + ')' + , recordToText => ({ recordToText })); + } return value; } } + +/** one field of record_out: empty for NULL, double-quoted when it would otherwise be ambiguous */ +function recordField(v: any): string { + if (v === null || v === undefined) { + return ''; + } + const str = typeof v === 'boolean' ? (v ? 't' : 'f') + : v instanceof Date ? v.toISOString() + : typeof v === 'object' ? JSON.stringify(v) + : String(v); + return str === '' || /[",\\()\s]/.test(str) + ? '"' + str.replace(/(["\\])/g, '$1$1') + '"' + : str; +} diff --git a/src/datatypes/t-timestamp.ts b/src/datatypes/t-timestamp.ts index 4eef6b32..6a6527ec 100644 --- a/src/datatypes/t-timestamp.ts +++ b/src/datatypes/t-timestamp.ts @@ -48,6 +48,17 @@ export class TimestampType extends TypeBase { return null; } + doPrefer(to: _IType): _IType | null { + // date < timestamp < timestamptz: comparing two of them promotes to the wider one, + // as postgres' implicit casts do (date = now() compares as timestamptz) + const rank = (t: DataType) => t === DataType.date ? 1 : t === DataType.timestamp ? 2 : t === DataType.timestamptz ? 3 : 0; + const a = rank(this.primary), b = rank(to.primary); + if (!a || !b) { + return null; + } + return a >= b ? this : to; + } + doCanConvertImplicit(to: _IType) { switch (to.primary) { case DataType.timestamp: diff --git a/src/db.ts b/src/db.ts index 0166b62b..2c593ee8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -84,6 +84,8 @@ class MemoryDb implements _IDb { this.raiseGlobal('schema-change', this); } + sessionTx: _Transaction | null = null; + constructor(public data: Transaction, schemas?: Map, readonly options: MemoryDbOptions = {}) { if (!schemas) { this.createSchema('public'); diff --git a/src/evaluator.ts b/src/evaluator.ts index 4354ac1c..400d0b30 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -411,8 +411,16 @@ export const Value = { if (!Array.isArray(rawArray)) { return false; } - const has = rawArray.some(x => of.equals(rawValue, x)); - return inclusive ? has : !has; + // three-valued logic: NULL IN (…) is NULL, and so is a miss when the list holds a + // NULL - which is why CHECK (col IN ('a','b')) accepts a NULL col in postgres + if (nullIsh(rawValue)) { + return rawArray.length ? null : !inclusive; + } + const has = rawArray.some(x => !nullIsh(x) && of.equals(rawValue, x)); + if (has) { + return inclusive; + } + return rawArray.some(x => nullIsh(x)) ? null : !inclusive; }); }, isNull(leftValue: IValue, expectNull: boolean): IValue { diff --git a/src/execution/plpgsql.ts b/src/execution/plpgsql.ts index d1e2e9ed..6e20af9c 100644 --- a/src/execution/plpgsql.ts +++ b/src/execution/plpgsql.ts @@ -1,4 +1,4 @@ -import { _IDb, _ISchema, _ITable, _Transaction, IValue, _ISelection, QueryError, NotSupported, getId, setId, _IType, Parameter, nil, StatementResult, _IStatementExecutor } from '../interfaces-private'; +import { _IDb, _ISchema, _ITable, _Transaction, IValue, _ISelection, QueryError, NotSupported, getId, setId, _IType, Parameter, nil, StatementResult, _IStatementExecutor, asTable } from '../interfaces-private'; import { Expr, parse, SelectStatement } from 'pgsql-ast-parser'; import { buildValue } from '../parser/expression-builder'; import { buildSelect } from './select'; @@ -42,13 +42,34 @@ function stripComments(code: string): string { ); } -/** Tokenize a plpgsql fragment: strings, numbers, `..` range, `:=`, words, punctuation. */ +/** Tokenize a plpgsql fragment: strings, numbers, `..` range, `:=`, words, operators, punctuation. */ function tokenize(code: string): string[] { // A $tag$…$tag$ (or $$…$$) dollar-quoted block is a single opaque token (matched // first so its content — which may contain ; ' etc. — is not split). JS regex // backreferences let us balance the tag, which the moo lexer can't do. - return stripComments(code) - .match(/\$([a-zA-Z_]\w*)?\$[\s\S]*?\$\1\$|'(?:[^']|'')*'|\d+\.\d+|\d+|\.\.|:=|::|>=|<=|<>|!=|\|\||[a-zA-Z_][\w$]*|[(),.;]|[^\s]/g) ?? []; + const raw = stripComments(code) + .match(/\$([a-zA-Z_]\w*)?\$[\s\S]*?\$\1\$|'(?:[^']|'')*'|\d+\.\d+|\d+|\.\.|:=|::|[+\-*/<>=~!@#%^&|`?]+|[a-zA-Z_][\w$]*|[(),.;]|[^\s]/g) ?? []; + return raw.flatMap(splitOperator); +} + +/** + * Postgres' rule for where an operator token ends: a run of + - * / < > = ~ ! @ # % ^ & | ` ? + * is one operator (->>, #>>, @>, ?|, ~*), except that a multi-character operator cannot end + * in + or - unless it also contains one of ~ ! @ # % ^ & | ` ? - so `x=-1` is `=` then `-1`. + * Splitting `->>` into `- > >` (as listing only two-character operators did) broke every + * trigger that reads JSON, e.g. `new.raw_user_meta_data->>'name'`. + */ +function splitOperator(tok: string): string[] { + if (tok.length < 2 || !/^[+\-*/<>=~!@#%^&|`?]+$/.test(tok) || /[~!@#%^&|`?]/.test(tok)) { + return [tok]; + } + let op = tok; + const tail: string[] = []; + while (op.length > 1 && /[+-]$/.test(op)) { + tail.unshift(op[op.length - 1]); + op = op.slice(0, -1); + } + return [op, ...tail]; } /** strip surrounding single quotes and unescape '' -> ' */ @@ -609,13 +630,15 @@ function makeHelpers(schema: _ISchema, params: Parameter[], returns: _IType | ni /** compiles a regular plpgsql function into a callable implementation */ function buildPlpgsqlFunction(code: string, args: VarDef[], returns: _IType | nil, schema: _ISchema) { - const { decls, block } = new GParser(code).parse(); - const setof = usesSetof([block]); + // parsed now to report syntax errors at CREATE FUNCTION; re-parsed at first call with + // %ROWTYPE / %TYPE resolved, once the tables they name exist + const setof = usesSetof([new GParser(code).parse().block]); // compilation is deferred to first call: the function's own name (recursion) and any // later-defined helpers must already be registered, which they are by call time let program: GProgram | null = null; const compile = (): GProgram => { + const { decls, block } = new GParser(expandRowTypes(tokenize(code), schema)).parse(); const locals = decls.map(d => ({ name: d.name, type: schema.getType(parseTypeDef((d as any).typeSrc)) as _IType, @@ -701,10 +724,12 @@ function mangleTrigger(toks: string[]): string[] { * assignments, control flow, RAISE, EXCEPTION, etc. Compiled lazily per table. */ function buildPlpgsqlTrigger(code: string, schema: _ISchema): TriggerRunner { - const { decls, block } = new GParser(mangleTrigger(tokenize(code))).parse(); + // syntax check at CREATE FUNCTION; compiled per table at first firing (see compileForTable) + new GParser(mangleTrigger(tokenize(code))).parse(); const perTable = new Map<_ITable, { compiled: GCompiled[]; locals: any[]; cols: { id: string }[] }>(); const compileForTable = (table: _ITable) => { + const { decls, block } = new GParser(expandRowTypes(mangleTrigger(tokenize(code)), schema)).parse(); const cols = table.selection.columns .filter(c => !!c.id) .map(c => ({ id: c.id!, type: c.type })); @@ -777,6 +802,68 @@ function buildPlpgsqlTrigger(code: string, schema: _ISchema): TriggerRunner { }; } +/** a table named by a plpgsql declaration (`markets` / `public.markets`) */ +function declTable(schema: _ISchema, ref: string[]): _ITable { + const name = ref.length > 1 ? { schema: ref[0], name: ref[1] } : { name: ref[0] }; + return asTable(schema.getThisOrSiblingFor(name).getObject(name)); +} + +/** + * Row-typed variables, by expansion into scalars (as NEW/OLD are, see mangleTrigger): + * DECLARE m public.markets%ROWTYPE; -> one variable per column, __r_m__ + * m.col -> __r_m__col + * SELECT … INTO m -> SELECT … INTO __r_m__c1, __r_m__c2, … (positional, like postgres) + * and DECLARE x tbl.col%TYPE takes that column's type. + */ +function expandRowTypes(toks: string[], schema: _ISchema): string[] { + const lower = toks.map(t => t.toLowerCase()); + const di = lower.indexOf('declare'); + const bi = lower.indexOf('begin'); + if (di < 0 || bi < di) { + return toks; + } + const rowVars = new Map(); + const out: string[] = toks.slice(0, di + 1); + for (let i = di + 1; i < bi;) { + let j = i; + while (j < bi && toks[j] !== ';') { + j++; + } + const decl = toks.slice(i, j); + const pct = decl.indexOf('%'); + const kind = pct > 1 ? decl[pct + 1]?.toLowerCase() : null; + // the name, possibly qualified, before %: [a, '.', b] -> ['a', 'b'] + const ref = pct > 1 ? decl.slice(1, pct).filter(x => x !== '.') : []; + if (kind === 'rowtype') { + const name = decl[0].toLowerCase(); + const cols = declTable(schema, ref).selection.columns.filter(c => !!c.id); + for (const c of cols) { + out.push(`__r_${name}__${c.id}`, c.type.name, ';'); + } + rowVars.set(name, cols.map(c => `__r_${name}__${c.id}`)); + } else if (kind === 'type' && ref.length >= 2) { + const col = ref[ref.length - 1]; + const type = declTable(schema, ref.slice(0, -1)).getColumnRef(col).expression.type; + out.push(decl[0], type.name, ...decl.slice(pct + 2), ';'); + } else { + out.push(...decl, ';'); + } + i = j + 1; + } + for (let k = bi; k < toks.length; k++) { + const vars = rowVars.get(lower[k]); + if (vars && toks[k + 1] === '.' && /^[a-zA-Z_]/.test(toks[k + 2] ?? '')) { + out.push(`__r_${lower[k]}__${lower[k + 2]}`); + k += 2; + } else if (vars && (lower[k - 1] === 'into' && lower[k - 2] !== 'insert' || lower[k - 1] === 'strict' && lower[k - 2] === 'into')) { + vars.forEach((v, n) => out.push(...(n ? [','] : []), v)); + } else { + out.push(toks[k]); + } + } + return out; +} + function parseTypeDef(typeSrc: string): any { const e = parseExpr(`null::${typeSrc}`); if (e.type !== 'cast') { diff --git a/src/execution/policy-deps.ts b/src/execution/policy-deps.ts new file mode 100644 index 00000000..f04b9feb --- /dev/null +++ b/src/execution/policy-deps.ts @@ -0,0 +1,99 @@ +import { astVisitor, Expr, SelectFromStatement } from 'pgsql-ast-parser'; +import { _ITable, nil } from '../interfaces-private'; + +/** A column a policy predicate reads. */ +export interface ColumnUse { + table: _ITable; + column: string; +} + +/** + * The (table, column) pairs a policy's USING / WITH CHECK reads, with SQL scoping: a ref + * qualified by an alias or table name resolves through the FROM clauses in scope, innermost + * first; an unqualified one resolves to the innermost table that has the column, the policy's + * own table being the outermost scope. So in a policy on `scenes`, + * `exists (select 1 from storyboards s where s.id = storyboard_id)` reads storyboards.id and + * scenes.storyboard_id - and not scenes.id. + * + * Postgres records these as dependencies: the columns cannot be dropped or retyped while the + * policy exists. + */ +export function policyColumnUses(owner: _ITable, ...exprs: (Expr | nil)[]): ColumnUse[] { + const schema = owner.ownerSchema; + const scopes: Map[] = [new Map([[owner.name, owner]])]; + const uses: ColumnUse[] = []; + + const hasColumn = (t: _ITable, c: string) => { + try { + return !!t.getColumnRef?.(c, true); + } catch { + return false; + } + }; + + const visitor = astVisitor(v => ({ + selection: (s: SelectFromStatement) => { + const scope = new Map(); + for (const f of s.from ?? []) { + if (f.type !== 'table') { + continue; + } + const t = schema.getObject(f.name, { nullIfNotFound: true }) as _ITable | nil; + if (t && (t as any).type === 'table') { + scope.set(f.name.alias ?? f.name.name, t); + } + } + scopes.push(scope); + try { + v.super().selection(s); + } finally { + scopes.pop(); + } + return s; + }, + ref: r => { + if (r.name === '*') { + return r; + } + for (let i = scopes.length - 1; i >= 0; i--) { + if (r.table) { + const t = scopes[i].get(r.table.name); + if (t) { + uses.push({ table: t, column: r.name }); + return r; + } + continue; + } + for (const t of scopes[i].values()) { + if (hasColumn(t, r.name)) { + uses.push({ table: t, column: r.name }); + return r; + } + } + } + return r; + }, + })); + + for (const e of exprs) { + if (e) { + visitor.expr(e); + } + } + return uses; +} + +/** Policies, on any table of the db, that read `table.column`. */ +export function policiesDependingOn(table: _ITable, column: string): { table: _ITable; policy: string }[] { + const out: { table: _ITable; policy: string }[] = []; + for (const schema of table.ownerSchema.db.listSchemas()) { + for (const t of schema.listTables()) { + for (const p of t.rls?.policies ?? []) { + if (policyColumnUses(t, p.using, p.withCheck).some(u => u.table === table && u.column === column)) { + out.push({ table: t, policy: p.name }); + } + } + } + } + return out; +} diff --git a/src/execution/roles.ts b/src/execution/roles.ts index f5da8b07..2567039c 100644 --- a/src/execution/roles.ts +++ b/src/execution/roles.ts @@ -65,6 +65,33 @@ export function currentRole(t: _Transaction): Role { return getRole(t, currentRoleName(t)) ?? DEFAULT_ROLE; } +/** GRANT … TO / CREATE POLICY … TO a role that does not exist fails in postgres ("role … does not exist"). */ +export function assertRolesExist(t: _Transaction, names: (string | undefined)[]): void { + for (const n of names) { + if (!n || n === 'public' || n === 'current_user' || n === 'session_user' || n === 'current_role') { + continue; + } + if (!getRole(t, n)) { + throw new QueryError(`role "${n}" does not exist`, '42704'); + } + } +} + +/** + * Run `fn` as `role` - a SECURITY DEFINER function's body runs with its owner's rights (so a + * helper like is_member(crew_id) can read a table whose policies would otherwise filter it, + * the standard way to avoid recursive RLS) - then restore the caller's role. + */ +export function runAsRole(t: _Transaction, role: string, fn: () => T): T { + const prev = t.getMap(GLOBAL_VARS).get(CURRENT_ROLE_KEY) as string | undefined; + setGuc(t, CURRENT_ROLE_KEY, role); + try { + return fn(); + } finally { + setGuc(t, CURRENT_ROLE_KEY, prev); + } +} + function setGuc(t: _Transaction, key: string, value: string | undefined): void { let g = t.getMap(GLOBAL_VARS); g = value === undefined ? g.delete(key) : g.set(key, value); diff --git a/src/execution/schema-amends/alter.ts b/src/execution/schema-amends/alter.ts index a04796e5..2783833f 100644 --- a/src/execution/schema-amends/alter.ts +++ b/src/execution/schema-amends/alter.ts @@ -2,6 +2,7 @@ import { _ISchema, _Transaction, SchemaField, NotSupported, _ITable, _IStatement import { AlterTableStatement } from 'pgsql-ast-parser'; import { ignore } from '../../utils'; import { ExecHelper } from '../exec-utils'; +import { policiesDependingOn } from '../policy-deps'; export class Alter extends ExecHelper implements _IStatementExecutor { @@ -51,6 +52,29 @@ export class Alter extends ExecHelper implements _IStatementExecutor { if (!col) { ignoreChange(); } else { + // a foreign key into this column, or a policy reading it, depends on it: + // postgres refuses unless CASCADE, which drops those dependents too + const name = change.column.name; + const fks = this.table.referencingForeignKeys?.([name]) ?? []; + const policies = policiesDependingOn(this.table, name); + if (change.behaviour === 'cascade') { + for (const fk of fks) { + fk.uninstall(t); + } + for (const p of policies) { + p.table.dropPolicy(p.policy, true); + } + } else if (fks.length || policies.length) { + throw new QueryError({ + error: `cannot drop column ${name} of table ${this.table.name} because other objects depend on it`, + details: [ + ...fks.map(fk => `constraint ${fk.name} on table ${fk.tableName} depends on column ${name} of table ${this.table.name}`), + ...policies.map(p => `policy ${p.policy} on table ${p.table.name} depends on column ${name} of table ${this.table.name}`), + ].join('\n'), + hint: 'Use DROP ... CASCADE to drop the dependent objects too.', + code: '2BP01', + }); + } col.drop(t); } break; diff --git a/src/execution/schema-amends/create-function.ts b/src/execution/schema-amends/create-function.ts index bdb5de9b..0fe08168 100644 --- a/src/execution/schema-amends/create-function.ts +++ b/src/execution/schema-amends/create-function.ts @@ -3,13 +3,16 @@ import { CreateFunctionStatement } from 'pgsql-ast-parser'; import { ExecHelper } from '../exec-utils'; import { buildValue } from '../../parser/expression-builder'; import { Types } from '../../datatypes'; -import { ignore, deepEqual } from '../../utils'; +import { ignore, deepEqual, executionCtx } from '../../utils'; +import { runAsRole, currentRoleName, DEFAULT_ROLE_NAME } from '../roles'; +import { getTriggerRunner } from '../plpgsql'; import { withSelection } from '../../parser/context'; export class CreateFunction extends ExecHelper implements _IStatementExecutor { private onSchema: _ISchema; private toRegister: FunctionDefinition; private replace: boolean; + private owner: string = DEFAULT_ROLE_NAME; constructor({ schema }: _IStatement, fn: CreateFunctionStatement) { super(fn); @@ -20,9 +23,12 @@ export class CreateFunction extends ExecHelper implements _IStatementExecutor { const lang = schema.db.getLanguage(fn.language.name); - // SECURITY DEFINER/INVOKER is a privilege concept; pg-mem has no roles, so - // the body simply runs with the caller's (only) rights either way. + // SECURITY DEFINER: the body runs as the function's owner (the role that created it), + // see the wrapping below; INVOKER (the default) runs as the caller ignore(fn.security); + // SET search_path = ... / COST / ROWS / PARALLEL: accepted, no effect here (pg-mem resolves + // names without a per-function search_path, and has no planner costs or parallelism) + ignore(fn.settings, fn.cost, fn.rows, fn.parallel); // determine arg types const args = withSelection(schema.dualTable.selection, () => fn.arguments.map<_ArgDefDetails>(a => ({ @@ -92,10 +98,20 @@ export class CreateFunction extends ExecHelper implements _IStatementExecutor { schema: schema, }); + let implementation = compiled; + if (fn.security === 'definer') { + const asOwner = (t: _Transaction, run: () => any) => runAsRole(t, this.owner, run); + implementation = (...a: any[]) => asOwner(executionCtx().transaction, () => compiled(...a)); + const trigger = getTriggerRunner(compiled); + if (trigger) { + (implementation as any).__triggerRunner = (ctx: any, t: _Transaction) => asOwner(t, () => trigger(ctx, t)); + } + } + this.toRegister = { name: fn.name.name, returns, - implementation: compiled, + implementation, args: args.filter(x => x.mode !== 'variadic'), argsVariadic, impure: fn.purity !== 'immutable', @@ -132,6 +148,8 @@ export class CreateFunction extends ExecHelper implements _IStatementExecutor { // (because does not support further rollbacks) t = t.fullCommit(); + // the creating role owns the function (what SECURITY DEFINER runs as) + this.owner = currentRoleName(t); this.onSchema.registerFunction(this.toRegister, this.replace); // new implicit transaction diff --git a/src/execution/schema-amends/create-policy.ts b/src/execution/schema-amends/create-policy.ts index b239cc10..41faaa3f 100644 --- a/src/execution/schema-amends/create-policy.ts +++ b/src/execution/schema-amends/create-policy.ts @@ -1,7 +1,10 @@ -import { _ISchema, _Transaction, _ITable, _IStatementExecutor, asTable, StatementResult, _IStatement } from '../../interfaces-private'; -import { CreatePolicyStatement, DropPolicyStatement } from 'pgsql-ast-parser'; +import { _ISchema, _Transaction, _ITable, _IStatementExecutor, asTable, StatementResult, _IStatement, QueryError, DataType, nil } from '../../interfaces-private'; +import { CreatePolicyStatement, DropPolicyStatement, Expr } from 'pgsql-ast-parser'; import { ignore } from '../../utils'; import { ExecHelper } from '../exec-utils'; +import { buildValue } from '../../parser/expression-builder'; +import { withSelection } from '../../parser/context'; +import { assertRolesExist } from '../roles'; export class CreatePolicy extends ExecHelper implements _IStatementExecutor { private table: _ITable; @@ -9,13 +12,35 @@ export class CreatePolicy extends ExecHelper implements _IStatementExecutor { constructor({ schema }: _IStatement, private p: CreatePolicyStatement) { super(p); this.table = asTable(schema.getObject(p.table)); - // the predicates are stored raw and compiled lazily at enforcement time + const cmd = p.for ?? 'all'; + if (cmd === 'insert' && p.using) { + throw new QueryError('only WITH CHECK expression allowed for INSERT', '42601'); + } + if ((cmd === 'select' || cmd === 'delete') && p.withCheck) { + throw new QueryError('WITH CHECK cannot be applied to SELECT or DELETE', '42601'); + } + // Postgres binds the predicates at CREATE POLICY: an unknown column, an operator that + // does not exist for the operand types (auth.uid() = ) or a non-boolean + // predicate fails here, not on the first query. Compile them now to fail the same way; + // enforcement recompiles them from the stored AST. + const check = (expr: Expr | nil, clause: string) => { + if (!expr) { + return; + } + const v = withSelection(this.table.selection, () => buildValue(expr)); + if (v.type.primary !== DataType.bool && v.type.primary !== DataType.null) { + throw new QueryError(`argument of POLICY ${clause} must be type boolean, not type ${v.type.name}`, '42804'); + } + }; + check(p.using, 'USING'); + check(p.withCheck, 'WITH CHECK'); ignore(p.using); ignore(p.withCheck); p.roles?.forEach(ignore); } execute(t: _Transaction): StatementResult { + assertRolesExist(t, (this.p.roles ?? []).map(r => r.name)); this.table.createPolicy({ name: this.p.name.name, // postgres default is PERMISSIVE diff --git a/src/execution/schema-amends/create-table.ts b/src/execution/schema-amends/create-table.ts index be1cc85e..142b7cfd 100644 --- a/src/execution/schema-amends/create-table.ts +++ b/src/execution/schema-amends/create-table.ts @@ -32,6 +32,8 @@ export class ExecuteCreateTable extends ExecHelper implements _IStatementExecuto type: this.schema.getType(f.dataType), serial: !f.dataType.kind && (f.dataType.name === 'serial' || f.dataType.name === 'bigserial'), }; + // keep the type as written, for information_schema (see ColRef.declaredType) + (nf as any).declaredType = f.dataType; delete (nf as Optional).dataType; fields.push(nf); break; @@ -65,7 +67,7 @@ export class ExecuteCreateTable extends ExecHelper implements _IStatementExecuto const partitionBy = this.p.partitionBy; // perform creation - checkExistence(this.schema, this.name, this.ifNotExists, () => { + const created = checkExistence(this.schema, this.name, this.ifNotExists, () => { if (partitionOf) { // a partition inherits its parent's columns const parent = asTable(this.schema.getObject(partitionOf.parent)); @@ -82,7 +84,11 @@ export class ExecuteCreateTable extends ExecHelper implements _IStatementExecuto } } }); - + if (!created) { + // IF NOT EXISTS on an existing table is a no-op in postgres (a NOTICE): the column + // definitions and their constraints are intentionally unused, not unsupported + ignore(this.p); + } // new implicit transaction t = t.fork(); diff --git a/src/execution/schema-amends/create-trigger.ts b/src/execution/schema-amends/create-trigger.ts index 6a59782c..51438630 100644 --- a/src/execution/schema-amends/create-trigger.ts +++ b/src/execution/schema-amends/create-trigger.ts @@ -3,7 +3,7 @@ import { CreateTriggerStatement } from 'pgsql-ast-parser'; import { buildValue } from '../../parser/expression-builder'; import { ExecHelper } from '../exec-utils'; import { TriggerOp } from '../triggers'; -import { compileTriggerWhen, TriggerContext } from '../plpgsql'; +import { compileTriggerWhen, getTriggerRunner, TriggerContext } from '../plpgsql'; export class CreateTrigger extends ExecHelper implements _IStatementExecutor { private target: _ITable | _IView; @@ -30,6 +30,15 @@ export class CreateTrigger extends ExecHelper implements _IStatementExecutor { } } this.functionSchema = schema.getThisOrSiblingFor(p.execute.function); + // the function is resolved at CREATE TRIGGER, not at the first firing + const fnName = p.execute.function.name; + const fn = this.functionSchema.getFunction(fnName, []); + if (!fn) { + throw new QueryError(`function ${p.execute.function.schema ? p.execute.function.schema + '.' : ''}${fnName}() does not exist`, '42883'); + } + if (!getTriggerRunner(fn.implementation)) { + throw new QueryError(`function ${fnName} must return type trigger`, '42P17'); + } this.events = p.events .filter(e => e.event !== 'truncate') .map(e => e.event as TriggerOp); diff --git a/src/execution/schema-amends/create-view.ts b/src/execution/schema-amends/create-view.ts index 649b7844..39860e53 100644 --- a/src/execution/schema-amends/create-view.ts +++ b/src/execution/schema-amends/create-view.ts @@ -18,6 +18,9 @@ export class CreateView extends ExecHelper implements _IStatementExecutor { // check existence this.existing = asView(this.schema.getObject(p.name, { nullIfNotFound: true })); ignore(p.orReplace); + // WITH (security_invoker = true, security_barrier, ...): accepted; pg-mem evaluates a view's + // query in the caller's context, which is what security_invoker asks for + ignore(p.parameters); this.drop = !!(p.orReplace && this.existing); let view = buildSelect(p.query); diff --git a/src/execution/statement-exec.ts b/src/execution/statement-exec.ts index 9ed0d688..3dad8968 100644 --- a/src/execution/statement-exec.ts +++ b/src/execution/statement-exec.ts @@ -15,7 +15,7 @@ import { CommitExecutor, RollbackExecutor, BeginStatementExec, SavepointExecutor import { TruncateTable } from './records-mutations/truncate-table'; import { ShowExecutor } from './show'; import { SetExecutor } from './set'; -import { CreateRoleExecutor, DropRoleExecutor, SetRoleExecutor, ResetExecutor } from './roles'; +import { CreateRoleExecutor, DropRoleExecutor, SetRoleExecutor, ResetExecutor, assertRolesExist } from './roles'; import { CreatePolicy, DropPolicy } from './schema-amends/create-policy'; import { CreateTrigger } from './schema-amends/create-trigger'; import { CreateEnum } from './schema-amends/create-enum'; @@ -62,7 +62,7 @@ export class StatementExec implements _IStatement { private executor?: _IStatementExecutor; private checkAstCoverage?: (() => void); - constructor(readonly schema: _ISchema, private statement: Statement, private pAsSql: string | nil, private parameters?: Parameter[]) { + constructor(readonly schema: _ISchema, readonly statement: Statement, private pAsSql: string | nil, private parameters?: Parameter[]) { } onExecuted(callback: OnStatementExecuted): void { @@ -163,14 +163,29 @@ export class StatementExec implements _IStatement { case 'create function': return new CreateFunction(this, p); case 'drop function': - return new SimpleExecutor(p, () => this.schema.dropFunction(p), 'DROP'); + return new SimpleExecutor(p, () => { + ignore(p.cascade); + this.schema.dropFunction(p); + }, 'DROP'); case 'do': return new DoStatementExec(this, p); case 'comment': return new Comment(this, p); - case 'raise': case 'grant': case 'revoke': + // no privilege system: privileges are not recorded, but the grantees and (for + // tables) the objects must exist, as postgres checks + return new SimpleExecutor(p, t => { + assertRolesExist(t, ((p as any).to ?? (p as any).from ?? []).map((r: any) => r?.name)); + const on = (p as any).on; + if (on?.type === 'table') { + for (const n of on.names ?? []) { + this.schema.getThisOrSiblingFor(n).getObject(n); + } + } + ignore(p); + }); + case 'raise': case 'alter role': case 'alter default privileges': // pg-mem has no privilege/role-config system: parse & ignore (dumps, RLS setup) @@ -246,6 +261,22 @@ export class StatementExec implements _IStatement { }, 'DROP TRIGGER'); case 'alter index': return new AlterIndex(this, p); + case 'drop view': + case 'drop materialized view': + // (materialized views are plain views in pg-mem) + return new SimpleExecutor(p, t => { + ignore(p.cascade); + for (const n of p.names) { + const obj = this.schema.getThisOrSiblingFor(n).getObject(n, { nullIfNotFound: !!p.ifExists }); + if (!obj) { + continue; // IF EXISTS + } + if (obj.type !== 'view') { + throw new QueryError(`"${n.name}" is not a view`, '42809'); + } + (obj as _IView).drop(t); + } + }, 'DROP VIEW'); default: throw NotSupported.never(p, 'statement type'); } diff --git a/src/execution/transaction-statements.ts b/src/execution/transaction-statements.ts index d673eb2d..a004a286 100644 --- a/src/execution/transaction-statements.ts +++ b/src/execution/transaction-statements.ts @@ -67,7 +67,7 @@ export class BeginStatementExec extends ExecHelper implements _IStatementExecuto } execute(t: _Transaction): StatementResult { - t = t.fork(); + t = t.fork(true); return this.noData(t, 'BEGIN'); } } diff --git a/src/functions/date.ts b/src/functions/date.ts index 11dd2224..776b2388 100644 --- a/src/functions/date.ts +++ b/src/functions/date.ts @@ -2,7 +2,7 @@ import { FunctionDefinition } from '../interfaces'; import { utc, fromParts, startOf, parseFormat } from '../datatypes/date-utils'; import { Interval } from 'pgsql-ast-parser'; import { DataType, QueryError } from '../interfaces-private'; -import { nullIsh } from '../utils'; +import { nullIsh, transactionNow } from '../utils'; import { dateToChar, intervalToChar, numberToChar } from './to-char'; @@ -22,8 +22,17 @@ export const dateFunctions: FunctionDefinition[] = [ return ret; } }, + // now() and transaction_timestamp() are the transaction's start time, constant for its whole + // lifetime (so a trigger's `new.updated_at = now()` equals the statement's now()); + // clock_timestamp() is the wall clock + ...['now', 'transaction_timestamp', 'statement_timestamp'].map(name => ({ + name, + returns: DataType.timestamptz, + impure: true, + implementation: () => transactionNow(), + })), { - name: 'now', + name: 'clock_timestamp', returns: DataType.timestamptz, impure: true, implementation: () => new Date(), diff --git a/src/functions/string.ts b/src/functions/string.ts index c5056614..e3c1fd60 100644 --- a/src/functions/string.ts +++ b/src/functions/string.ts @@ -128,8 +128,9 @@ export const stringFunctions: FunctionDefinition[] = [ implementation: (x: string) => x?.toUpperCase(), }, { + // concat(VARIADIC "any"): every argument, the first included, may be of any type name: 'concat', - args: [DataType.text], + args: [], argsVariadic: DataType.text, returns: DataType.text, allowNullArguments: true, diff --git a/src/interfaces-private.ts b/src/interfaces-private.ts index 8feed381..ed351b7a 100644 --- a/src/interfaces-private.ts +++ b/src/interfaces-private.ts @@ -155,8 +155,14 @@ export type _ArgDefDetails = ArgDefDetails & { export interface _Transaction { readonly isChild: boolean; - /** Create a new transaction within this transaction */ - fork(): _Transaction; + /** true inside a BEGIN … COMMIT/ROLLBACK block (this transaction or an ancestor was opened by BEGIN) */ + readonly inExplicitBlock: boolean; + /** transaction start: the value of now() / current_timestamp for its whole lifetime */ + readonly startedAt: Date; + /** set when a statement failed inside an explicit block: Postgres refuses everything but COMMIT/ROLLBACK until the block ends */ + aborted: boolean; + /** Create a new transaction within this transaction (explicit = opened by BEGIN) */ + fork(explicit?: boolean): _Transaction; /** Commit this transaction (returns the parent transaction) */ commit(): _Transaction; /** Commits this transaction and all underlying transactions */ @@ -387,6 +393,13 @@ export interface _IDb extends IMemoryDb { readonly options: MemoryDbOptions; readonly public: _ISchema; readonly data: _Transaction; + /** + * The transaction block a BEGIN left open at the end of a query call. + * Postgres keeps a transaction open across round-trips until COMMIT/ROLLBACK; pg-mem used to + * commit at the end of every call, so `begin` / `delete` / `rollback` sent separately (as every + * driver does) persisted the delete. The next call resumes this transaction instead of forking root. + */ + sessionTx: _Transaction | null; readonly searchPath: ReadonlyArray; /** session-scoped named prepared statements (SQL-level PREPARE / EXECUTE); * each entry is a runner that binds args and executes against a transaction */ @@ -446,6 +459,10 @@ export interface _ITable extends IMemoryTable, _RelationBase { readonly rls: TableRls; createPolicy(policy: Policy): void; dropPolicy(name: string, ifExists: boolean): void; + /** every constraint on this table (FKs, uniques, checks, ...) */ + listConstraints?(): Iterable<_IConstraint>; + /** foreign keys on any table that reference this one (only those through `viaColumns` when given) */ + referencingForeignKeys?(viaColumns?: string[]): _IForeignKeyRef[]; setRowLevelSecurity(action: 'enable' | 'disable' | 'force' | 'no force'): void; /** Triggers attached to this table */ readonly triggers: TableTriggers; @@ -525,6 +542,14 @@ export interface _Column { onDrop(sub: DropHandler): ISubscription; } +/** The parts of a foreign key that dependency checks need. */ +export interface _IForeignKeyRef { + readonly name: string; + readonly tableName: string; + readonly foreignColumns: string[]; + uninstall(t: _Transaction): void; +} + export interface CreateIndexDef { ifNotExists?: boolean; columns: CreateIndexColDef[]; diff --git a/src/parser/expression-builder.ts b/src/parser/expression-builder.ts index 5d4c9117..1c5c3a3e 100644 --- a/src/parser/expression-builder.ts +++ b/src/parser/expression-builder.ts @@ -1,10 +1,10 @@ import { _ISelection, IValue, _IType, _ISchema, _IAlias, _Transaction } from '../interfaces-private'; import { currentRoleName, sessionRoleName } from '../execution/roles'; -import { buildLikeMatcher, nullIsh, hasNullish, intervalToSec, parseTime, asSingleQName, colToStr, executionCtx, ignore } from '../utils'; +import { buildLikeMatcher, nullIsh, hasNullish, intervalToSec, parseTime, asSingleQName, colToStr, executionCtx, ignore, transactionNow } from '../utils'; import { DataType, CastError, QueryError, NotSupported, nil, ColumnNotFound } from '../interfaces'; import hash from 'object-hash'; import { Value, Evaluator } from '../evaluator'; -import { Types, isNumeric, reconciliateTypes, ArrayType, RecordCol } from '../datatypes'; +import { Types, isNumeric, reconciliateTypes, ArrayType, RecordCol, crossesTypeCategory } from '../datatypes'; import { Expr, ExprBinary, UnaryOperator, ExprCase, ExprWhen, ExprMember, ExprArrayIndex, ExprTernary, BinaryOperator, SelectStatement, ExprValueKeyword, ExprExtract, Interval, ExprOverlay, ExprSubstring, ExprPosition, ExprCall } from 'pgsql-ast-parser'; import lru from 'lru-cache'; import { aggregationFunctions, getAggregator } from '../transforms/aggregation'; @@ -123,6 +123,11 @@ function _buildValueReal(val: Expr): IValue { ignore(val.value); return new Evaluator(Types.bigint, null, val.valueText, null, val.valueText); } + if (val.value > 2147483647 || val.value < -2147483648) { + // postgres types an integer literal that does not fit int4 as bigint, so that + // assigning it to an integer column is "integer out of range" + return new Evaluator(Types.bigint, null, String(val.value), null, String(val.value)); + } return Value.number(val.value, Types.integer); case 'call': return _buildCall(val); @@ -307,11 +312,14 @@ function buildKeyword(kw: ExprValueKeyword, args: Expr[]): IValue { return Value.constant(Types.text(), 'pg_mem'); case 'current_schema': return Value.constant(Types.text(), 'public'); + // evaluated per execution, not at compile time (a cached plan must not freeze the clock), + // from the transaction start like now(); current_date is that instant's date case 'current_date': - return Value.constant(Types.date, new Date()); + return transactionClock(Types.timestamptz()).cast(Types.date); case 'current_timestamp': + return transactionClock(Types.timestamptz()); case 'localtimestamp': - return Value.constant(Types.timestamp(), new Date()); + return transactionClock(Types.timestamptz()).cast(Types.timestamp()); case 'localtime': case 'current_time': throw new NotSupported('"date" data type, please file an issue in https://github.com/oguimbal/pg-mem if you need it !'); @@ -322,6 +330,10 @@ function buildKeyword(kw: ExprValueKeyword, args: Expr[]): IValue { } } +function transactionClock(type: _IType): IValue { + return new Evaluator(type, null, Math.random().toString(), [], () => transactionNow(), { unpure: true }); +} + function buildUnary(op: UnaryOperator, operand: Expr) { const expr = _buildValue(operand); @@ -364,8 +376,17 @@ export function buildSubqueryArray(op: Expr): IValue { function buildIn(left: Expr, array: Expr, inclusive: boolean): IValue { let leftValue = _buildValue(left); + if (array.type === 'list') { + // `x IN (a, b)` is `x = a OR x = b`: every element must be comparable with x, + // so `uuid_col IN (text_col)` fails like `uuid_col = text_col` does + reconciliateTypes([leftValue, ...array.expressions.map(e => _buildValue(e))]); + } // `x IN (subquery)` needs the subquery's rows as a list, not a scalar let rightValue = isSubqueryNode(array) ? buildSelectAsArray(array as any) : _buildValue(array); + if (array.type !== 'list' && rightValue.type.primary !== DataType.list && rightValue.type.primary !== DataType.array) { + // `x IN (y)` - the parser drops the parens of a one-element list + reconciliateTypes([leftValue, rightValue]); + } return Value.in(leftValue, rightValue, inclusive); } @@ -421,6 +442,17 @@ export function buildBinaryValue(leftValue: IValue, op: BinaryOperator, rightVal // resolve a custom operator registered on the schema (ranges, extensions, ...) const resolveCustom = () => { const { schema } = buildCtx(); + // postgres has `text || anynonarray` and `anynonarray || text`: the other side goes + // through its text output function ('n=' || 1, 'at ' || now()) + if (op === '||') { + const isStr = (v: IValue) => v.type.primary === DataType.text || v.type.primary === DataType.citext; + const nonArray = (v: IValue) => v.type.primary !== DataType.array && v.type.primary !== DataType.list && v.type.primary !== DataType.jsonb && v.type.primary !== DataType.null; + if (isStr(leftValue) && !isStr(rightValue) && nonArray(rightValue) && !rightValue.isConstantLiteral) { + rightValue = rightValue.cast(Types.text()); + } else if (isStr(rightValue) && !isStr(leftValue) && nonArray(leftValue) && !leftValue.isConstantLiteral) { + leftValue = leftValue.cast(Types.text()); + } + } const resolved = schema.resolveOperator(op, leftValue, rightValue); if (!resolved) { throw new QueryError(`operator does not exist: ${leftValue.type.name} ${op} ${rightValue.type.name}`, '42883'); @@ -510,6 +542,12 @@ export function buildBinaryValue(leftValue: IValue, op: BinaryOperator, rightVal case 'ILIKE': case 'NOT LIKE': case 'NOT ILIKE': + // LIKE is text ~~ text: a typed non-string operand has no operator (integer ~~ unknown) + for (const v of [leftValue, rightValue]) { + if (!(v.isConstantLiteral && v.type.primary === DataType.text) && crossesTypeCategory(v.type, Types.text()) && !rightValue.isAny) { + throw new QueryError(`operator does not exist: ${leftValue.type.name} ~~ ${rightValue.type.name}`, '42883'); + } + } expectBoth(Types.text()); const caseSenit = op === 'LIKE' || op === 'NOT LIKE'; const not = op === 'NOT ILIKE' || op === 'NOT LIKE'; diff --git a/src/parser/function-call.ts b/src/parser/function-call.ts index a4a95409..28b709fc 100644 --- a/src/parser/function-call.ts +++ b/src/parser/function-call.ts @@ -1,5 +1,5 @@ import { IValue, _IType, _ISelection, _ISchema, _IDb, _Transaction } from '../interfaces-private'; -import { Types, ArrayType } from '../datatypes'; +import { Types, ArrayType, crossesTypeCategory } from '../datatypes'; import { toJsonValue } from '../datatypes/json-numbers'; import { QueryError, NotSupported, nil, DataType } from '../interfaces'; import { Evaluator } from '../evaluator'; @@ -264,6 +264,12 @@ export function buildCall(name: string | QName, args: IValue[]): IValue { } throw new QueryError(`COALESCE types ${a.name} and ${b.type.name} cannot be matched`, '42804'); }, args[0].type); + for (const x of args) { + // a typed argument from another category cannot be matched (COALESCE(uuid_col, text_col)) + if (!(x.isConstantLiteral && x.type.primary === DataType.text) && crossesTypeCategory(x.type, type)) { + throw new QueryError(`COALESCE types ${type.name} and ${x.type.name} cannot be matched`, '42804'); + } + } args = args.map(x => x.cast(type!)); get = (...args: any[]) => args.find(x => !nullIsh(x)); break; diff --git a/src/schema/information-schema/columns-list.ts b/src/schema/information-schema/columns-list.ts index 60ac0058..08fb768b 100644 --- a/src/schema/information-schema/columns-list.ts +++ b/src/schema/information-schema/columns-list.ts @@ -1,6 +1,7 @@ import { _ITable, _ISelection, IValue, _IIndex, _IDb, IndexKey, setId, _Transaction, _ISchema } from '../../interfaces-private'; import { Schema, nil } from '../../interfaces'; -import { toSql } from 'pgsql-ast-parser'; +import { toSql, DataTypeDef } from 'pgsql-ast-parser'; +import { DataType } from '../../interfaces'; import { Types } from '../../datatypes'; import { TableIndex } from '../table-index'; import { ReadOnlyTable } from '../readonly-table'; @@ -12,10 +13,55 @@ const IS_SCHEMA = Symbol('_is_colmun'); * Only real tables carry ColRefs; views and function-call tables expose values without column * definitions, so callers must tolerate nil rather than assume a table. */ +const BUILTIN = new Set(Object.values(DataType)); + +/** Postgres' internal (pg_type) name for a type, as udt_name reports it. */ +function udtName(type: IValue['type'], declared: DataTypeDef | nil): string { + const n = (declared as any)?.name?.toLowerCase() as string | undefined; + switch (type.primary) { + case DataType.integer: return n === 'smallint' || n === 'int2' || n === 'smallserial' ? 'int2' : 'int4'; + case DataType.bigint: return 'int8'; + case DataType.float: return n === 'real' || n === 'float4' ? 'float4' : 'float8'; + case DataType.decimal: return 'numeric'; + case DataType.text: + if (n === 'char' || n === 'character' || n === 'bpchar') return 'bpchar'; + return (type as any).len || n === 'varchar' || n === 'character varying' ? 'varchar' : 'text'; + case DataType.array: + return '_' + udtName((type as any).of, declared?.kind === 'array' ? declared.arrayOf : null); + } + // built-ins are named by their primary (timestamptz, not 'timestamp with time zone'); enums/domains by name + return BUILTIN.has(type.primary) ? type.primary : (type as any).name ?? type.primary; +} + +/** information_schema.columns.data_type: the SQL-standard name, 'ARRAY', or 'USER-DEFINED'. */ +function sqlTypeName(type: IValue['type'], declared: DataTypeDef | nil): string { + if (type.primary === DataType.array) { + return 'ARRAY'; + } + if (type.primary === DataType.citext || !BUILTIN.has(type.primary)) { + return 'USER-DEFINED'; + } + switch (udtName(type, declared)) { + case 'int2': return 'smallint'; + case 'int4': return 'integer'; + case 'int8': return 'bigint'; + case 'float4': return 'real'; + case 'float8': return 'double precision'; + case 'bool': return 'boolean'; + case 'bpchar': return 'character'; + case 'varchar': return 'character varying'; + case 'timestamptz': return 'timestamp with time zone'; + case 'timestamp': return 'timestamp without time zone'; + case 'timetz': return 'time with time zone'; + case 'time': return 'time without time zone'; + } + return udtName(type, declared); +} + function columnRef( table: _ITable, columnName: string | nil -): { notNull?: boolean; default?: IValue | nil } | nil { +): { notNull?: boolean; default?: IValue | nil; declaredType?: DataTypeDef | nil } | nil { if (!columnName) { return null; } @@ -179,14 +225,15 @@ export class ColumnsListSchema extends ReadOnlyTable implements _ITable { // column refs (views, function-call tables) still fall back to the permissive answer. is_nullable: columnRef(table, t.id)?.notNull ? 'NO' : 'YES', column_default: defaultExpressionOf(table, t.id), - data_type: t.type.primary, // <== todo + data_type: sqlTypeName(t.type, columnRef(table, t.id)?.declaredType), + character_maximum_length: (t.type as any).len ?? (udtName(t.type, columnRef(table, t.id)?.declaredType) === 'bpchar' ? 1 : null), numeric_precision: null, // <== todo numeric_precision_radix: null, // <== todo numeric_scale: null, // <== todo udt_catalog: 'pgmem', udt_schema: 'pg_catalog', - udt_name: t.type.primary, // <== todo + udt_name: udtName(t.type, columnRef(table, t.id)?.declaredType), dtd_identifier: i, // <== todo diff --git a/src/schema/overload-resolver.ts b/src/schema/overload-resolver.ts index 86a54bb5..243b5218 100644 --- a/src/schema/overload-resolver.ts +++ b/src/schema/overload-resolver.ts @@ -1,5 +1,5 @@ import { _IType, _ArgDefDetails, nil, DataType, IValue } from '../interfaces-private'; -import { Types } from '../datatypes'; +import { Types, crossesTypeCategory } from '../datatypes'; import { it } from '../utils'; import { QueryError } from '../interfaces'; @@ -141,7 +141,8 @@ class OverloadNode { } // handle variadic args - if (this.leaf && this.leaf.argsVariadic && this.compatible(arg, this.leaf.argsVariadic)) { + // (the variadic builtins - concat, concat_ws, format - take VARIADIC "any" in postgres) + if (this.leaf && this.leaf.argsVariadic && this.compatible(arg, this.leaf.argsVariadic, true)) { return this.leaf; } @@ -149,12 +150,18 @@ class OverloadNode { return null; } - private compatible(givenArg: IValue, expectedArg: _IType) { + private compatible(givenArg: IValue, expectedArg: _IType, anyCategory = false) { if (givenArg.type === expectedArg) { return true; } - return givenArg.isConstantLiteral - ? givenArg.type.canCast(expectedArg) - : givenArg.type.canConvertImplicit(expectedArg) ?? givenArg.type.canCast(expectedArg); + if (givenArg.isConstantLiteral) { + return givenArg.type.canCast(expectedArg); + } + // a typed argument never crosses a type category implicitly: lower(uuid_col) is + // "function lower(uuid) does not exist" in postgres + if (!anyCategory && crossesTypeCategory(givenArg.type, expectedArg)) { + return false; + } + return givenArg.type.canConvertImplicit(expectedArg) ?? givenArg.type.canCast(expectedArg); } } \ No newline at end of file diff --git a/src/schema/pg-catalog/binary-operators.ts b/src/schema/pg-catalog/binary-operators.ts index 180285b1..a7c4f5bb 100644 --- a/src/schema/pg-catalog/binary-operators.ts +++ b/src/schema/pg-catalog/binary-operators.ts @@ -158,16 +158,44 @@ function registerNumericOperators(schema: _ISchema) { } +/** an elapsed time as postgres' interval subtraction justifies it: days, then h/m/s/ms, one sign */ +function msToInterval(ms: number) { + const sign = ms < 0 ? -1 : 1; + let rest = Math.abs(ms); + const days = Math.floor(rest / 86400_000); rest -= days * 86400_000; + const hours = Math.floor(rest / 3600_000); rest -= hours * 3600_000; + const minutes = Math.floor(rest / 60_000); rest -= minutes * 60_000; + const seconds = Math.floor(rest / 1000); rest -= seconds * 1000; + const out: any = {}; + if (days) out.days = sign * days; + if (hours) out.hours = sign * hours; + if (minutes) out.minutes = sign * minutes; + if (seconds) out.seconds = sign * seconds; + if (rest) out.milliseconds = sign * rest; + return out; +} + function registerDatetimeOperators(schema: _ISchema) { - // ======= date "-" date ======= + // ======= date "-" date = integer (days) ======= schema.registerOperator({ operator: '-', commutative: false, left: Types.date, right: Types.date, - returns: Types.interval, + returns: Types.integer, implementation: (a, b) => utc(a).diff(utc(b), 'days'), }) + // ======= timestamp(tz) "-" timestamp(tz) = interval, as days + time (never months) ======= + for (const tt of [Types.timestamptz(), Types.timestamp()]) { + schema.registerOperator({ + operator: '-', + commutative: false, + left: tt, + right: tt, + returns: Types.interval, + implementation: (a: Date, b: Date) => msToInterval(a.getTime() - b.getTime()), + }); + } // ======= date/time "+ -" timestamp ======= for (const dt of dateTypes) { @@ -218,7 +246,41 @@ function registerDatetimeOperators(schema: _ISchema) { +/** jsonb key existence: a top-level object key, a string element of a top-level array, or the string scalar itself */ +function jsonbHasKey(doc: any, key: string): boolean { + if (Array.isArray(doc)) { + return doc.some(x => x === key); + } + if (doc && typeof doc === 'object') { + return Object.prototype.hasOwnProperty.call(doc, key); + } + return doc === key; +} + function registerJsonOperators(schema: _ISchema) { + // ======= "jsonb ? text" / "?|" (any) / "?&" (all) key existence + schema.registerOperator({ + operator: '?', + left: Types.jsonb, + right: Types.text(), + returns: Types.bool, + implementation: (a, b: string) => jsonbHasKey(a, b), + }); + schema.registerOperator({ + operator: '?|', + left: Types.jsonb, + right: Types.text().asArray(), + returns: Types.bool, + implementation: (a, b: string[]) => b.some(k => k !== null && jsonbHasKey(a, k)), + }); + schema.registerOperator({ + operator: '?&', + left: Types.jsonb, + right: Types.text().asArray(), + returns: Types.bool, + implementation: (a, b: string[]) => b.every(k => k !== null && jsonbHasKey(a, k)), + }); + // ======= "json @> json" query operator schema.registerOperator({ operator: '@>', diff --git a/src/schema/prepared.ts b/src/schema/prepared.ts index 3a9e4111..bd05655f 100644 --- a/src/schema/prepared.ts +++ b/src/schema/prepared.ts @@ -27,6 +27,8 @@ function isSchemaChange(s: Statement): boolean { case 'drop index': case 'drop sequence': case 'drop table': + case 'drop view': + case 'drop materialized view': case 'drop trigger': case 'drop type': return true; @@ -40,6 +42,53 @@ function isSchemaChange(s: Statement): boolean { } } +/** + * Where a top-level query call starts: the transaction block a previous call left open + * (BEGIN without COMMIT/ROLLBACK yet), or a fresh implicit transaction forked from root. + * + * Inside a block that a failed statement aborted, Postgres refuses everything but the + * statement that ends the block; COMMIT then rolls back, like it does in Postgres. + */ +function startCall(db: _IDb, statements: Statement[]): _Transaction { + const open = db.sessionTx; + db.sessionTx = null; + if (!open) { + return db.data.fork(); + } + if (open.aborted) { + const first = statements[0]?.type; + if (first === 'rollback' || first === 'commit') { + // the block is over either way; run the rest of the call on a fresh implicit tx + // (a COMMIT on an aborted block is a ROLLBACK in Postgres) + statements.splice(0, 1); + return db.data.fork(); + } + db.sessionTx = open; + throw new QueryError('current transaction is aborted, commands ignored until end of transaction block', '25P02'); + } + return open; +} + +/** End of a top-level call: commit, unless a BEGIN block is still open, in which case keep it for the next call. */ +function endCall(db: _IDb, state: _Transaction) { + if (state.inExplicitBlock) { + db.sessionTx = state; + return; + } + // run any deferred (DEFERRABLE INITIALLY DEFERRED) constraint checks before + // finalizing - a violation aborts the batch, so nothing persists to root + runDeferredChecks(state); + state.fullCommit(); +} + +/** A statement failed: if it ran inside a BEGIN block, the block is now aborted until ROLLBACK. */ +function failCall(db: _IDb, state: _Transaction) { + if (state.inExplicitBlock) { + state.aborted = true; + db.sessionTx = state; + } +} + let _paramList: Parameter[] | null = null; const paramsVisitor = astVisitor(() => ({ // a PREPAREd statement's own $-parameters are bound at EXECUTE time; they are not @@ -122,36 +171,48 @@ class PreparedQueryNoDescribe implements _IPreparedQuery { } bind(args: any[]): _IBoundQuery { - // Start an implicit transaction - let t = this.schema.db.data.fork(); + const db = this.schema.db; + const query = [...this.query]; + let t = startCall(db, query); const results: StatementResult[] = []; - for (const stmt of this.query) { - const s = new StatementExec(this.schema, stmt, this.singleSql); - - const compiled = s.compile(); - - // store last select for debug purposes - if (compiled instanceof SelectExec) { - this.schema.lastSelect = compiled.selection; + try { + for (const stmt of query) { + const s = new StatementExec(this.schema, stmt, this.singleSql); + + const compiled = s.compile(); + + // store last select for debug purposes + if (compiled instanceof SelectExec) { + this.schema.lastSelect = compiled.selection; + } + + // execute statement + const r = s.executeStatement(t, args); + r.result.rows = cleanResults(r.result.rows); + results.push(r); + t = r.state; } - - // execute statement - const r = s.executeStatement(t, args); - r.result.rows = cleanResults(r.result.rows); - results.push(r); - t = r.state; + } catch (e) { + failCall(db, t); + throw e; + } + if (!results.length) { + // the call was only the ROLLBACK/COMMIT closing an aborted block + endCall(db, t); + return new NoDescribeBound(this, [{ result: { command: 'ROLLBACK', fields: [], rowCount: 0, rows: [], location: { start: 0, end: 0 } } as any, state: t }], true); } return new NoDescribeBound(this, results); } } class NoDescribeBound implements _IBoundQuery { - constructor(private parent: PreparedQueryNoDescribe, private results: StatementResult[]) { + constructor(private parent: PreparedQueryNoDescribe, private results: StatementResult[], private ended = false) { } executeAll(): QueryResult { const last = this.results[this.results.length - 1]!; - runDeferredChecks(last.state); - last.state.fullCommit(); + if (!this.ended) { + endCall(this.parent.schema.db, last.state); + } this.parent.executed?.(); return last.result; } @@ -242,19 +303,42 @@ class Bound implements _IBoundQuery { } private *doExecute(outerTx?: _Transaction): IterableIterator { - // Start an implicit transaction - // (to avoid messing global data if an operation fails mid-write) - let t = outerTx ?? this.parent.schema.db.data.fork(); - + // Start an implicit transaction (to avoid messing global data if an operation fails + // mid-write), or resume the BEGIN block a previous call left open + const db = this.parent.schema.db; + let stmts = this.stmts; + let t: _Transaction; + if (outerTx) { + t = outerTx; + } else { + const asts = stmts.map(s => s.statement); + t = startCall(db, asts); + if (asts.length < stmts.length) { + // the leading ROLLBACK/COMMIT closed an aborted block + stmts = stmts.slice(stmts.length - asts.length); + if (!stmts.length) { + yield { result: { command: 'ROLLBACK', fields: [], rowCount: 0, rows: [], location: { start: 0, end: 0 } } as any, state: t }; + endCall(db, t); + return; + } + } + } let lastResult: StatementResult; - for (const s of this.stmts) { - // Execute statement - const r = s.executeStatement(t, this.args); - r.result.rows = cleanResults(r.result.rows); - yield r; - lastResult = r; - t = r.state; + try { + for (const s of stmts) { + // Execute statement + const r = s.executeStatement(t, this.args); + r.result.rows = cleanResults(r.result.rows); + yield r; + lastResult = r; + t = r.state; + } + } catch (e) { + if (!outerTx) { + failCall(db, t); + } + throw e; } if (this.parent.lastSelect) { @@ -263,10 +347,7 @@ class Bound implements _IBoundQuery { this.parent.executed?.(); if (!outerTx) { - // run any deferred (DEFERRABLE INITIALLY DEFERRED) constraint checks before - // finalizing - a violation aborts the batch, so nothing persists to root - runDeferredChecks(lastResult!.state); - lastResult!.state.fullCommit(); + endCall(db, lastResult!.state); } } diff --git a/src/schema/view.ts b/src/schema/view.ts index 5d69fc91..c84f8eaa 100644 --- a/src/schema/view.ts +++ b/src/schema/view.ts @@ -65,6 +65,7 @@ export class View extends FilterBase implements _IView { } drop(t: _Transaction): void { - throw new Error('Method not implemented.'); + this.ownerSchema._reg_unregister(this); + this.db.onSchemaChange(); } } \ No newline at end of file diff --git a/src/table.ts b/src/table.ts index dd309b0b..4ee3fa1c 100644 --- a/src/table.ts +++ b/src/table.ts @@ -239,6 +239,8 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I type: this.ownerSchema.getType(column.dataType), }; delete (tp as any as Optional).dataType; + // keep the type as written, for information_schema (see ColRef.declaredType) + (tp as any).declaredType = column.dataType; return this.addColumn(tp, t); } @@ -635,10 +637,28 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I ?? (this.name + '_constraint_' + (++this.cstGen)); } + /** + * Postgres' name for an unnamed constraint/index: __…_, then + * 1, 2… until free. Migrations rely on it: `alter table t drop constraint if + * exists t_status_check` before re-adding a widened check is how enums get extended. + */ + autoRelName(columns: string[], suffix: string, taken: (name: string) => boolean): string { + const base = [this.name, ...columns, suffix].join('_'); + let name = base; + for (let i = 1; taken(name); i++) { + name = base + i; + } + return name; + } + addCheck(_t: _Transaction, check: Expr, constraintName?: string): _IConstraint { - constraintName = this.constraintNameGen(constraintName); - this.checkNoConstraint(constraintName); const getter = withSelection(this.selection, () => buildValue(check).cast(Types.bool)); + if (!constraintName) { + // one referenced column:
__check; several:
_check + const cols = [...new Set([...getter.usedColumns].map(c => c.id!))]; + constraintName = this.autoRelName(cols.length === 1 ? cols : [], 'check', n => this.constraintsByName.has(n) || !!this.ownerSchema.getOwnObject(n)); + } + this.checkNoConstraint(constraintName); const checkVal = (t: _Transaction, v: any) => { const value = getter.get(v, t); @@ -682,6 +702,10 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I value: getter, }); } + if (!_indexName && _type === 'unique') { + // a UNIQUE constraint (not a CREATE UNIQUE INDEX) is
__key + _indexName = this.autoRelName(expressions.map(e => e.name), 'key', n => this.constraintsByName.has(n) || !!this.ownerSchema.getOwnObject(n)); + } return this.createIndex(t, { columns: keys, primary: _type === 'primary', @@ -706,7 +730,9 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I const ihash = indexHash(expressions.columns.map(x => x.value)); - const indexName = this.determineIndexRelName(expressions.indexName, ihash, expressions.ifNotExists, 'idx'); + // an unnamed index is
__idx; an expression contributes its function name (lower(x) -> lower), else "expr" + const nameParts = expressions.columns.map(c => c.value.id ?? 'expr'); + const indexName = this.determineIndexRelName(expressions.indexName, nameParts, expressions.ifNotExists, 'idx'); if (!indexName) { return null; } @@ -747,7 +773,7 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I return ret; } - private determineIndexRelName(indexName: string | nil, ihash: string, ifNotExists: boolean | nil, sufix: string): string | nil { + private determineIndexRelName(indexName: string | nil, nameParts: string[], ifNotExists: boolean | nil, sufix: string): string | nil { if (indexName) { if (this.ownerSchema.getOwnObject(indexName)) { if (ifNotExists) { @@ -757,12 +783,7 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I } return indexName; } else { - const baseName = indexName = `${this.name}_${ihash}_${sufix}`; - let i = 1; - while (this.ownerSchema.getOwnObject(indexName)) { - indexName = baseName + (i++); - } - return indexName!; + return this.autoRelName(nameParts, sufix, n => !!this.ownerSchema.getOwnObject(n) || this.constraintsByName.has(n)); } } @@ -860,7 +881,7 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I const ihash = indexHash(cst.localColumns.map(x => x.name)); let constraintName: string | nil; if (cst.constraintName?.name) { - constraintName = this.determineIndexRelName(cst.constraintName.name, ihash, false, 'fk'); + constraintName = this.determineIndexRelName(cst.constraintName.name, [], false, 'fk'); } else { // Postgres convention for an unnamed FK:
__..._fkey // (a numeric suffix is appended on collision, like Postgres) @@ -876,9 +897,35 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I } const ret = new ForeignKey(constraintName) .install(t, cst, this); + const name = constraintName; + ret.onUninstalled = () => this.constraintsByName.delete(name); return new ConstraintWrapper(this.constraintsByName, ret); } + /** + * Foreign keys on any table, this one included, that reference this table - narrowed to the + * ones through `viaColumns` when given. Postgres refuses to DROP the table or a referenced + * column out from under them ("other objects depend on it") unless CASCADE. + */ + referencingForeignKeys(viaColumns?: string[]): ForeignKey[] { + const out: ForeignKey[] = []; + for (const schema of this.ownerSchema.db.listSchemas()) { + for (const tbl of schema.listTables()) { + for (const c of tbl.listConstraints?.() ?? []) { + const fk = ((c as any).wrapped ?? c) as ForeignKey; + if (fk.constraintKind !== 'foreign key' || fk.referencedTable !== this) { + continue; + } + if (viaColumns && !fk.foreignColumns.some(fc => viaColumns.includes(fc))) { + continue; + } + out.push(fk); + } + } + } + return out; + } + getConstraint(constraint: string): _IConstraint | nil { return this.constraintsByName.get(constraint); } @@ -965,6 +1012,8 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I for (const map of this.indexByHashAndName.values()) { for (const i of map.values()) { i.index.dropFromData(t); + // free the index's name too, or re-creating the table hits "_pkey already exists" + this.ownerSchema._reg_unregister(i.index); } } // todo should also check foreign keys, cascade, ... diff --git a/src/tests/conversions.spec.ts b/src/tests/conversions.spec.ts index 68edd03c..da45a763 100644 --- a/src/tests/conversions.spec.ts +++ b/src/tests/conversions.spec.ts @@ -32,7 +32,8 @@ describe('Conversions', () => { db.public.none(`create table test(value decimal)`); db.public.none(`insert into test(value) values ('42.5')`); const many = db.public.many(`select value from test where value is not null`); - expect(many).toEqual([{ value: 42.5 }]); + // numeric is a decimal string whichever way it was inserted (as node-postgres returns it) + expect(many).toEqual([{ value: '42.5' }]); }); it('incompatible decimal with string', () => { diff --git a/src/tests/corpus-parity.spec.ts b/src/tests/corpus-parity.spec.ts new file mode 100644 index 00000000..9ee7e080 --- /dev/null +++ b/src/tests/corpus-parity.spec.ts @@ -0,0 +1,307 @@ +import { describe, it, beforeEach, expect } from 'bun:test'; + +import { newDb } from '../db'; +import { IMemoryDb } from '../interfaces'; +import { expectQueryError } from './test-utils'; + +// Gaps found by running real project migrations through pg-mem and PGlite side by side +// (tools/corpus-diff). Each case is distilled from a statement Postgres and pg-mem disagreed on. + +describe('corpus parity', () => { + + let db: IMemoryDb; + let many: (str: string) => any[]; + let none: (str: string) => void; + beforeEach(() => { + db = newDb(); + many = db.public.many.bind(db.public); + none = db.public.none.bind(db.public); + }); + + describe('a transaction block spans query calls', () => { + // Drivers send BEGIN, the work and ROLLBACK as separate round-trips. pg-mem used to + // commit at the end of every call, so the rollback undid nothing. + beforeEach(() => none(`create table k (id int primary key, n int); insert into k values (1, 1), (2, 2)`)); + const ns = () => many(`select n from k order by id`).map(r => r.n); + + it('ROLLBACK undoes work sent in earlier calls', () => { + none(`begin`); + none(`delete from k`); + expect(many(`select count(*) from k`)).toEqual([{ count: 0 }]); + none(`rollback`); + expect(ns()).toEqual([1, 2]); + }); + + it('COMMIT keeps it', () => { + none(`begin`); + none(`update k set n = 9`); + none(`commit`); + expect(ns()).toEqual([9, 9]); + }); + + it('a failed statement aborts the block until it ends, and COMMIT then rolls back', () => { + none(`begin`); + none(`update k set n = 5`); + expectQueryError(() => none(`insert into k values (1, 0)`), /duplicate key/); + expectQueryError(() => none(`select 1`), /current transaction is aborted/); + none(`commit`); + expect(ns()).toEqual([1, 2]); + expect(many(`select 1 as x`)).toEqual([{ x: 1 }]); + }); + + it('savepoints work across calls', () => { + none(`begin`); + none(`insert into k values (3, 3)`); + none(`savepoint a`); + none(`insert into k values (4, 4)`); + none(`rollback to savepoint a`); + none(`commit`); + expect(ns()).toEqual([1, 2, 3]); + }); + + it('statements outside a block still autocommit', () => { + none(`update k set n = 7 where id = 1`); + none(`rollback`); + expect(ns()).toEqual([7, 2]); + }); + }); + + describe('typed values do not cross type categories implicitly', () => { + // pg-mem accepted `auth.uid() = `; postgres has no uuid = text operator and + // refuses at CREATE POLICY - a shipped migration failed exactly there. + beforeEach(() => none(`create table t (u uuid, s text, i int, bi bigint, b bool, ts timestamptz, d date, j jsonb, v varchar(10), n numeric)`)); + const rejects = (expr: string) => expectQueryError(() => many(`select 1 from t where ${expr}`)); + const accepts = (expr: string) => expect(() => many(`select 1 from t where ${expr}`)).not.toThrow(); + + it('rejects comparisons postgres has no operator for', () => { + for (const e of ['u = s', 's = u', 'b = s', 's = true', 'v = u', 'u in (s)', 'u is not distinct from s', + 'coalesce(u, s) is null', `s in ('a', 1)`, 'lower(u) = s', 's like 1', `i like 'x'`]) { + rejects(e); + } + }); + + it('still coerces untyped literals and widens within a category', () => { + for (const e of [`u = '00000000-0000-0000-0000-000000000001'`, `i = '1'`, 'i = bi', 'bi = i', 'i = n', 'd = ts', 'ts > d', + 'd = now()', 's = v', 'u::text = s', `s || i = 'x1'`, `j ? s`, `concat(u, 'x') = s`]) { + accepts(e); + } + }); + + it('binds parameters as untyped', () => { + none(`insert into t (u) values ('00000000-0000-0000-0000-000000000001')`); + expect(db.public.prepare('select count(*) as c from t where u = $1').bind(['00000000-0000-0000-0000-000000000001']).executeAll().rows) + .toEqual([{ c: 1 }]); + }); + + it('has jsonb key-existence operators', () => { + expect(many(`select '{"a":1}'::jsonb ? 'a' as x, '["a"]'::jsonb ? 'a' as y, '{"a":1}'::jsonb ?| array['z','a'] as z, '{"a":1}'::jsonb ?& array['z','a'] as w`)) + .toEqual([{ x: true, y: true, z: true, w: false }]); + }); + }); + + describe('CREATE POLICY binds its predicates', () => { + beforeEach(() => none(`create schema auth; + create function auth.uid() returns uuid as $$ select null::uuid $$ language sql stable; + create table p (id text primary key, owner uuid, title text)`)); + + it('rejects an operator that does not exist for the operand types', () => { + expectQueryError(() => none(`create policy x on p for select using (auth.uid() = id)`), /cannot cast|operator does not exist/); + }); + it('rejects an unknown column', () => { + expectQueryError(() => none(`create policy x on p for select using (nope = 1)`), /nope/); + }); + it('rejects a non-boolean predicate', () => { + expectQueryError(() => none(`create policy x on p for select using (title)`), /must be type boolean/); + }); + it('rejects USING on INSERT and WITH CHECK on SELECT/DELETE', () => { + expectQueryError(() => none(`create policy x on p for insert using (auth.uid() = owner)`), /only WITH CHECK/); + expectQueryError(() => none(`create policy x on p for select with check (auth.uid() = owner)`), /WITH CHECK cannot be applied/); + }); + it('accepts a well-typed policy', () => { + none(`create policy x on p for all using (auth.uid() = owner) with check (auth.uid() = owner)`); + }); + }); + + describe('dependency checks on DDL', () => { + beforeEach(() => none(`create table parent (id text primary key, owner text); + create table child (id text primary key, parent_id text references parent(id), note text); + create policy c on child for select using (exists (select 1 from parent p where p.id = child.parent_id and p.owner = 'x'))`)); + + it('refuses to retype a column any policy reads, even from another table', () => { + expectQueryError(() => none(`alter table child alter column parent_id type text`), /used in a policy definition/); + expectQueryError(() => none(`alter table parent alter column owner type varchar(20)`), /used in a policy definition/); + none(`alter table child alter column note type varchar(50)`); + // p.id in the subquery is parent.id, not child.id + none(`alter table child alter column id type varchar(50)`); + }); + it('refuses to drop a column a foreign key or a policy depends on, unless CASCADE', () => { + expectQueryError(() => none(`alter table parent drop column id`), /other objects depend on it/); + expectQueryError(() => none(`alter table parent drop column owner`), /other objects depend on it/); + none(`alter table parent drop column owner cascade`); + expect(many(`select policyname from pg_policies where tablename = 'child'`)).toEqual([]); + }); + it('resolves the trigger function at CREATE TRIGGER', () => { + expectQueryError(() => none(`create trigger t before update on child for each row execute function nope()`), /does not exist/); + none(`create function f() returns int language sql as $$ select 1 $$`); + expectQueryError(() => none(`create trigger t before update on child for each row execute function f()`), /must return type trigger/); + }); + }); + + describe('unnamed constraints and indexes get postgres names', () => { + beforeEach(() => none(`create table x (id int primary key, a int check (a > 0), b int, e text, f text, + check (a > b), check (b > 0), unique (e, f), unique (e))`)); + it('names checks
__check, or
_check over several columns', () => { + // dropping by the postgres name is how migrations widen an enum-like check + none(`alter table x drop constraint x_a_check`); + none(`alter table x drop constraint x_check`); + none(`alter table x drop constraint x_b_check`); + none(`alter table x add check (b < 100)`); + none(`alter table x drop constraint x_b_check`); + }); + it('names unique constraints
__key and indexes
__idx', () => { + none(`create index on x (a, b)`); + expect(many(`select indexname from pg_indexes where tablename = 'x' order by 1`).map(r => r.indexname)) + .toEqual(['x_a_b_idx', 'x_e_f_key', 'x_e_key', 'x_pkey']); + }); + }); + + describe('three-valued IN', () => { + it('a NULL operand passes a CHECK (col IN (...))', () => { + none(`create table r (v text check (v in ('a', 'b')))`); + none(`insert into r values (null)`); + expectQueryError(() => none(`insert into r values ('c')`), /r_v_check/); + }); + it('NULL IN / NOT IN a list holding NULL is NULL', () => { + expect(many(`select (null in (1, 2)) is null as a, (1 not in (2, null)) is null as b, 1 in (1, null) as c`)) + .toEqual([{ a: true, b: true, c: true }]); + }); + }); + + describe('CREATE TABLE IF NOT EXISTS on an existing table', () => { + it('is a no-op even when the new definition has constraints', () => { + none(`create table v (id text primary key)`); + none(`create table if not exists v (id text primary key, n int not null default 0 check (n > 0) references v(id))`); + expect(many(`select column_name from information_schema.columns where table_name = 'v'`)).toEqual([{ column_name: 'id' }]); + }); + }); + + describe('numeric input', () => { + beforeEach(() => none(`create table n (i int, s smallint, b bigint, d numeric(10, 2), f float)`)); + it('range-checks integers', () => { + expectQueryError(() => none(`insert into n (i) values (3000000000)`), /integer out of range/); + expectQueryError(() => none(`insert into n (i) values ('3000000000')`), /integer out of range/); + expectQueryError(() => none(`insert into n (s) values (70000)`), /smallint out of range/); + none(`insert into n (i, s, b) values (2147483647, 32767, 3000000000)`); + }); + it('parses text strictly into the stored representation', () => { + expectQueryError(() => none(`insert into n (i) values ('12abc')`), /invalid input syntax/); + none(`insert into n (d, f) values ('5200.456', '1.5')`); + expect(many(`select d, d + 1 as d1, f from n where d is not null`)).toEqual([{ d: '5200.46', d1: '5201.46', f: 1.5 }]); + }); + }); + + describe('transaction time', () => { + it('now() and current_timestamp are constant within a transaction', () => { + none(`begin`); + const [{ a }] = many(`select now() as a`); + const t0 = Date.now(); while (Date.now() - t0 < 5) { /* let the clock move */ } + const [{ b, c }] = many(`select now() as b, current_timestamp as c`); + none(`commit`); + expect(b.getTime()).toBe(a.getTime()); + expect(c.getTime()).toBe(a.getTime()); + }); + it('current_date is a date (midnight), usable in a column default', () => { + none(`create table d (x date not null default current_date, y date)`); + none(`insert into d (y) values (current_date + 2)`); + const [{ x, y }] = many(`select x, y from d`); + expect(x.getUTCHours()).toBe(0); + expect((y.getTime() - x.getTime()) / 86400_000).toBe(2); + }); + }); + + describe('information_schema.columns reports postgres type names', () => { + it('matches data_type / udt_name', () => { + none(`create table c (a varchar(20), b smallint, c real, d numeric, e boolean, f timestamptz, g text[], h char(3))`); + expect(many(`select column_name, data_type, udt_name from information_schema.columns where table_name = 'c' order by ordinal_position`) + .map(r => `${r.column_name}:${r.data_type}:${r.udt_name}`)) + .toEqual(['a:character varying:varchar', 'b:smallint:int2', 'c:real:float4', 'd:numeric:numeric', 'e:boolean:bool', + 'f:timestamp with time zone:timestamptz', 'g:ARRAY:_text', 'h:character:bpchar']); + }); + }); + + describe('supabase function & view DDL', () => { + it('accepts SECURITY DEFINER ... SET search_path, and DROP VIEW / DROP FUNCTION f()', () => { + none(`create table t (id int)`); + none(`create or replace function public.h() returns trigger language plpgsql security definer set search_path = public as $$ begin return new; end $$`); + none(`create function g() returns int language sql stable set search_path = '' cost 5 parallel safe as $$ select 1 $$`); + none(`create view v with (security_invoker = true) as select * from t`); + none(`drop view if exists v; drop view if exists v; drop function if exists g() cascade`); + }); + }); + + describe('plpgsql', () => { + beforeEach(() => none(`create table markets (id text primary key, title text, status text, meta jsonb); + insert into markets values ('a', 'Rain?', 'open', '{"k": "v"}'), ('b', 'Snow?', 'settled', null)`)); + + it('reads multi-character operators (->>) inside bodies', () => { + none(`create function k(mid text) returns text language plpgsql as $$ begin return (select meta->>'k' from markets where id = mid); end $$`); + expect(many(`select k('a') as x`)).toEqual([{ x: 'v' }]); + }); + + it('supports %ROWTYPE and %TYPE variables, resolved at first call', () => { + none(`create function st(mid text) returns text language plpgsql as $$ + declare m public.markets%rowtype; s markets.status%type; + begin select * into m from markets where id = mid; s := m.status; + if m.status <> 'open' then return 'closed:' || m.title; end if; return 'open:' || s; end $$`); + expect(many(`select st('a') as a, st('b') as b`)).toEqual([{ a: 'open:open', b: 'closed:Snow?' }]); + }); + + it('runs a SECURITY DEFINER body as its owner, so RLS does not filter inside it', () => { + none(`create role authenticated; + create table members (crew int, uid text); alter table members enable row level security; + create policy own on members for select to authenticated using (uid = current_setting('app.uid')); + insert into members values (1, 'me'), (1, 'you'), (2, 'you'); + create function crew_size(c int) returns int language sql stable security definer as $$ select count(*)::int from members where crew = c $$; + create function crew_size_invoker(c int) returns int language sql stable as $$ select count(*)::int from members where crew = c $$`); + none(`select set_config('app.uid', 'me', false)`); + none(`set role authenticated`); + expect(many(`select crew_size(1) as d, crew_size_invoker(1) as i, current_user as u`)).toEqual([{ d: 2, i: 1, u: 'authenticated' }]); + }); + }); + + describe('date/time and record arithmetic', () => { + it('subtracts timestamps into an interval, and dates into days', () => { + expect(many(`select (timestamptz '2026-01-01' - timestamptz '2026-01-02 06:30:00')::text as a, + current_date - (current_date - 3) as b`)).toEqual([{ a: '-1 days -06:30:00', b: 3 }]); + }); + it('renders intervals and rows as text like postgres', () => { + expect(many(`select (interval '3 months')::text as a, (interval '0')::text as b`)).toEqual([{ a: '3 mons', b: '00:00:00' }]); + expect(many(`select 'x-' || s as r from (values ('a b')) as s(suffix)`)).toEqual([{ r: 'x-("a b")' }]); + }); + }); + + describe('DROP and re-CREATE', () => { + it('removes foreign keys into a table dropped with CASCADE from the catalogues', () => { + none(`create table pr (id int primary key); create table li (u int references pr(id) on delete cascade, v int references pr(id)); + drop table pr cascade; create table pr (id int primary key)`); + expect(many(`select constraint_name from information_schema.table_constraints where table_name = 'li' and constraint_type = 'FOREIGN KEY'`)).toEqual([]); + }); + + it('frees the dropped table index names', () => { + none(`create table p (id int primary key, e text unique)`); + none(`drop table if exists p cascade`); + none(`create table p (id int primary key, e text unique)`); + }); + }); + + describe('grantees must exist', () => { + it('rejects GRANT / CREATE POLICY ... TO an unknown role', () => { + none(`create table p (id int)`); + expectQueryError(() => none(`grant select on p to service_role`), /role "service_role" does not exist/); + expectQueryError(() => none(`create policy x on p for select to service_role using (true)`), /role "service_role" does not exist/); + expectQueryError(() => none(`grant select on nope to public`), /nope/); + none(`create role service_role; grant all on p to service_role, public`); + }); + }); +}); + diff --git a/src/tests/insert.queries.spec.ts b/src/tests/insert.queries.spec.ts index 57b2ea8e..3fe76d66 100644 --- a/src/tests/insert.queries.spec.ts +++ b/src/tests/insert.queries.spec.ts @@ -331,7 +331,8 @@ describe('Inserts', () => { it('should allow string for bigint columns on insert', () => { none(`create table test(a bigint, b int8);`); - expect(many(`insert into test values ('123456','111') returning a`)).toEqual([{ a: 123456 }]); + // bigint is a digit string whichever way it was inserted (as node-postgres returns it) + expect(many(`insert into test values ('123456','111') returning a`)).toEqual([{ a: '123456' }]); }) it('checks that insert values has enough columns', () => { diff --git a/src/tests/rls-ddl.spec.ts b/src/tests/rls-ddl.spec.ts index 80e10cf6..5f092139 100644 --- a/src/tests/rls-ddl.spec.ts +++ b/src/tests/rls-ddl.spec.ts @@ -42,6 +42,8 @@ describe('RLS policy DDL & storage', () => { }); it('captures AS / FOR / TO / WITH CHECK', () => { + // the grantees must exist, as in postgres ("role ... does not exist") + none(`create role alice; create role bob`); none(`create policy p2 on docs as restrictive for insert to alice, bob with check (owner = current_user)`); const p = rls().policies.find((x: any) => x.name === 'p2'); expect(p.permissive).toBe(false); diff --git a/src/transaction.ts b/src/transaction.ts index 2683dd52..c5676e1f 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -17,8 +17,22 @@ export class Transaction implements _Transaction { return !!this.parent; } - private constructor(private parent: Transaction | null, private data: ImMap) { + aborted = false; + + get inExplicitBlock(): boolean { + return this.explicit || !!this.parent?.inExplicitBlock; + } + + /** + * When this transaction started - what now(), current_timestamp and current_date return for + * its whole lifetime in postgres. A transaction forked from root starts now; nested ones + * (BEGIN inside the implicit transaction of a call, savepoint-like children) inherit it. + */ + readonly startedAt: Date; + + private constructor(private parent: Transaction | null, private data: ImMap, private explicit = false) { this.origData = data; + this.startedAt = parent?.isChild ? parent.startedAt : new Date(); } @@ -26,8 +40,8 @@ export class Transaction implements _Transaction { return new Transaction(null, this.data); } - fork(): _Transaction { - return new Transaction(this, this.data); + fork(explicit = false): _Transaction { + return new Transaction(this, this.data, explicit); } commit(): _Transaction { diff --git a/src/utils.ts b/src/utils.ts index dc563e92..43316a57 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -380,6 +380,11 @@ export function executionCtx(): ExecCtx { } return curCtx[curCtx.length - 1]; } +/** now() in postgres: the current transaction's start time (wall clock outside of any execution) */ +export function transactionNow(): Date { + return curCtx.length ? new Date(curCtx[curCtx.length - 1].transaction.startedAt.getTime()) : new Date(); +} + export function hasExecutionCtx(): boolean { return curCtx.length > 0; } diff --git a/tools/corpus-diff/README.md b/tools/corpus-diff/README.md new file mode 100644 index 00000000..9cc5d86b --- /dev/null +++ b/tools/corpus-diff/README.md @@ -0,0 +1,54 @@ +# corpus-diff: pg-mem vs Postgres on real project migrations + +Runs real RapidNative project migrations through pg-mem and PGlite (real Postgres in WASM) side by +side and reports every place they disagree. PGlite is the oracle: any difference is a pg-mem gap. + +```bash +bun tools/corpus-diff/run.ts [outDir] [--only p07] +``` + +`//migrations/*.sql` plus an optional `seed.sql`, one directory per project. +Results land in `outDir/diffs.json` (default `/../corpus-diff-out`). + +**The corpus is user project code. Never commit it.** Pull it on demand into a scratch directory. + +## Getting a corpus + +From `rapidnative-website`, with the read-only BI credentials (see its `bi` skill). This picks a +seeded random sample of recent projects that have migrations: + +```sql +select setseed(0.20261006); +select p.id from projects p +where p.created_at >= now() - interval '30 days' + and exists (select 1 from files f where f.project_id = p.id and f.file_path like 'supabase/migrations/%.sql') +order by random() limit 50; +-- then per project: +select file_path, content from files where project_id = $1 + and (file_path like 'supabase/migrations/%.sql' or file_path = 'supabase/seed.sql') order by file_path; +``` + +Use the BI database connection, not the Supabase REST URL in the website's `.env`. That URL is a +different project and holds only a handful of rows. + +## What is compared + +Each project gets the same Supabase bootstrap the agent's validator installs (`bootstrap.ts`, copied +from rapidnative-website `pg-session.ts`), then, on both engines: + +| kind | what | +|---|---| +| `bootstrap`, `migration-*`, `seed-*` | each file applies, or fails, on both. `-rejected`: pg-mem refused SQL Postgres accepts (an app that won't boot). `-accepted`: pg-mem took SQL Postgres refuses (a validator waving through a broken migration) | +| `catalog` | the exact `information_schema` / `pg_*` reads `PgSession.buildTableInfo()` makes | +| `data` | rows after the seed (generated uuids and `now()`-relative timestamps normalised) | +| `rls-read`, `rls-write` | counts and affected rows as anon, the demo user and a stranger | +| `insert`, `reject` | a copy of a seeded row must insert; single-field corruptions (bad literal, NULL into NOT NULL, int overflow, dangling FK, duplicate key, CHECK violation) must fail like Postgres | +| `ddl` | policies with mistyped operands or missing columns, retyping/dropping columns other objects depend on, triggers on missing functions | +| `trigger` | `updated_at` triggers fire | +| `cascade` | deleting the demo user: what survives | +| `rollback` | state leaking past a ROLLBACK | + +Probes run in a transaction that is rolled back. pg-mem is rebuilt after DDL probes, because its +DDL is not transactional. + +To cluster `diffs.json` by root cause, group on `kind`, `probe`, and the normalised error text of each side. diff --git a/tools/corpus-diff/bootstrap.ts b/tools/corpus-diff/bootstrap.ts new file mode 100644 index 00000000..3b131fa1 --- /dev/null +++ b/tools/corpus-diff/bootstrap.ts @@ -0,0 +1,80 @@ +// The Supabase surface RapidNative's agent validator (rapidnative-website +// src/lib/coding-agent/db/pg-session.ts, AUTH_BOOTSTRAP) installs before applying a project's +// migrations. Copied verbatim so both engines are judged on exactly what production runs. +export const DEMO_USER_ID = '00000000-0000-0000-0000-000000000001'; +export const STRANGER_ID = '00000000-0000-0000-0000-0000000000ff'; + +export const AUTH_BOOTSTRAP = [ + `create schema if not exists auth`, + `create table if not exists auth.users ( + id uuid primary key default gen_random_uuid(), + aud text default 'authenticated', + role text default 'authenticated', + email text unique, + encrypted_password text, + email_confirmed_at timestamptz, + last_sign_in_at timestamptz, + raw_app_meta_data jsonb default '{}'::jsonb, + raw_user_meta_data jsonb default '{}'::jsonb, + is_super_admin boolean default false, + created_at timestamptz default now(), + updated_at timestamptz default now(), + phone text unique, + phone_confirmed_at timestamptz, + banned_until timestamptz, + deleted_at timestamptz, + is_anonymous boolean default false + )`, + `create function auth.uid() returns uuid as $$ + select coalesce(nullif(current_setting('request.jwt.claim.sub', true), ''), '${DEMO_USER_ID}')::uuid + $$ language sql stable`, + `create function auth.role() returns text as $$ + select coalesce(nullif(current_setting('request.jwt.claim.role', true), ''), 'authenticated')::text + $$ language sql stable`, + `create role anon nologin`, + `create role authenticated nologin`, + `grant usage on schema public to anon, authenticated`, + `alter default privileges in schema public grant select, insert, update, delete on tables to anon, authenticated`, + `create schema if not exists storage`, + `create table if not exists storage.buckets ( + id text primary key, + name text not null unique, + owner uuid, + owner_id text, + public boolean default false, + avif_autodetection boolean default false, + file_size_limit bigint, + allowed_mime_types text[], + created_at timestamptz default now(), + updated_at timestamptz default now() + )`, + `create table if not exists storage.objects ( + id uuid primary key default gen_random_uuid(), + bucket_id text references storage.buckets(id), + name text, + owner uuid, + owner_id text, + metadata jsonb, + path_tokens text[] generated always as (string_to_array(name, '/')) stored, + version text, + user_metadata jsonb, + created_at timestamptz default now(), + updated_at timestamptz default now(), + last_accessed_at timestamptz default now() + )`, + `alter table storage.objects enable row level security`, + `create function storage.foldername(name text) returns text[] as $$ + select (string_to_array(name, '/'))[1:array_length(string_to_array(name, '/'), 1) - 1] + $$ language sql immutable`, + `create function storage.filename(name text) returns text as $$ + select (string_to_array(name, '/'))[array_length(string_to_array(name, '/'), 1)] + $$ language sql immutable`, + `create function storage.extension(name text) returns text as $$ + select reverse(split_part(reverse((string_to_array(name, '/'))[array_length(string_to_array(name, '/'), 1)]), '.', 1)) + $$ language sql immutable`, + `grant usage on schema storage to anon, authenticated`, + `grant select, insert, update, delete on storage.buckets, storage.objects to anon, authenticated`, +]; + +/** PgSession.refresh() runs this after every schema change. */ +export const REFRESH_GRANT = `grant select, insert, update, delete on all tables in schema public to anon, authenticated`; diff --git a/tools/corpus-diff/engines.ts b/tools/corpus-diff/engines.ts new file mode 100644 index 00000000..dca9f825 --- /dev/null +++ b/tools/corpus-diff/engines.ts @@ -0,0 +1,68 @@ +// Two engines behind one async interface, so every step of the corpus diff runs the same SQL on +// both. PGlite is real Postgres compiled to WASM and is the source of truth; pg-mem is under test. +import { PGlite } from '@electric-sql/pglite'; +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp'; +import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto'; +import { newDb } from '../../src'; + +export interface Outcome { + ok: boolean; + rows?: any[]; + rowCount?: number; + err?: string; +} + +export interface Engine { + kind: 'pglite' | 'pgmem'; + /** multi-statement, no results */ + exec(sql: string): Promise; + /** single statement with rows + affected count */ + query(sql: string): Promise<{ rows: any[]; rowCount: number }>; + close(): Promise; +} + +export async function createPglite(): Promise { + const db = new PGlite({ extensions: { uuid_ossp, pgcrypto } }); + await db.waitReady; + return { + kind: 'pglite', + exec: async (sql) => { + await db.exec(sql); + }, + query: async (sql) => { + const r = await db.query(sql); + return { rows: r.rows as any[], rowCount: r.affectedRows ?? r.rows.length }; + }, + close: () => db.close(), + }; +} + +export async function createPgmem(): Promise { + const db = newDb(); + return { + kind: 'pgmem', + exec: async (sql) => { + db.public.none(sql); + }, + query: async (sql) => { + const r = db.public.query(sql); + return { rows: r.rows ?? [], rowCount: r.rowCount ?? r.rows?.length ?? 0 }; + }, + close: async () => {}, + }; +} + +export const create = (kind: Engine['kind']) => (kind === 'pglite' ? createPglite() : createPgmem()); + +/** First line of an error, as PgSession records it. */ +export const errLine = (e: any) => String(e?.message ?? e).split('\n')[0]; + +export async function attempt(fn: () => Promise): Promise { + try { + const r = await fn(); + if (r && typeof r === 'object' && 'rows' in r) return { ok: true, rows: r.rows, rowCount: r.rowCount }; + return { ok: true }; + } catch (e) { + return { ok: false, err: errLine(e) }; + } +} diff --git a/tools/corpus-diff/run.ts b/tools/corpus-diff/run.ts new file mode 100644 index 00000000..f7fc1e11 --- /dev/null +++ b/tools/corpus-diff/run.ts @@ -0,0 +1,478 @@ +// Differential test of pg-mem against PGlite (real Postgres) on real project migrations. +// +// bun tools/corpus-diff/run.ts [outDir] [--only p07] +// +// //migrations/*.sql + optional seed.sql — one directory per project. Nothing +// from the corpus is committed: it is user project code, pulled on demand (see README.md). +// +// Every step runs on both engines and the outcomes are compared. PGlite is the oracle: a diff is a +// pg-mem gap, in either direction — rejecting SQL Postgres accepts (an app that will not boot), or +// accepting SQL Postgres rejects (a validator that waves through a migration that fails on publish). +import fs from 'node:fs'; +import path from 'node:path'; +import { AUTH_BOOTSTRAP, DEMO_USER_ID, REFRESH_GRANT, STRANGER_ID } from './bootstrap'; +import { attempt, create, type Engine, type Outcome } from './engines'; + +interface Project { + name: string; + migrations: { path: string; sql: string }[]; + seed?: string; + /** every literal in the project's SQL, used to tell seeded uuids from generated ones */ + text: string; +} + +interface Diff { + project: string; + kind: string; + table?: string; + probe: string; + sql?: string; + pglite: Outcome | unknown; + pgmem: Outcome | unknown; +} + +// ── loading ──────────────────────────────────────────────────────────── + +function loadProject(dir: string): Project { + const mdir = path.join(dir, 'migrations'); + const migrations = fs.existsSync(mdir) + ? fs.readdirSync(mdir).filter((f) => f.endsWith('.sql')).map((f) => ({ path: `supabase/migrations/${f}`, sql: fs.readFileSync(path.join(mdir, f), 'utf8') })) + : []; + const seedPath = path.join(dir, 'seed.sql'); + const seed = fs.existsSync(seedPath) ? fs.readFileSync(seedPath, 'utf8') : undefined; + return { name: path.basename(dir), migrations, seed, text: migrations.map((m) => m.sql).join('\n') + '\n' + (seed ?? '') }; +} + +// ── build: what PgSession.create() + db_seed do ─────────────────────────── + +interface Built { + eng: Engine; + /** wall clock when the seed ran: seed timestamps are now() ± an interval, compared relative to it */ + seedAt: number; + bootstrap: Outcome[]; + migrations: (Outcome & { path: string })[]; + seed?: Outcome; +} + +async function build(kind: Engine['kind'], p: Project): Promise { + const eng = await create(kind); + const bootstrap: Outcome[] = []; + for (const stmt of AUTH_BOOTSTRAP) bootstrap.push(await attempt(() => eng.exec(stmt))); + const migrations: Built['migrations'] = []; + for (const m of [...p.migrations].sort((a, b) => a.path.localeCompare(b.path))) { + migrations.push({ path: m.path, ...(await attempt(() => eng.exec(m.sql))) }); + } + await attempt(() => eng.exec(REFRESH_GRANT)); + const seedAt = Date.now(); + const seed = p.seed?.trim() ? await attempt(() => eng.exec(p.seed!)) : undefined; + return { eng, seedAt, bootstrap, migrations, seed }; +} + +// ── catalogue: exactly the reads PgSession.buildTableInfo() makes ───────── + +const catalogQueries = (t: string) => ({ + columns: `select column_name, data_type, is_nullable, column_default from information_schema.columns + where table_schema = 'public' and table_name = '${t}'`, + rls: `select rowsecurity from pg_tables where schemaname = 'public' and tablename = '${t}'`, + policies: `select * from pg_policies where tablename = '${t}'`, + indexes: `select indexname, indexdef from pg_indexes where schemaname = 'public' and tablename = '${t}'`, + foreignKeys: `select tc.constraint_name, kcu.column_name, rc.delete_rule + from information_schema.table_constraints tc + join information_schema.key_column_usage kcu + on kcu.constraint_name = tc.constraint_name + left join information_schema.referential_constraints rc + on rc.constraint_name = tc.constraint_name + where tc.constraint_type = 'FOREIGN KEY' and tc.table_name = '${t}'`, + triggers: `select trigger_name, action_timing, event_manipulation + from information_schema.triggers where event_object_table = '${t}'`, + primaryKey: `select kcu.column_name + from information_schema.table_constraints tc + join information_schema.key_column_usage kcu + on kcu.constraint_name = tc.constraint_name + where tc.constraint_type = 'PRIMARY KEY' and tc.table_name = '${t}'`, +}); + +/** Normalise a column default so cosmetic deparse differences ('x'::text vs 'x') don't count. */ +/** drop one paren pair only when it wraps the whole expression ("(a)+(b)" is left alone) */ +const unwrap = (x: string) => { + if (!x.startsWith('(') || !x.endsWith(')')) return x; + let depth = 0; + for (let i = 0; i < x.length; i++) { + if (x[i] === '(') depth++; + else if (x[i] === ')' && --depth === 0 && i < x.length - 1) return x; + } + return x.slice(1, -1); +}; +const normDefault = (d: any) => + d == null ? null : unwrap(String(d).toLowerCase().replace(/::[a-z_ ]+(\[\])?/g, '').replace(/\s+/g, '').replace(/"/g, '')); + +const sortBy = (xs: T[], key: (x: T) => string) => [...xs].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)); + +/** Each catalogue facet reduced to the fields PgSession actually reads. */ +const shapeCatalog: Record any> = { + columns: (rows) => sortBy(rows.map((r) => ({ name: r.column_name, type: r.data_type, nullable: String(r.is_nullable).toUpperCase() === 'YES', default: normDefault(r.column_default) })), (c) => c.name), + rls: (rows) => !!rows[0]?.rowsecurity, + policies: (rows) => sortBy(rows.map((r) => ({ name: r.policyname, cmd: r.cmd ?? 'ALL', using: r.qual != null, check: r.with_check != null })), (p) => p.name), + indexes: (rows) => rows.map((r) => r.indexname).sort(), + foreignKeys: (rows) => sortBy(rows.map((r) => ({ column: r.column_name, onDelete: r.delete_rule ?? 'NO ACTION' })), (f) => f.column + f.onDelete), + triggers: (rows) => [...new Set(rows.map((r) => `${r.trigger_name}:${r.action_timing}:${r.event_manipulation}`))].sort(), + primaryKey: (rows) => rows.map((r) => String(r.column_name)).sort(), +}; + +// ── value normalisation for row comparison ────────────────────────────── + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function normValue(v: any, p: Project, startedAt: number): any { + if (v == null) return null; + if (v instanceof Date) { + // timestamps made from now() during the run differ between engines by the ms the runs are + // apart: compare them as an offset from that engine's own seed time, to the nearest 10s + const off = v.getTime() - startedAt; + return Math.abs(off) < 400 * 86400_000 ? `= 0 ? '+' : ''}${Math.round(off / 10_000) * 10}s>` : v.toISOString(); + } + if (typeof v === 'bigint') return Number(v); + if (typeof v === 'number') return Number.isInteger(v) ? v : Math.round(v * 1e6) / 1e6; + if (typeof v === 'string') { + if (UUID.test(v)) return p.text.toLowerCase().includes(v.toLowerCase()) ? v.toLowerCase() : ''; + if (/^-?\d+(\.\d+)?$/.test(v)) return normValue(Number(v), p, startedAt); // numeric comes back as a string from PGlite + if (/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d/.test(v)) { + const t = Date.parse(v.replace(' ', 'T')); + if (!Number.isNaN(t)) return normValue(new Date(t), p, startedAt); + } + return v; + } + if (Array.isArray(v)) return v.map((x) => normValue(x, p, startedAt)); + if (typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, normValue(v[k], p, startedAt)])); + return v; +} + +const normRows = (rows: any[] | undefined, p: Project, t0: number) => + (rows ?? []).map((r) => JSON.stringify(normValue(r, p, t0))).sort(); + +const same = (a: any, b: any) => JSON.stringify(a) === JSON.stringify(b); + +// ── SQL literal rendering (from PGlite's rows, so both engines get identical text) ── + +const q = (s: string) => `'${s.replace(/'/g, "''")}'`; +const ident = (s: string) => `"${s.replace(/"/g, '""')}"`; + +function literal(v: any, type: string): string { + if (v == null) return 'null'; + if (v instanceof Date) return q(v.toISOString()); + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (typeof v === 'number' || typeof v === 'bigint') return String(v); + if (type === 'ARRAY' && Array.isArray(v)) return `${q('{' + v.map((x) => (x == null ? 'NULL' : '"' + String(x).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"')).join(',') + '}')}`; + if (type === 'json' || type === 'jsonb') return q(JSON.stringify(v)); + if (typeof v === 'object') return q(JSON.stringify(v)); + return q(String(v)); +} + +/** A fresh value for a unique/PK column, so a template insert does not collide. */ +function perturb(v: any, type: string): string { + if (type === 'uuid') return q(crypto.randomUUID()); + if (/int|numeric|real|double/.test(type)) return String(Math.floor(Number(v ?? 0)) + 1_000_000 + Math.floor(Math.random() * 1000)); + if (type === 'text' || type.startsWith('character')) return q(`${v ?? 'x'}-probe${Math.floor(Math.random() * 1e6)}`); + return literal(v, type); +} + +// ── probes ─────────────────────────────────────────────────────────── + +interface Probe { + kind: string; + table: string; + probe: string; + /** statements; the last one's outcome is compared */ + sql: string[]; + role?: { as: 'anon' | 'authenticated'; uid: string }; + ddl?: boolean; + /** compare returned rows, not just ok/rowCount */ + rows?: boolean; +} + +const asRole = (r: NonNullable) => [ + `select set_config('request.jwt.claim.sub', '${r.uid}', false)`, + `select set_config('request.jwt.claim.role', '${r.as}', false)`, + `set role ${r.as}`, +]; + +const DEMO = { as: 'authenticated' as const, uid: DEMO_USER_ID }; +const STRANGER = { as: 'authenticated' as const, uid: STRANGER_ID }; +const ANON = { as: 'anon' as const, uid: '' }; + +interface TableMeta { + name: string; + cols: { name: string; type: string; nullable: boolean; hasDefault: boolean; generated: boolean }[]; + pk: string[]; + unique: string[][]; + fks: { cols: string[]; refTable: string }[]; + checks: { cols: string[]; def: string }[]; + policyCols: string[]; + referencedBy: number; + sample?: Record; +} + +/** Everything probe generation needs, read from the oracle. */ +async function tableMeta(eng: Engine, t: string): Promise { + const cols = (await eng.query(`select column_name, data_type, is_nullable, column_default, is_generated, is_identity + from information_schema.columns where table_schema='public' and table_name='${t}' order by ordinal_position`)).rows.map((r) => ({ + name: r.column_name, type: r.data_type, nullable: r.is_nullable === 'YES', hasDefault: r.column_default != null || r.is_identity === 'YES', generated: r.is_generated === 'ALWAYS', + })); + const cons = (await eng.query(`select c.contype, pg_get_constraintdef(c.oid) def, c.confrelid::regclass::text reftable, + array(select a.attname::text from unnest(c.conkey) k join pg_attribute a on a.attrelid=c.conrelid and a.attnum=k) cols + from pg_constraint c where c.conrelid = 'public.${ident(t)}'::regclass`)).rows; + const policies = (await eng.query(`select qual, with_check from pg_policies where schemaname='public' and tablename='${t}'`)).rows; + const policyText = policies.map((r) => `${r.qual ?? ''} ${r.with_check ?? ''}`).join(' '); + const referencedBy = Number((await eng.query(`select count(*)::int n from pg_constraint where contype='f' and confrelid = 'public.${ident(t)}'::regclass and conrelid <> confrelid`)).rows[0].n); + const sample = (await eng.query(`select * from public.${ident(t)} limit 1`)).rows[0]; + return { + name: t, + cols, + pk: cons.find((c) => c.contype === 'p')?.cols ?? [], + unique: cons.filter((c) => c.contype === 'u').map((c) => c.cols), + fks: cons.filter((c) => c.contype === 'f').map((c) => ({ cols: c.cols, refTable: c.reftable })), + checks: cons.filter((c) => c.contype === 'c').map((c) => ({ cols: c.cols, def: c.def })), + policyCols: cols.map((c) => c.name).filter((n) => new RegExp(`\\b${n}\\b`).test(policyText)), + referencedBy, + sample, + }; +} + +function templateInsert(m: TableMeta, override: Record = {}): string | null { + if (!m.sample) return null; + const keyCols = new Set([...m.pk, ...m.unique.flat()]); + const cols = m.cols.filter((c) => !c.generated); + const vals = cols.map((c) => { + if (c.name in override) return override[c.name]; + if (keyCols.has(c.name)) return perturb(m.sample![c.name], c.type); + return literal(m.sample![c.name], c.type); + }); + return `insert into public.${ident(m.name)} (${cols.map((c) => ident(c.name)).join(', ')}) values (${vals.join(', ')})`; +} + +function probesFor(m: TableMeta, all: TableMeta[]): Probe[] { + const t = ident(m.name); + const out: Probe[] = []; + const P = (p: Omit) => out.push({ table: m.name, ...p }); + + // RLS reads + for (const [label, role] of [['anon', ANON], ['demo', DEMO], ['stranger', STRANGER]] as const) { + P({ kind: 'rls-read', probe: `count as ${label}`, sql: [`select count(*)::int as n from public.${t}`], role, rows: true }); + } + const anyCol = m.cols.find((c) => !c.generated && !m.pk.includes(c.name))?.name ?? m.cols[0]?.name; + if (anyCol) { + for (const [label, role] of [['demo', DEMO], ['stranger', STRANGER], ['anon', ANON]] as const) { + P({ kind: 'rls-write', probe: `update as ${label}`, sql: [`update public.${t} set ${ident(anyCol)} = ${ident(anyCol)}`], role }); + P({ kind: 'rls-write', probe: `delete as ${label}`, sql: [`delete from public.${t}`], role }); + } + } + // updated_at trigger: inside one transaction now() is constant, so a row the trigger touched has updated_at = now() + if (m.sample && m.cols.some((c) => c.name === 'updated_at') && anyCol && anyCol !== 'updated_at') { + P({ kind: 'trigger', probe: 'updated_at fires on update', sql: [`update public.${t} set ${ident(anyCol)} = ${ident(anyCol)} returning (updated_at = now()) as fired`], rows: true }); + } + + // template insert (positive) and single-field corruptions (negative), superuser and per role + const tmpl = templateInsert(m); + if (tmpl) { + P({ kind: 'insert', probe: 'template row', sql: [tmpl] }); + P({ kind: 'rls-write', probe: 'template insert as demo', sql: [tmpl], role: DEMO }); + P({ kind: 'rls-write', probe: 'template insert as stranger', sql: [tmpl], role: STRANGER }); + P({ kind: 'rls-write', probe: 'template insert as anon', sql: [tmpl], role: ANON }); + for (const c of m.cols.filter((c) => !c.generated)) { + const bad: [string, string][] = []; + if (!c.nullable) bad.push(['null into not-null', 'null']); + if (/^(integer|bigint|smallint|numeric|real|double precision)$/.test(c.type)) bad.push(['non-numeric literal', q('abc')]); + if (c.type === 'uuid') bad.push(['malformed uuid', q('not-a-uuid')]); + if (c.type === 'boolean') bad.push(['malformed boolean', q('maybe')]); + if (/^(timestamp|date|time)/.test(c.type)) bad.push(['malformed timestamp', q('not-a-date')]); + if (c.type === 'jsonb' || c.type === 'json') bad.push(['malformed json', q('{bad')]); + if (c.type === 'integer') bad.push(['int4 overflow', '3000000000']); + if (c.type === 'smallint') bad.push(['int2 overflow', '70000']); + if (/^character varying/.test(c.type)) bad.push(['varchar too long (if bounded)', q('x'.repeat(10_001))]); + if (m.fks.some((f) => f.cols.includes(c.name))) bad.push(['dangling foreign key', c.type === 'uuid' ? q(crypto.randomUUID()) : q('no-such-parent')]); + if (m.sample && [m.pk, ...m.unique].some((k) => k.length === 1 && k[0] === c.name)) bad.push(['duplicate key', literal(m.sample[c.name], c.type)]); + for (const ch of m.checks.filter((ch) => ch.cols.includes(c.name))) { + if (c.type === 'text' || c.type.startsWith('character')) bad.push([`check violation ${ch.def.slice(0, 60)}`, q('__bogus__')]); + else if (/int|numeric|real|double/.test(c.type)) bad.push([`check violation ${ch.def.slice(0, 60)}`, '-999999']); + } + for (const [what, val] of bad) { + const sql = templateInsert(m, { [c.name]: val }); + if (sql) P({ kind: 'reject', probe: `${c.name}: ${what}`, sql: [sql] }); + } + } + } + + // DDL the validator must judge the way Postgres does + for (const c of m.cols) { + if (c.type === 'text' || c.type === 'uuid') { + P({ kind: 'ddl', probe: `policy auth.uid() = ${c.type} column (${c.name})`, sql: [`create policy zz_probe on public.${t} for select using (auth.uid() = ${ident(c.name)})`], ddl: true }); + } + } + P({ kind: 'ddl', probe: 'policy on missing column', sql: [`create policy zz_probe on public.${t} for select using (zz_no_such_col = 1)`], ddl: true }); + for (const c of m.policyCols) { + P({ kind: 'ddl', probe: `alter type of policy-referenced column ${c}`, sql: [`alter table public.${t} alter column ${ident(c)} type text`], ddl: true }); + } + P({ kind: 'ddl', probe: 'index on missing column', sql: [`create index zz_probe_idx on public.${t} (zz_no_such_col)`], ddl: true }); + if (m.cols[0]) P({ kind: 'ddl', probe: 'add existing column', sql: [`alter table public.${t} add column ${ident(m.cols[0].name)} text`], ddl: true }); + P({ kind: 'ddl', probe: 'create existing table', sql: [`create table public.${t} (id int)`], ddl: true }); + P({ kind: 'ddl', probe: 'create table if not exists (existing)', sql: [`create table if not exists public.${t} (id int)`], ddl: true }); + if (m.referencedBy > 0) P({ kind: 'ddl', probe: 'drop table with dependents', sql: [`drop table public.${t}`], ddl: true }); + if (m.pk.length === 1 && m.referencedBy > 0) P({ kind: 'ddl', probe: 'drop referenced pk column', sql: [`alter table public.${t} drop column ${ident(m.pk[0])}`], ddl: true }); + P({ kind: 'ddl', probe: 'drop policy if exists (missing)', sql: [`drop policy if exists zz_nope on public.${t}`], ddl: true }); + P({ kind: 'ddl', probe: 'drop policy (missing)', sql: [`drop policy zz_nope on public.${t}`], ddl: true }); + P({ kind: 'ddl', probe: 'trigger with missing function', sql: [`create trigger zz_trg before update on public.${t} for each row execute function public.zz_no_such_fn()`], ddl: true }); + void all; + return out; +} + +/** Cross-table probes. */ +function globalProbes(metas: TableMeta[]): Probe[] { + const out: Probe[] = []; + if (!metas.length) return out; + const counts = `select ${metas.map((m) => `(select count(*)::int from public.${ident(m.name)}) as ${ident(m.name)}`).join(', ')}`; + out.push({ kind: 'cascade', table: '*', probe: 'delete demo user, surviving rows', sql: [`delete from auth.users where id = '${DEMO_USER_ID}'`, counts], rows: true }); + out.push({ kind: 'reject', table: 'auth.users', probe: 'pg uuid = text comparison', sql: [`select 1 where auth.uid() = 'abc'::text`] }); + return out; +} + +// ── running a probe on one engine ───────────────────────────────────────── + +async function runProbe(eng: Engine, p: Probe): Promise { + await attempt(() => eng.exec('begin')); + try { + if (p.role) for (const s of asRole(p.role)) await eng.exec(s); + let last: Outcome = { ok: true }; + for (const s of p.sql) { + last = await attempt(() => eng.query(s)); + if (!last.ok) break; + } + return last; + } finally { + await attempt(() => eng.exec('reset role')); + await attempt(() => eng.exec('rollback')); + } +} + +/** Row counts of every user table + auth.users — detects a rollback that did not roll back. */ +async function fingerprint(eng: Engine, tables: string[]): Promise { + const parts: string[] = []; + for (const t of ['auth.users', ...tables.map((t) => `public.${ident(t)}`)]) { + const o = await attempt(() => eng.query(`select count(*)::int as n from ${t}`)); + parts.push(`${t}=${o.ok ? o.rows![0].n : 'ERR'}`); + } + const zz = await attempt(() => eng.query(`select count(*)::int as n from pg_policies where policyname = 'zz_probe'`)); + parts.push(`zz=${zz.ok ? zz.rows![0].n : '?'}`); + return parts.join(','); +} + +// ── per project ─────────────────────────────────────────────────────── + +async function diffProject(p: Project): Promise<{ diffs: Diff[]; stats: Record }> { + const t0 = Date.now(); + const diffs: Diff[] = []; + const stats: Record = {}; + const bump = (k: string) => (stats[k] = (stats[k] ?? 0) + 1); + const D = (d: Omit) => diffs.push({ project: p.name, ...d }); + + const ref = await build('pglite', p); + let mem = await build('pgmem', p); + + ref.bootstrap.forEach((o, i) => { + if (o.ok !== mem.bootstrap[i].ok) D({ kind: 'bootstrap', probe: AUTH_BOOTSTRAP[i].replace(/\s+/g, ' ').slice(0, 80), pglite: o, pgmem: mem.bootstrap[i] }); + }); + ref.migrations.forEach((o, i) => { + bump('migrations'); + const m = mem.migrations[i]; + if (o.ok !== m.ok) D({ kind: o.ok ? 'migration-rejected' : 'migration-accepted', probe: o.path, pglite: o, pgmem: m }); + }); + if (ref.seed) { + bump('seeds'); + if (ref.seed.ok !== mem.seed!.ok) D({ kind: ref.seed.ok ? 'seed-rejected' : 'seed-accepted', probe: 'seed.sql', pglite: ref.seed, pgmem: mem.seed }); + } + + // catalogue + const tables = (await ref.eng.query(`select tablename from pg_tables where schemaname = 'public' order by 1`)).rows.map((r) => r.tablename as string); + const memTablesO = await attempt(() => mem.eng.query(`select tablename from pg_tables where schemaname = 'public' order by 1`)); + const memTables = memTablesO.ok ? memTablesO.rows!.map((r) => r.tablename as string).sort() : []; + if (!same(tables, memTables)) D({ kind: 'catalog', table: '*', probe: 'pg_tables', pglite: tables, pgmem: memTablesO.ok ? memTables : memTablesO }); + + for (const t of tables) { + for (const [facet, sql] of Object.entries(catalogQueries(t))) { + bump('catalog'); + const a = await attempt(() => ref.eng.query(sql)); + const b = await attempt(() => mem.eng.query(sql)); + if (!b.ok) { D({ kind: 'catalog', table: t, probe: `${facet} (query failed)`, sql, pglite: '(ok)', pgmem: b }); continue; } + const sa = shapeCatalog[facet](a.rows!); + const sb = shapeCatalog[facet](b.rows!); + if (!same(sa, sb)) D({ kind: 'catalog', table: t, probe: facet, pglite: sa, pgmem: sb }); + } + // data after seed + bump('data'); + const a = await attempt(() => ref.eng.query(`select * from public.${ident(t)}`)); + const b = await attempt(() => mem.eng.query(`select * from public.${ident(t)}`)); + const ra = normRows(a.rows, p, ref.seedAt), rb = normRows(b.rows, p, mem.seedAt); + if (a.ok !== b.ok || !same(ra, rb)) D({ kind: 'data', table: t, probe: 'rows after seed', pglite: a.ok ? ra.slice(0, 3) : a, pgmem: b.ok ? rb.slice(0, 3) : b }); + } + + // probes (generated from the oracle's catalogue) + const metas: TableMeta[] = []; + for (const t of tables) metas.push(await tableMeta(ref.eng, t)); + const probes = [...metas.flatMap((m) => probesFor(m, metas)), ...globalProbes(metas)]; + const memFp0 = await fingerprint(mem.eng, memTables); + for (const pr of probes) { + bump(`probe:${pr.kind}`); + const a = await runProbe(ref.eng, pr); + const b = await runProbe(mem.eng, pr); + let differ = a.ok !== b.ok; + if (!differ && a.ok && !pr.ddl) differ = (a.rowCount ?? 0) !== (b.rowCount ?? 0) && /^(update|delete|insert)/i.test(pr.sql.at(-1)!); + if (!differ && a.ok && pr.rows) differ = !same(normRows(a.rows, p, t0), normRows(b.rows, p, t0)); + if (differ) { + D({ kind: pr.kind, table: pr.table, probe: pr.probe, sql: pr.sql.join(';\n'), pglite: { ...a, rows: a.rows?.slice(0, 3) }, pgmem: { ...b, rows: b.rows?.slice(0, 3) } }); + } + // pg-mem cannot roll back DDL; rebuild instead of letting one probe poison the next + const leaked = pr.ddl ? true : (await fingerprint(mem.eng, memTables)) !== memFp0; + if (leaked) { + if (!pr.ddl) D({ kind: 'rollback', table: pr.table, probe: `rollback after: ${pr.probe}`, pglite: '(restored)', pgmem: '(state leaked past rollback)' }); + await mem.eng.close(); + mem = await build('pgmem', p); + } + } + await ref.eng.close(); + await mem.eng.close(); + return { diffs, stats }; +} + +// ── main ────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const corpus = args[0]; +const outDir = args[1] && !args[1].startsWith('--') ? args[1] : path.join(corpus, '..', 'corpus-diff-out'); +const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null; +if (!corpus) { + console.error('usage: bun tools/corpus-diff/run.ts [outDir] [--only ]'); + process.exit(2); +} +fs.mkdirSync(outDir, { recursive: true }); +const projects = fs.readdirSync(corpus).filter((d) => fs.statSync(path.join(corpus, d)).isDirectory()).filter((d) => !only || d === only).sort(); +const all: Diff[] = []; +const totals: Record = {}; +const perProject: { project: string; diffs: number; ms: number; error?: string }[] = []; +for (const name of projects) { + const t = Date.now(); + try { + const { diffs, stats } = await diffProject(loadProject(path.join(corpus, name))); + all.push(...diffs); + for (const [k, v] of Object.entries(stats)) totals[k] = (totals[k] ?? 0) + v; + perProject.push({ project: name, diffs: diffs.length, ms: Date.now() - t }); + console.log(`${name}: ${diffs.length} diffs (${Date.now() - t}ms)`); + } catch (e: any) { + perProject.push({ project: name, diffs: -1, ms: Date.now() - t, error: String(e?.stack ?? e) }); + console.log(`${name}: HARNESS ERROR ${String(e?.message ?? e).split('\n')[0]}`); + } +} +fs.writeFileSync(path.join(outDir, 'diffs.json'), JSON.stringify({ totals, perProject, diffs: all }, null, 1)); +const byKind: Record = {}; +for (const d of all) byKind[d.kind] = (byKind[d.kind] ?? 0) + 1; +console.log('\ntotals', totals); +console.log('diffs by kind', byKind); +console.log(`projects with ≥1 diff: ${perProject.filter((p) => p.diffs !== 0).length}/${perProject.length}`); From 19112a1054a00a12a8a01c7235bff01b555b177f Mon Sep 17 00:00:00 2001 From: sanketsahu Date: Tue, 6 Oct 2026 15:24:00 -0700 Subject: [PATCH 2/5] feat: transactional DDL, Postgres FK action order, RLS fixes; 0/50 divergence on 250 projects Second round from the corpus diff (tools/corpus-diff). All five sets of 50 real projects (250, ~46k checks) now match PGlite. - DDL is transactional: a failed migration or ROLLBACK / ROLLBACK TO undoes created, altered and dropped tables, columns, constraints, indexes, policies, triggers and functions (schema-snapshot.ts captures the schema metadata before a transaction's first DDL; row data already rolls back with the transaction). DDL no longer force-commits mid-transaction, and commit rebases onto a parent that moved (CREATE SCHEMA registers catalogues in root). - referential actions run breadth-first like postgres RI triggers (ri-queue.ts): a direct NO ACTION reference is checked before a grandchild cascade - security: RLS was skipped on index lookups - `select ... where id = 2` returned rows no policy allowed, and UPDATE/DELETE ... WHERE reached them. Filters on a policed table now apply the policies on top. - "infinite recursion detected in policy for relation", following postgres' expansion rules (only relations whose applicable policies have subqueries; UPDATE/DELETE that read columns also expand SELECT policies) - CREATE OR REPLACE TRIGGER; replacing a trigger function no longer "changes its return type" - information_schema.columns.column_default reads like pg_get_expr (0.30, ARRAY[...], (now() + '2 days'::interval), '-1') - fix: count(*) with a filter on information_schema.columns / pg_constraint crashed Requires @tinbase/pgsql-ast-parser 12.2.0 (CREATE OR REPLACE TRIGGER, numeric literal text). Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/constraints/foreign-key.ts | 7 +- src/execution/records-mutations/deletion.ts | 4 +- src/execution/records-mutations/update.ts | 4 +- src/execution/ri-queue.ts | 34 ++++ src/execution/rls-enforce.ts | 131 +++++++++++++- src/execution/schema-amends/alter-enum.ts | 6 - src/execution/schema-amends/alter-index.ts | 3 - src/execution/schema-amends/alter-sequence.ts | 6 - src/execution/schema-amends/alter.ts | 8 - src/execution/schema-amends/comment.ts | 3 - .../schema-amends/create-composite-type.ts | 2 - src/execution/schema-amends/create-domain.ts | 2 - src/execution/schema-amends/create-enum.ts | 6 - .../schema-amends/create-function.ts | 21 ++- src/execution/schema-amends/create-index.ts | 6 - .../schema-amends/create-materialized-view.ts | 7 - src/execution/schema-amends/create-schema.ts | 6 - .../schema-amends/create-sequence.ts | 6 - src/execution/schema-amends/create-table.ts | 11 +- src/execution/schema-amends/create-trigger.ts | 6 +- src/execution/schema-amends/create-view.ts | 6 - src/execution/schema-amends/drop-index.ts | 6 - src/execution/schema-amends/drop-sequence.ts | 6 - src/execution/schema-amends/drop-table.ts | 6 - src/execution/schema-amends/drop-type.ts | 6 - src/execution/statement-exec.ts | 9 +- src/execution/transaction-statements.ts | 6 +- src/interfaces-private.ts | 12 +- src/schema-snapshot.ts | 163 +++++++++++++++++ src/schema/information-schema/columns-list.ts | 71 +++++++- src/schema/overload-resolver.ts | 11 ++ src/schema/pg-catalog/pg-constraints-list.ts | 2 +- src/schema/prepared.ts | 11 +- src/schema/schema.ts | 10 +- src/tests/corpus-parity.spec.ts | 171 ++++++++++++++++++ src/tests/insert.queries.spec.ts | 6 +- src/transaction.ts | 68 ++++++- src/utils.ts | 6 + tools/corpus-diff/run.ts | 25 ++- 40 files changed, 741 insertions(+), 141 deletions(-) create mode 100644 src/execution/ri-queue.ts create mode 100644 src/schema-snapshot.ts diff --git a/package.json b/package.json index cac95df3..02d378b4 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "immutable": "^4.3.4", "lru-cache": "^6.0.0", "object-hash": "^2.0.3", - "pgsql-ast-parser": "npm:@tinbase/pgsql-ast-parser@^12.1.0" + "pgsql-ast-parser": "npm:@tinbase/pgsql-ast-parser@^12.2.0" }, "devDependencies": { "@babel/cli": "^7.10.5", diff --git a/src/constraints/foreign-key.ts b/src/constraints/foreign-key.ts index d06cb6cd..f07c0f78 100644 --- a/src/constraints/foreign-key.ts +++ b/src/constraints/foreign-key.ts @@ -3,6 +3,7 @@ import { Expr, ExprBinary, TableConstraintForeignKey } from 'pgsql-ast-parser'; import { asTable, CreateIndexColDef, _IConstraint, _ITable, _Transaction } from '../interfaces-private'; import { nullIsh } from '../utils'; import { deferCheck } from '../execution/deferred-checks'; +import { enqueueRi } from '../execution/ri-queue'; export class ForeignKey implements _IConstraint { @@ -129,8 +130,9 @@ export class ForeignKey implements _IConstraint { right: b, }), equals[0]); - // check nothing matches - for (const local of table.selection.filter(expr).enumerate(dt)) { + // check nothing matches - as a queued RI action, see ri-queue.ts + enqueueRi(() => { + for (const local of [...table.selection.filter(expr).enumerate(dt)]) { // ====== ON DELETE switch (neu ? onUpdate : onDelete) { case 'no action': @@ -155,6 +157,7 @@ export class ForeignKey implements _IConstraint { break; } } + }); })); // ===================== diff --git a/src/execution/records-mutations/deletion.ts b/src/execution/records-mutations/deletion.ts index fe6c1261..45c05a6f 100644 --- a/src/execution/records-mutations/deletion.ts +++ b/src/execution/records-mutations/deletion.ts @@ -1,5 +1,5 @@ import { _ITable, _Transaction, IValue, _Explainer, _ISchema, asTable, _ISelection, _IIndex, _IStatement } from '../../interfaces-private'; -import { applyReadRls } from '../rls-enforce'; +import { applyReadRls, statementReadsColumns } from '../rls-enforce'; import { fireRowTriggers, fireStatementTriggers, SKIP_ROW } from '../triggers'; import { DeleteStatement } from 'pgsql-ast-parser'; import { MutationDataSourceBase } from './mutation-base'; @@ -12,7 +12,7 @@ export class Deletion extends MutationDataSourceBase { const { schema } = buildCtx(); const table = asTable(schema.getObject(ast.from)); // row-level security: DELETE can only affect rows visible via DELETE policies - const mutatedSel = applyReadRls(table, table.selection, 'delete') + const mutatedSel = applyReadRls(table, table.selection, 'delete', statementReadsColumns(ast as any)) .filter(ast.where); super(table, mutatedSel, ast); diff --git a/src/execution/records-mutations/update.ts b/src/execution/records-mutations/update.ts index 7e2ef026..6394a601 100644 --- a/src/execution/records-mutations/update.ts +++ b/src/execution/records-mutations/update.ts @@ -1,5 +1,5 @@ import { _ITable, _Transaction, _Explainer, _ISchema, asTable, _ISelection, _IIndex, _IStatement } from '../../interfaces-private'; -import { applyReadRls, checkWriteRls } from '../rls-enforce'; +import { applyReadRls, checkWriteRls, statementReadsColumns } from '../rls-enforce'; import { fireRowTriggers, fireStatementTriggers, SKIP_ROW } from '../triggers'; import { UpdateStatement } from 'pgsql-ast-parser'; import { MutationDataSourceBase, Setter, createSetter } from './mutation-base'; @@ -63,7 +63,7 @@ export class Update extends MutationDataSourceBase { // => REGULAR UPDATE // row-level security: UPDATE can only affect rows visible via UPDATE policies - mutatedSel = applyReadRls(into, into.selection, 'update') + mutatedSel = applyReadRls(into, into.selection, 'update', statementReadsColumns(ast as any)) .filter(ast.where); } diff --git a/src/execution/ri-queue.ts b/src/execution/ri-queue.ts new file mode 100644 index 00000000..fb679dde --- /dev/null +++ b/src/execution/ri-queue.ts @@ -0,0 +1,34 @@ +/** + * Referential actions run the way postgres runs its RI triggers: queued as AFTER events and fired + * in order once the statement's own rows are done. A cascade's own RI events are appended to the + * SAME queue (postgres runs RI actions with fire_triggers = false), so actions run breadth-first: + * deleting a user checks a direct NO ACTION reference BEFORE a grandchild cascade (user -> + * profile -> rows) has removed the rows - and fails, like postgres does. + * + * Each statement execution has its own queue (a statement run by a trigger fires its RI events at + * its own end). Outside any statement (the JS table API) actions run immediately. + */ +const queues: (() => void)[][] = []; + +export function withRiQueue(run: () => T): T { + const queue: (() => void)[] = []; + queues.push(queue); + try { + const ret = run(); + while (queue.length) { + queue.shift()!(); + } + return ret; + } finally { + queues.pop(); + } +} + +export function enqueueRi(action: () => void): void { + const queue = queues[queues.length - 1]; + if (queue) { + queue.push(action); + } else { + action(); + } +} diff --git a/src/execution/rls-enforce.ts b/src/execution/rls-enforce.ts index 55e33c10..4a7cb216 100644 --- a/src/execution/rls-enforce.ts +++ b/src/execution/rls-enforce.ts @@ -5,9 +5,104 @@ import { buildValue } from '../parser/expression-builder'; import { withSelection } from '../parser/context'; import { currentRole } from './roles'; import { Policy, policyAppliesToCommand, policyAppliesToRole } from './rls'; +import { astVisitor, Expr, FromTable } from 'pgsql-ast-parser'; export type RlsCommand = 'select' | 'insert' | 'update' | 'delete'; +/** tables read in a predicate's subqueries (FROM / JOIN), resolved against the table's schema */ +function tablesReadBy(owner: _ITable, exprs: (Expr | null | undefined)[]): _ITable[] { + const out = new Set<_ITable>(); + const v = astVisitor(() => ({ + fromTable: (f: FromTable) => { + const obj = owner.ownerSchema.getThisOrSiblingFor(f.name).getObject(f.name, { nullIfNotFound: true }); + if (obj && (obj as any).type === 'table') { + out.add(obj as _ITable); + } + return f; + }, + })); + for (const e of exprs) { + if (e) { + v.expr(e); + } + } + return [...out]; +} + +/** does any of these predicates contain a subquery ("sublink")? */ +function hasSubLinks(exprs: (Expr | null | undefined)[]): boolean { + let found = false; + const v = astVisitor(() => ({ selection: () => { found = true; return null as any; } })); + for (const e of exprs) { + if (e && !found) { + v.expr(e); + } + } + return found; +} + +/** + * Postgres expands the policies of the relations a query touches - and, through the subqueries in + * those policies, of the relations they read - and refuses a cycle: a profiles policy that queries + * profiles (the usual "admins can see everyone" attempt) is "infinite recursion detected in policy + * for relation profiles", for any role RLS applies to and whether or not there are rows. + * + * Like its fireRIRrules, a relation joins the set being expanded, and is checked against it, only + * when its applicable policies contain a subquery: an UPDATE policy reading its own table whose + * SELECT policy is just `true` is fine. Returns the re-entered relation. + */ +function recursivePolicyRelation(table: _ITable, commands: RlsCommand[], roleName: string, expanding = new Set<_ITable>()): _ITable | null { + if (!table.rls.enabled) { + return null; + } + const exprs = table.rls.policies + .filter(p => policyAppliesToRole(p, roleName) && commands.some(c => policyAppliesToCommand(p, c))) + .flatMap(p => [p.using, p.withCheck]); + if (!hasSubLinks(exprs)) { + return null; + } + if (expanding.has(table)) { + return table; + } + expanding.add(table); + try { + for (const read of tablesReadBy(table, exprs)) { + // a subquery reads: its table's SELECT policies get expanded + const hit = recursivePolicyRelation(read, ['select'], roleName, expanding); + if (hit) { + return hit; + } + } + return null; + } finally { + expanding.delete(table); + } +} + +const recursionCache = new WeakMap<_ITable, Map>(); + +/** Throws postgres' error when expanding this table's policies for the current role recurses. */ +export function assertNoPolicyRecursion(table: _ITable, command: RlsCommand, t: _Transaction, readsColumns = false): void { + const role = currentRole(t).name; + // an UPDATE/DELETE that reads columns (WHERE, RETURNING, SET x = ) needs SELECT rights, + // so the table's SELECT policies are expanded too + const commands: RlsCommand[] = readsColumns && command !== 'select' ? [command, 'select'] : [command]; + // policies can change (DDL): key the cache on them too + const key = `${role}|${commands.join(',')}|${(table.ownerSchema.db as any).schemaVersion}`; + let byKey = recursionCache.get(table); + if (!byKey) { + recursionCache.set(table, byKey = new Map()); + } + let hit = byKey.get(key); + if (hit === undefined) { + hit = recursivePolicyRelation(table, commands, role); + byKey.set(key, hit); + } + if (hit) { + throw new QueryError(`infinite recursion detected in policy for relation "${hit.name}"`, '42P17'); + } +} + /** True when the current role skips RLS entirely (superuser or BYPASSRLS). */ export function bypassesRls(t: _Transaction): boolean { const role = currentRole(t); @@ -64,7 +159,7 @@ class RlsSelection extends FilterBase { return null; } - constructor(private sel: _ISelection, private table: _ITable, private command: RlsCommand) { + constructor(private sel: _ISelection, private table: _ITable, private command: RlsCommand, private readsColumns = false) { super(sel); this.compiled = compilePolicies(sel, table.rls.policies); } @@ -81,10 +176,25 @@ class RlsSelection extends FilterBase { if (!this.enforced(t)) { return this.sel.hasItem(raw, t); } + // index lookups (WHERE id = …) check rows here rather than enumerating + assertNoPolicyRecursion(this.table, this.command, t, this.readsColumns); return this.sel.hasItem(raw, t) && rowPasses(this.compiled, 'using', currentRole(t).name, this.command, raw, t); } + /** + * WHERE on a policed table. Index-based filters (id = 1, IN, ranges) take their index from the + * column's origin - the raw table - and become a selection over it, which silently dropped this + * layer: under RLS, `select * from t where id = 2` returned rows no policy allowed. Filter the + * unprotected selection (keeping index lookups) and re-apply the policies on top. + */ + filter(where: Expr | undefined | null): _ISelection { + if (!where) { + return this; + } + return new RlsSelection(this.sel.filter(where), this.table, this.command, this.readsColumns); + } + private enforced(t: _Transaction): boolean { return this.table.rls.enabled && !bypassesRls(t); } @@ -94,6 +204,7 @@ class RlsSelection extends FilterBase { yield* this.sel.enumerate(t); return; } + assertNoPolicyRecursion(this.table, this.command, t, this.readsColumns); const roleName = currentRole(t).name; for (const raw of this.sel.enumerate(t)) { if (rowPasses(this.compiled, 'using', roleName, this.command, raw, t)) { @@ -112,11 +223,24 @@ class RlsSelection extends FilterBase { } /** Wrap a table's selection with row-level security read enforcement. */ -export function applyReadRls(table: _ITable, selection: _ISelection, command: RlsCommand): _ISelection { +export function applyReadRls(table: _ITable, selection: _ISelection, command: RlsCommand, readsColumns = false): _ISelection { if (!table.rls.policies.length && !table.rls.enabled) { return selection; } - return new RlsSelection(selection, table, command); + return new RlsSelection(selection, table, command, readsColumns); +} + +/** does an UPDATE/DELETE read the table's columns (and so need SELECT rights)? */ +export function statementReadsColumns(ast: { where?: Expr | null; returning?: any; sets?: { value: Expr }[] }): boolean { + if (ast.where || ast.returning?.length) { + return true; + } + let found = false; + const v = astVisitor(() => ({ ref: r => { found = true; return r; } })); + for (const set of ast.sets ?? []) { + v.expr(set.value); + } + return found; } /** Throws if a written row violates the WITH CHECK predicates for a command. */ @@ -124,6 +248,7 @@ export function checkWriteRls(table: _ITable, command: 'insert' | 'update', row: if (!table.rls.enabled || bypassesRls(t)) { return; } + assertNoPolicyRecursion(table, command, t); const compiled = compilePolicies(table.selection, table.rls.policies); if (!rowPasses(compiled, 'withCheck', currentRole(t).name, command, row, t)) { throw new QueryError(`new row violates row-level security policy for table "${table.name}"`, '42501'); diff --git a/src/execution/schema-amends/alter-enum.ts b/src/execution/schema-amends/alter-enum.ts index 5bbe7449..49386e88 100644 --- a/src/execution/schema-amends/alter-enum.ts +++ b/src/execution/schema-amends/alter-enum.ts @@ -17,9 +17,6 @@ export class AlterEnum extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index sequence creation does support further rollbacks) - t = t.fullCommit(); const enumValues = this.originalEnum.values switch (this.p.change.type) { @@ -32,9 +29,6 @@ export class AlterEnum extends ExecHelper implements _IStatementExecutor { break; } - // new implicit transaction - t = t.fork(); - return this.noData(t, 'ALTER'); } } diff --git a/src/execution/schema-amends/alter-index.ts b/src/execution/schema-amends/alter-index.ts index 64233308..fbdceb57 100644 --- a/src/execution/schema-amends/alter-index.ts +++ b/src/execution/schema-amends/alter-index.ts @@ -13,7 +13,6 @@ export class AlterIndex extends ExecHelper implements _IStatementExecutor { execute(t: _Transaction) { // commit pending data before making schema changes (no rollback support) - t = t.fullCommit(); const idx = asIndex(this.schema.getObject(this.p.index, { nullIfNotFound: this.p.ifExists, @@ -34,8 +33,6 @@ export class AlterIndex extends ExecHelper implements _IStatementExecutor { } else { ignore(this.p); } - - t = t.fork(); return this.noData(t, 'ALTER INDEX'); } } diff --git a/src/execution/schema-amends/alter-sequence.ts b/src/execution/schema-amends/alter-sequence.ts index a995af8d..8210ee18 100644 --- a/src/execution/schema-amends/alter-sequence.ts +++ b/src/execution/schema-amends/alter-sequence.ts @@ -19,16 +19,10 @@ export class AlterSequence extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index sequence creation does support further rollbacks) - t = t.fullCommit(); // alter the sequence this.seq?.alter(t, this.p.change); - // new implicit transaction - t = t.fork(); - return this.noData(t, 'ALTER'); } } diff --git a/src/execution/schema-amends/alter.ts b/src/execution/schema-amends/alter.ts index 2783833f..0a078401 100644 --- a/src/execution/schema-amends/alter.ts +++ b/src/execution/schema-amends/alter.ts @@ -18,10 +18,6 @@ export class Alter extends ExecHelper implements _IStatementExecutor { let ignored = 0; - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); - for (const change of this.p.changes) { function ignoreChange() { @@ -115,10 +111,6 @@ export class Alter extends ExecHelper implements _IStatementExecutor { } } - - - // new implicit transaction - t = t.fork(); return this.noData(t, 'ALTER'); } } diff --git a/src/execution/schema-amends/comment.ts b/src/execution/schema-amends/comment.ts index 8d799290..6d039798 100644 --- a/src/execution/schema-amends/comment.ts +++ b/src/execution/schema-amends/comment.ts @@ -13,9 +13,6 @@ export class Comment extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); const on = this.p.on; switch (on.type) { diff --git a/src/execution/schema-amends/create-composite-type.ts b/src/execution/schema-amends/create-composite-type.ts index 0e8ac20e..4ea46d3f 100644 --- a/src/execution/schema-amends/create-composite-type.ts +++ b/src/execution/schema-amends/create-composite-type.ts @@ -20,9 +20,7 @@ export class CreateCompositeType extends ExecHelper implements _IStatementExecut } execute(t: _Transaction): StatementResult { - t = t.fullCommit(); new CompositeType(this.onSchema, this.name, this.columns).install(); - t = t.fork(); return this.noData(t, 'CREATE TYPE'); } } diff --git a/src/execution/schema-amends/create-domain.ts b/src/execution/schema-amends/create-domain.ts index 87389e49..923d9844 100644 --- a/src/execution/schema-amends/create-domain.ts +++ b/src/execution/schema-amends/create-domain.ts @@ -60,9 +60,7 @@ export class CreateDomain extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction): StatementResult { - t = t.fullCommit(); new DomainType(this.onSchema, this.name, this.base, this.notNull, this.checks).install(); - t = t.fork(); return this.noData(t, 'CREATE DOMAIN'); } } diff --git a/src/execution/schema-amends/create-enum.ts b/src/execution/schema-amends/create-enum.ts index 7ba24169..6ebc3be4 100644 --- a/src/execution/schema-amends/create-enum.ts +++ b/src/execution/schema-amends/create-enum.ts @@ -15,16 +15,10 @@ export class CreateEnum extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); // register enum this.onSchema .registerEnum(this.name, this.values); - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } diff --git a/src/execution/schema-amends/create-function.ts b/src/execution/schema-amends/create-function.ts index 0fe08168..b66a4a3c 100644 --- a/src/execution/schema-amends/create-function.ts +++ b/src/execution/schema-amends/create-function.ts @@ -128,8 +128,10 @@ export class CreateFunction extends ExecHelper implements _IStatementExecutor { throw new QueryError(`function ${this.toRegister.name} lready exists with same argument types`, '42723'); } - // ... it must be the same type - if (existing.returns !== returns) { + // ... it must be the same type ("returns trigger" builds a fresh placeholder record + // type each time, so two trigger functions compare by that, not by identity) + const bothTriggers = !!getTriggerRunner(existing.implementation) && isTrigger; + if (existing.returns !== returns && !bothTriggers && !sameReturnType(existing.returns, returns)) { throw new QueryError(`cannot change return type of existing function`, '42P13'); } @@ -144,16 +146,19 @@ export class CreateFunction extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); // the creating role owns the function (what SECURITY DEFINER runs as) this.owner = currentRoleName(t); this.onSchema.registerFunction(this.toRegister, this.replace); - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } + +/** same declared return type, compared structurally (types are not always interned) */ +function sameReturnType(a: _IType | nil, b: IType | nil): boolean { + if (!a || !b) { + return a === b; + } + const x = a as any, y = b as any; + return x.primary === y.primary && x.name === y.name && (x.of?.name ?? null) === (y.of?.name ?? null); +} diff --git a/src/execution/schema-amends/create-index.ts b/src/execution/schema-amends/create-index.ts index fccff2b0..32eeade0 100644 --- a/src/execution/schema-amends/create-index.ts +++ b/src/execution/schema-amends/create-index.ts @@ -53,16 +53,10 @@ export class CreateIndexExec extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index creation does not support further rollbacks) - t = t.fullCommit(); // create index this.onTable .createIndex(t, this.indexDef); - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } diff --git a/src/execution/schema-amends/create-materialized-view.ts b/src/execution/schema-amends/create-materialized-view.ts index 34716b2f..e78a9539 100644 --- a/src/execution/schema-amends/create-materialized-view.ts +++ b/src/execution/schema-amends/create-materialized-view.ts @@ -33,15 +33,8 @@ export class CreateMaterializedView extends ExecHelper implements _IStatementExe return this.noData(t, 'CREATE'); } - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); - // view creation this.toRegister.register(); - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } \ No newline at end of file diff --git a/src/execution/schema-amends/create-schema.ts b/src/execution/schema-amends/create-schema.ts index 915cdc2f..6c6c4a5e 100644 --- a/src/execution/schema-amends/create-schema.ts +++ b/src/execution/schema-amends/create-schema.ts @@ -20,17 +20,11 @@ export class CreateSchema extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); // create schema if (this.toCreate) { this.st.schema.db.createSchema(this.toCreate); } - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } diff --git a/src/execution/schema-amends/create-sequence.ts b/src/execution/schema-amends/create-sequence.ts index 017cc9b0..eb8f699c 100644 --- a/src/execution/schema-amends/create-sequence.ts +++ b/src/execution/schema-amends/create-sequence.ts @@ -12,15 +12,9 @@ export class ExecuteCreateSequence extends ExecHelper implements _IStatementExec } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index sequence creation does support further rollbacks) - t = t.fullCommit(); // create the sequence this.createSeq(t); - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } diff --git a/src/execution/schema-amends/create-table.ts b/src/execution/schema-amends/create-table.ts index 142b7cfd..ae9e9235 100644 --- a/src/execution/schema-amends/create-table.ts +++ b/src/execution/schema-amends/create-table.ts @@ -59,10 +59,6 @@ export class ExecuteCreateTable extends ExecHelper implements _IStatementExecuto execute(t: _Transaction) { - // commit pending data before making changes - // (because the creation does not support further rollbacks) - t = t.fullCommit(); - const partitionOf = this.p.partitionOf; const partitionBy = this.p.partitionBy; @@ -75,10 +71,10 @@ export class ExecuteCreateTable extends ExecHelper implements _IStatementExecuto name: c.id!, type: c.type, })); - const child = this.schema.declareTable({ name: this.toDeclare.name, fields }); + const child = this.schema.declareTable({ name: this.toDeclare.name, fields }, false, t); setupChildPartition(parent, child, partitionOf.bound); } else { - const table = this.schema.declareTable(this.toDeclare); + const table = this.schema.declareTable(this.toDeclare, false, t); if (partitionBy) { setupPartitionedParent(table, partitionBy); } @@ -89,9 +85,6 @@ export class ExecuteCreateTable extends ExecHelper implements _IStatementExecuto // definitions and their constraints are intentionally unused, not unsupported ignore(this.p); } - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } \ No newline at end of file diff --git a/src/execution/schema-amends/create-trigger.ts b/src/execution/schema-amends/create-trigger.ts index 51438630..57df2291 100644 --- a/src/execution/schema-amends/create-trigger.ts +++ b/src/execution/schema-amends/create-trigger.ts @@ -59,7 +59,10 @@ export class CreateTrigger extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction): StatementResult { - t = t.fullCommit(); + if (this.p.orReplace) { + // CREATE OR REPLACE TRIGGER (postgres 14+) + this.target.dropTrigger(this.p.name.name, true); + } this.target.createTrigger({ name: this.p.name.name, timing: this.p.timing, @@ -71,7 +74,6 @@ export class CreateTrigger extends ExecHelper implements _IStatementExecutor { updateColumns: this.updateColumns, arguments: this.arguments, }); - t = t.fork(); return this.noData(t, 'CREATE TRIGGER'); } } diff --git a/src/execution/schema-amends/create-view.ts b/src/execution/schema-amends/create-view.ts index 39860e53..027e7992 100644 --- a/src/execution/schema-amends/create-view.ts +++ b/src/execution/schema-amends/create-view.ts @@ -46,9 +46,6 @@ export class CreateView extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because does not support further rollbacks) - t = t.fullCommit(); // drop if needed if (this.existing && this.drop) { @@ -57,9 +54,6 @@ export class CreateView extends ExecHelper implements _IStatementExecutor { // view creation this.toRegister.register(); - - // new implicit transaction - t = t.fork(); return this.noData(t, 'CREATE'); } } diff --git a/src/execution/schema-amends/drop-index.ts b/src/execution/schema-amends/drop-index.ts index 2f44fb81..2eaa5a1f 100644 --- a/src/execution/schema-amends/drop-index.ts +++ b/src/execution/schema-amends/drop-index.ts @@ -22,18 +22,12 @@ export class DropIndex extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index sequence creation does support further rollbacks) - t = t.fullCommit(); // alter the sequence for (const idx of this.idx) { idx.onTable.dropIndex(t, idx.name); } - // new implicit transaction - t = t.fork(); - return this.noData(t, 'DROP'); } } diff --git a/src/execution/schema-amends/drop-sequence.ts b/src/execution/schema-amends/drop-sequence.ts index e7f513fc..7ae53506 100644 --- a/src/execution/schema-amends/drop-sequence.ts +++ b/src/execution/schema-amends/drop-sequence.ts @@ -18,18 +18,12 @@ export class DropSequence extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index sequence creation does support further rollbacks) - t = t.fullCommit(); // drop the sequence for (const seq of this.seqs) { seq.drop(t); } - // new implicit transaction - t = t.fork(); - return this.noData(t, 'DROP'); } } diff --git a/src/execution/schema-amends/drop-table.ts b/src/execution/schema-amends/drop-table.ts index e4587005..3d9a8e89 100644 --- a/src/execution/schema-amends/drop-table.ts +++ b/src/execution/schema-amends/drop-table.ts @@ -23,18 +23,12 @@ export class DropTable extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because it does not support further rollbacks) - t = t.fullCommit(); // drop table for (const table of this.tables) { table.drop(t, this.cascade); } - // new implicit transaction - t = t.fork(); - return this.noData(t, 'DROP'); } } diff --git a/src/execution/schema-amends/drop-type.ts b/src/execution/schema-amends/drop-type.ts index c23ecba9..47104308 100644 --- a/src/execution/schema-amends/drop-type.ts +++ b/src/execution/schema-amends/drop-type.ts @@ -18,18 +18,12 @@ export class DropType extends ExecHelper implements _IStatementExecutor { } execute(t: _Transaction) { - // commit pending data before making changes - // (because the index sequence creation does support further rollbacks) - t = t.fullCommit(); // drop the sequence for (const seq of this.types) { seq.drop(t); } - // new implicit transaction - t = t.fork(); - return this.noData(t, 'DROP'); } } diff --git a/src/execution/statement-exec.ts b/src/execution/statement-exec.ts index 3dad8968..bf0019e6 100644 --- a/src/execution/statement-exec.ts +++ b/src/execution/statement-exec.ts @@ -35,6 +35,9 @@ import { CreateCompositeType } from './schema-amends/create-composite-type'; import { InsteadOfView } from './records-mutations/instead-of'; import { MergeExec } from './records-mutations/merge'; import { hasInsteadOf, TriggerOp } from './triggers'; +import { isSchemaStatement } from '../persistence'; +import { captureSchema } from '../schema-snapshot'; +import { withRiQueue } from './ri-queue'; import { _IView, _ITable } from '../interfaces-private'; const detailsIncluded = Symbol('errorDetailsIncluded'); @@ -355,7 +358,11 @@ export class StatementExec implements _IStatement { if (!this.executor) { throw new Error('Statement not prepared') } - const result = this.executor.execute(t); + if (isSchemaStatement(this.statement) || this.statement.type === 'do') { + // DDL is transactional: remember the schema so a rollback can put it back + t.checkpointSchema(() => captureSchema(this.schema.db)); + } + const result = withRiQueue(() => this.executor!.execute(t)); // post-execution for (const s of this.onExecutedCallbacks) { diff --git a/src/execution/transaction-statements.ts b/src/execution/transaction-statements.ts index a004a286..2d6c17e1 100644 --- a/src/execution/transaction-statements.ts +++ b/src/execution/transaction-statements.ts @@ -1,7 +1,8 @@ import { _IStatementExecutor, _Transaction, StatementResult } from '../interfaces-private'; import { ExecHelper } from './exec-utils'; import { CommitStatement, RollbackStatement, StartTransactionStatement, BeginStatement, SavepointStatement, ReleaseSavepointStatement } from 'pgsql-ast-parser'; -import { ignore } from '../utils'; +import { ignore, executionCtx } from '../utils'; +import { captureSchema } from '../schema-snapshot'; export class CommitExecutor extends ExecHelper implements _IStatementExecutor { @@ -43,7 +44,8 @@ export class SavepointExecutor extends ExecHelper implements _IStatementExecutor } execute(t: _Transaction): StatementResult { - t.savepoint(this.stmt.name.name); + const db = executionCtx().schema.db; + t.savepoint(this.stmt.name.name, () => captureSchema(db)); return this.noData(t, 'SAVEPOINT'); } } diff --git a/src/interfaces-private.ts b/src/interfaces-private.ts index ed351b7a..17fb1cea 100644 --- a/src/interfaces-private.ts +++ b/src/interfaces-private.ts @@ -55,7 +55,7 @@ export interface _ISchema extends ISchema { getTable(table: string, nullIfNotFound?: boolean): _ITable; tablesCount(t: _Transaction): number; listTables(t?: _Transaction): Iterable<_ITable>; - declareTable(table: Schema, noSchemaChange?: boolean): _ITable; + declareTable(table: Schema, noSchemaChange?: boolean, inTransaction?: _Transaction): _ITable; createSequence(t: _Transaction, opts: CreateSequenceOptions | nil, name: QName | nil): _ISequence; /** Get functions matching this overload */ resolveFunction(name: string | QName, args: IValue[], forceOwn?: boolean): _FunctionDefinition | nil; @@ -168,8 +168,14 @@ export interface _Transaction { /** Commits this transaction and all underlying transactions */ fullCommit(): _Transaction; rollback(): _Transaction; - /** Capture the current state under a named savepoint */ - savepoint(name: string): void; + /** Capture the current state under a named savepoint (and the schema, when given a capturer) */ + savepoint(name: string, captureSchema?: () => () => void): void; + /** Record the schema before this transaction's first DDL, so a rollback can restore it */ + checkpointSchema(capture: () => () => void): void; + /** Roll back the schema of this transaction and every enclosing one (a failed call) */ + discardAll(): void; + /** The BEGIN block this transaction belongs to, if any */ + readonly explicitBlock: _Transaction | null; /** Restore the state captured by a named savepoint (keeping the savepoint) */ rollbackTo(name: string): void; /** Discard a named savepoint (and any established after it) */ diff --git a/src/schema-snapshot.ts b/src/schema-snapshot.ts new file mode 100644 index 00000000..177acb2b --- /dev/null +++ b/src/schema-snapshot.ts @@ -0,0 +1,163 @@ +import { _IDb } from './interfaces-private'; + +const SYSTEM_SCHEMAS = new Set(['pg_catalog', 'information_schema']); + +/** + * Transactional DDL. + * + * Row data lives in immutable maps inside the transaction, so rolling it back is just dropping the + * transaction. Schema objects (tables, columns, constraints, indexes, policies, triggers, functions, + * types) are mutable objects outside it. To make DDL roll back like it does in postgres - a failed + * migration leaves no half-created tables - the schema metadata is captured before the first DDL of + * a transaction and put back if that transaction rolls back. + * + * The capture is a copy of each schema object's own properties, with containers (Map, Set, Array, + * plain object) copied a few levels deep and class instances kept by reference - those that are + * themselves mutable schema objects are captured in their own right. Objects created after the + * capture simply drop out of the restored registries. + */ +export function captureSchema(db: _IDb): () => void { + const restorers: (() => void)[] = []; + const seen = new Set(); + const capture = (o: any, skip: string[] = []) => { + if (!o || typeof o !== 'object' || seen.has(o)) { + return; + } + seen.add(o); + restorers.push(captureProps(o, skip)); + }; + + // never part of the schema: row data and the session's open transaction + capture(db, ['data', 'sessionTx', 'schemaVersion', 'handlers']); + for (const schema of db.listSchemas()) { + const s = schema as any; + // the built-in catalogues (and their hundreds of functions) are not changed by user DDL; + // skipping them keeps the capture cheap enough to take on every DDL transaction + if (SYSTEM_SCHEMAS.has(s.name)) { + continue; + } + capture(s, ['fns', 'ops', 'interceptors', 'db']); + for (const resolver of [s.fns, s.ops]) { + if (resolver?.snapshot) { + restorers.push(resolver.snapshot()); + } + } + for (const rel of s.relsByNameCas?.values?.() ?? []) { + capture(rel, ['ownerSchema', 'db']); + const r = rel as any; + if (r.type !== 'table') { + continue; + } + capture(r.columnMgr); + capture(r.columnMgr?.map); + for (const col of r.columnMgr?.map?.values?.() ?? []) { + capture(col, ['table']); + } + capture(r.rls); + capture(r.triggers); + for (const c of r.listConstraints?.() ?? []) { + capture(c); + capture(c.wrapped); + } + } + } + + return () => { + for (const r of restorers) { + r(); + } + for (const schema of db.listSchemas()) { + for (const rel of (schema as any).relsByNameCas?.values?.() ?? []) { + rel.columnMgr?.invalidateColumns?.(); + rel.selection?.rebuild?.(); + } + } + db.onSchemaChange(); + }; +} + +/** a captured value: containers keep their identity and remember their contents */ +type Snap = { ref: any; kind: 'map' | 'set' | 'array' | 'object'; items: [any, Snap][] } | { value: any }; + +function snap(v: any, depth: number): Snap { + // a frozen value cannot have changed: keep it by reference + if (depth < 0 || !v || typeof v !== 'object' || Object.isFrozen(v)) { + return { value: v }; + } + if (v instanceof Map) { + return { ref: v, kind: 'map', items: [...v].map(([k, x]) => [k, snap(x, depth - 1)]) }; + } + if (v instanceof Set) { + return { ref: v, kind: 'set', items: [...v].map(x => [null, snap(x, depth - 1)]) }; + } + if (Array.isArray(v)) { + return { ref: v, kind: 'array', items: v.map(x => [null, snap(x, depth - 1)]) }; + } + if (Object.getPrototypeOf(v) === Object.prototype) { + return { ref: v, kind: 'object', items: Object.keys(v).map(k => [k, snap(v[k], depth - 1)]) }; + } + return { value: v }; +} + +/** + * Put a captured value back, refilling the original container instances in place - other code + * holds references to them (e.g. ColumnManager binds get/has to its Map), so swapping in copies + * would orphan those. + */ +function unsnap(s: Snap): any { + if ('value' in s) { + return s.value; + } + const items = s.items.map(([k, x]) => [k, unsnap(x)] as [any, any]); + switch (s.kind) { + case 'map': + s.ref.clear(); + for (const [k, v] of items) { s.ref.set(k, v); } + break; + case 'set': + s.ref.clear(); + for (const [, v] of items) { s.ref.add(v); } + break; + case 'array': + s.ref.length = 0; + s.ref.push(...items.map(([, v]) => v)); + break; + case 'object': { + const keep = new Set(items.map(([k]) => k)); + for (const k of Object.keys(s.ref)) { + if (!keep.has(k) && Object.getOwnPropertyDescriptor(s.ref, k)?.configurable) { + delete s.ref[k]; + } + } + for (const [k, v] of items) { + if (s.ref[k] !== v && Object.getOwnPropertyDescriptor(s.ref, k)?.writable !== false) { + s.ref[k] = v; + } + } + break; + } + } + return s.ref; +} + +function captureProps(o: any, skip: string[]): () => void { + if (o instanceof Map || o instanceof Set) { + const whole = snap(o, 3); + return () => { unsnap(whole); }; + } + const keys = Object.keys(o).filter(k => !skip.includes(k)); + const saved = new Map(keys.map(k => [k, snap(o[k], 3)])); + return () => { + for (const k of Object.keys(o)) { + if (!skip.includes(k) && !saved.has(k) && Object.getOwnPropertyDescriptor(o, k)?.configurable) { + delete o[k]; + } + } + for (const [k, v] of saved) { + const val = unsnap(v); + if (o[k] !== val && Object.getOwnPropertyDescriptor(o, k)?.writable !== false) { + o[k] = val; + } + } + }; +} diff --git a/src/schema/information-schema/columns-list.ts b/src/schema/information-schema/columns-list.ts index 08fb768b..5e275efb 100644 --- a/src/schema/information-schema/columns-list.ts +++ b/src/schema/information-schema/columns-list.ts @@ -91,6 +91,10 @@ function defaultExpressionOf(table: _ITable, columnName: string | nil): string | if (!ast) { return null; } + const pg = pgExprText(ast); + if (pg !== null) { + return pg; + } try { // toSql renders defensively — `now()` comes out as `(now () )`. Postgres reports `now()`, and // consumers compare these strings, so collapse the padding and drop one layer of wrapping @@ -110,6 +114,71 @@ function defaultExpressionOf(table: _ITable, columnName: string | nil): string | } } +const pgQuote = (s: string) => `'${s.replace(/'/g, "''")}'`; +const KEYWORD_TEXT: Record = { + current_date: 'CURRENT_DATE', current_timestamp: 'CURRENT_TIMESTAMP', localtimestamp: 'LOCALTIMESTAMP', + current_time: 'CURRENT_TIME', localtime: 'LOCALTIME', current_user: 'CURRENT_USER', current_role: 'CURRENT_ROLE', + session_user: 'SESSION_USER', user: 'CURRENT_USER', +}; + +/** + * A default expression as postgres' pg_get_expr prints it: literals as written (0.30, not 0.3), + * negative numbers quoted ('-1'::integer), a binary expression wrapped once - (now() + '2 days'::interval) + * - with bare operands, ARRAY[...], CURRENT_DATE. Null for shapes it does not know, which then go + * through the generic toSql rendering. + */ +function pgExprText(e: any): string | null { + const operand = (x: any) => pgExprText(x); + switch (e?.type) { + case 'string': + return pgQuote(e.value); + case 'integer': + // negative, or too big for int4 (then typed bigint): postgres prints it quoted + return e.value < 0 || e.value > 2147483647 ? pgQuote(String(e.valueText ?? e.value)) : String(e.valueText ?? e.value); + case 'numeric': { + const txt = e.raw ?? e.valueText ?? String(e.value); + return txt.startsWith('-') ? pgQuote(txt) : txt; + } + case 'boolean': + return e.value ? 'true' : 'false'; + case 'null': + return 'NULL'; + case 'keyword': + return KEYWORD_TEXT[e.keyword] ?? null; + case 'call': { + const args = (e.args ?? []).map(operand); + if (args.some((a: string | null) => a === null) || e.distinct || e.orderBy || e.filter || e.over) { + return null; + } + const fn = (e.function.schema ? e.function.schema + '.' : '') + e.function.name; + return `${fn}(${args.join(', ')})`; + } + case 'cast': { + const inner = operand(e.operand); + return inner === null ? null : `${inner}::${toSql.dataType(e.to as any)}`; + } + case 'binary': { + const l = operand(e.left), r = operand(e.right); + return l === null || r === null ? null : `(${l} ${e.op} ${r})`; + } + case 'unary': { + const v = operand(e.operand); + if (v === null) { + return null; + } + if (e.op === '-' && /^[\d.]+$/.test(v)) { + return pgQuote('-' + v); + } + return e.op === 'NOT' ? `(NOT ${v})` : `(${e.op}${v})`; + } + case 'array': { + const items = (e.expressions ?? []).map(operand); + return items.some((a: string | null) => a === null) ? null : `ARRAY[${items.join(', ')}]`; + } + } + return null; +} + /** Remove one paren pair only when it wraps the entire expression. */ function stripWrappingParens(sql: string): string { if (!sql.startsWith('(') || !sql.endsWith(')')) { @@ -256,7 +325,7 @@ export class ColumnsListSchema extends ReadOnlyTable implements _ITable { } getIndex(forValue: IValue): _IIndex | nil { - if (forValue.id === 'table_name') { + if (forValue?.id === 'table_name') { return new TableIndex(this, forValue); } return null; diff --git a/src/schema/overload-resolver.ts b/src/schema/overload-resolver.ts index 243b5218..fa16ac07 100644 --- a/src/schema/overload-resolver.ts +++ b/src/schema/overload-resolver.ts @@ -31,6 +31,17 @@ export class OverloadResolver { return [...ovr.all()]; } + /** every registered overload, to put the resolver back as it is now (transactional DDL) */ + snapshot(): () => void { + const all = [...this.byName.keys()].flatMap(n => this.getOverloads(n)); + return () => { + this.byName = new Map(); + for (const v of all) { + this.add(v, true); + } + }; + } + remove(value: T) { this.byName.get(value.name)?.unindex(value); } diff --git a/src/schema/pg-catalog/pg-constraints-list.ts b/src/schema/pg-catalog/pg-constraints-list.ts index aa00ec78..94c5c6cc 100644 --- a/src/schema/pg-catalog/pg-constraints-list.ts +++ b/src/schema/pg-catalog/pg-constraints-list.ts @@ -81,7 +81,7 @@ export class PgConstraintTable extends ReadOnlyTable implements _ITable { } getIndex(forValue: IValue): _IIndex | nil { - if (forValue.id === 'table_name') { + if (forValue?.id === 'table_name') { return new TableIndex(this, forValue); } return null; diff --git a/src/schema/prepared.ts b/src/schema/prepared.ts index bd05655f..3ab942a3 100644 --- a/src/schema/prepared.ts +++ b/src/schema/prepared.ts @@ -58,10 +58,11 @@ function startCall(db: _IDb, statements: Statement[]): _Transaction { if (open.aborted) { const first = statements[0]?.type; if (first === 'rollback' || first === 'commit') { - // the block is over either way; run the rest of the call on a fresh implicit tx - // (a COMMIT on an aborted block is a ROLLBACK in Postgres) + // the block is over either way (a COMMIT on an aborted block is a ROLLBACK in + // postgres): undo it, schema included, and carry on in whatever enclosed it statements.splice(0, 1); - return db.data.fork(); + const after = open.explicitBlock?.rollback() ?? db.data; + return after.isChild ? after : db.data.fork(); } db.sessionTx = open; throw new QueryError('current transaction is aborted, commands ignored until end of transaction block', '25P02'); @@ -86,7 +87,11 @@ function failCall(db: _IDb, state: _Transaction) { if (state.inExplicitBlock) { state.aborted = true; db.sessionTx = state; + return; } + // the call's implicit transaction is dropped, so its data is gone: put the schema back too + // (a failed migration must not leave half its tables behind) + state.discardAll(); } let _paramList: Parameter[] | null = null; diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 7f5ae16b..8e4e4fd2 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -393,10 +393,14 @@ export class DbSchema implements _ISchema, ISchema { - declareTable(table: Schema, noSchemaChange?: boolean): MemoryTable { - const trans = this.db.data.fork(); + declareTable(table: Schema, noSchemaChange?: boolean, inTransaction?: _Transaction): MemoryTable { + // CREATE TABLE builds the table inside the statement's transaction (DDL is transactional); + // the JS API (db.public.declareTable) has none, so it commits on its own + const trans = inTransaction ?? this.db.data.fork(); const ret = new MemoryTable(this, trans, table).register(); - trans.commit(); + if (!inTransaction) { + trans.commit(); + } if (!noSchemaChange) { this.db.onSchemaChange(); } diff --git a/src/tests/corpus-parity.spec.ts b/src/tests/corpus-parity.spec.ts index 9ee7e080..f87c39b9 100644 --- a/src/tests/corpus-parity.spec.ts +++ b/src/tests/corpus-parity.spec.ts @@ -303,5 +303,176 @@ describe('corpus parity', () => { none(`create role service_role; grant all on p to service_role, public`); }); }); + + describe('DDL is transactional', () => { + // expectations checked against PGlite (real Postgres) + const n = (q: string) => many(q)[0].n; + + it('a failed multi-statement call leaves nothing behind', () => { + expectQueryError(() => none(`create table a(x int); insert into a values (1); select nope_fn()`)); + expect(n(`select count(*)::int as n from pg_tables where tablename = 'a'`)).toBe(0); + }); + it('ROLLBACK undoes CREATE TABLE, and restores a dropped table with its rows', () => { + none(`begin`); none(`create table b(x int)`); none(`rollback`); + expect(n(`select count(*)::int as n from pg_tables where tablename = 'b'`)).toBe(0); + none(`create table c(x int primary key); insert into c values (1), (2)`); + none(`begin`); none(`drop table c`); none(`rollback`); + expect(n(`select count(*)::int as n from c`)).toBe(2); + }); + it('restores a dropped foreign key, still enforced', () => { + none(`create table p(id int primary key); create table ch(pid int constraint ch_fk references p(id))`); + none(`begin`); none(`alter table ch drop constraint ch_fk`); none(`rollback`); + expectQueryError(() => none(`insert into ch values (99)`), /ch_fk/); + }); + it('undoes added columns, policies, RLS, functions, triggers and type changes', () => { + none(`create table d(x int, y int)`); + none(`create function d_trg() returns trigger language plpgsql as $$ begin new.y = 42; return new; end $$`); + none(`begin`); + none(`alter table d add column z text`); + none(`alter table d enable row level security`); + none(`create policy pd on d using (true)`); + none(`create function f1() returns int language sql as $$ select 1 $$`); + none(`create trigger tg before insert on d for each row execute function d_trg()`); + none(`alter table d alter column x type text`); + none(`rollback`); + expect(n(`select count(*)::int as n from information_schema.columns where table_name = 'd'`)).toBe(2); + expect(n(`select count(*)::int as n from pg_policies where tablename = 'd'`)).toBe(0); + expect(many(`select rowsecurity from pg_tables where tablename = 'd'`)).toEqual([{ rowsecurity: false }]); + expectQueryError(() => many(`select f1()`), /f1/); + none(`insert into d (x) values (1)`); + expect(many(`select x, y from d`)).toEqual([{ x: 1, y: null }]); + }); + it('ROLLBACK TO SAVEPOINT undoes only the DDL after it', () => { + none(`begin`); none(`create table h(x int)`); none(`savepoint s`); + none(`alter table h add column y int`); none(`create index h_x on h(x)`); + none(`rollback to savepoint s`); none(`commit`); + expect(n(`select count(*)::int as n from information_schema.columns where table_name = 'h'`)).toBe(1); + expect(n(`select count(*)::int as n from pg_indexes where tablename = 'h'`)).toBe(0); + none(`create index h_x on h(x)`); + }); + it('keeps DDL from a committed block', () => { + none(`begin`); none(`create table i(x int)`); none(`insert into i values (5)`); none(`commit`); + expect(many(`select x from i`)).toEqual([{ x: 5 }]); + }); + }); + + describe('referential actions run in postgres order (breadth-first)', () => { + // outcomes checked against PGlite + const count = (q: string) => Number(many(q)[0].n); + + it('a direct NO ACTION reference is checked before a grandchild cascade clears it', () => { + none(`create table u (id int primary key); create table pr (id int primary key references u(id) on delete cascade); + create table tx (id int primary key, acct int not null references pr(id) on delete cascade, by int references u(id)); + insert into u values (1); insert into pr values (1); insert into tx values (1, 1, 1)`); + expectQueryError(() => none(`delete from u where id = 1`), /tx_by_fkey/); + expect(count(`select (select count(*) from pr) + (select count(*) from tx) * 10 as n`)).toBe(11); + }); + it('a direct CASCADE queued before a NO ACTION check satisfies it', () => { + none(`create table u (id int primary key); create table a (uid int references u(id) on delete cascade, by int references u(id)); + insert into u values (1); insert into a values (1, 1)`); + none(`delete from u where id = 1`); + expect(count(`select count(*) as n from a`)).toBe(0); + }); + it('a NO ACTION declared before the CASCADE fires first and fails', () => { + none(`create table u (id int primary key); create table a (by int references u(id), uid int references u(id) on delete cascade); + insert into u values (1); insert into a values (1, 1)`); + expectQueryError(() => none(`delete from u where id = 1`), /a_by_fkey/); + }); + it('cascades through chains and self references', () => { + none(`create table t (id int primary key, parent int references t(id) on delete cascade); + insert into t values (1, null), (2, 1), (3, 2), (4, null)`); + none(`delete from t where id = 1`); + expect(count(`select count(*) as n from t`)).toBe(1); + }); + }); + + describe('column_default reads like pg_get_expr', () => { + it('prints literals as written and expressions without extra parens', () => { + none(`create table o (a numeric default 0.30, b double precision default 0.0, d text[] default array['x','y'], + e timestamptz default now() + interval '2 days', g int default -1, r bigint default 3000000000, i date default current_date)`); + expect(many(`select column_name, column_default from information_schema.columns where table_name = 'o' order by ordinal_position`) + .map(r => r.column_default)) + .toEqual(['0.30', '0.0', `ARRAY['x', 'y']`, `(now() + '2 days'::interval)`, `'-1'`, `'3000000000'`, 'CURRENT_DATE']); + }); + }); + + describe('policy expansion', () => { + beforeEach(() => none(`create role authenticated; + create table profiles (id int primary key, role text); alter table profiles enable row level security; + create policy p_sel on profiles for select using (exists (select 1 from profiles p where p.id = 1 and p.role = 'admin')); + create table cats (id int); alter table cats enable row level security; + create policy c_sel on cats for select using (true); + create policy c_upd on cats for update using (exists (select 1 from profiles p where p.id = 1)); + insert into cats values (1)`)); + + it('fails on a policy that (indirectly) queries its own table, for roles RLS applies to', () => { + none(`set role authenticated`); + expectQueryError(() => many(`select * from profiles`), /infinite recursion detected in policy for relation "profiles"/); + expectQueryError(() => none(`update cats set id = id`), /infinite recursion detected in policy for relation "profiles"/); + expect(many(`select count(*)::int as n from cats`)).toEqual([{ n: 1 }]); + none(`reset role`); + expect(many(`select count(*)::int as n from profiles`)).toEqual([{ n: 0 }]); + }); + }); + + describe('CREATE OR REPLACE TRIGGER', () => { + it('replaces an existing trigger', () => { + none(`create table o (id int, n int); + create function f() returns trigger language plpgsql as $$ begin new.n = 1; return new; end $$; + create function g() returns trigger language plpgsql as $$ begin new.n = 2; return new; end $$; + create or replace trigger t before insert on o for each row execute function f(); + create or replace trigger t before insert on o for each row execute function g(); + insert into o (id) values (1)`); + expect(many(`select n from o`)).toEqual([{ n: 2 }]); + }); + }); + + describe('RLS on index lookups', () => { + it('applies policies to WHERE = … reads and writes', () => { + none(`create role authenticated; create table pr (id int primary key, n text); alter table pr enable row level security; + create policy s on pr for select using (id = 1); create policy u on pr for update using (id = 1); create policy d on pr for delete using (id = 1); + insert into pr values (1, 'a'), (2, 'b')`); + none(`set role authenticated`); + expect(many(`select * from pr where id = 2`)).toEqual([]); + expect(many(`select n from pr where id = 1`)).toEqual([{ n: 'a' }]); + expect(db.public.query(`update pr set n = 'z' where id = 2`).rowCount).toBe(0); + expect(db.public.query(`delete from pr where id in (2)`).rowCount).toBe(0); + none(`reset role`); + expect(many(`select n from pr order by id`)).toEqual([{ n: 'a' }, { n: 'b' }]); + }); + }); + + describe('policy recursion follows postgres rules', () => { + // checked against PGlite + beforeEach(() => none(`create role authenticated; create table pr (id int primary key, n text); alter table pr enable row level security`)); + it('an UPDATE policy reading its own table is fine when that table has no recursive SELECT policy', () => { + none(`create policy s on pr for select using (true); + create policy u on pr for update using (id = 1 or exists (select 1 from pr p where p.id = 2)); set role authenticated`); + none(`update pr set n = n where id = 1`); + }); + it('an UPDATE/DELETE that reads columns expands the recursive SELECT policy; one that does not, does not', () => { + none(`create policy s on pr for select using (id = 1 or exists (select 1 from pr p where p.id = 2)); + create policy u on pr for update using (id = 1); create policy d on pr for delete using (id = 1); set role authenticated`); + expectQueryError(() => none(`update pr set n = 'x' where id = 1`), /infinite recursion/); + expectQueryError(() => none(`delete from pr where id = 1`), /infinite recursion/); + none(`update pr set n = 'x'`); + none(`delete from pr`); + }); + }); + + describe('CREATE OR REPLACE FUNCTION', () => { + it('replaces a trigger function that triggers already use, in a later call', () => { + none(`create table p (id int, updated_at timestamptz); + create or replace function set_updated_at() returns trigger as $$ begin new.updated_at = now(); return new; end $$ language plpgsql; + create trigger p_upd before update on p for each row execute function set_updated_at()`); + none(`create or replace function set_updated_at() returns trigger as $$ begin new.updated_at = '2000-01-01'; return new; end $$ language plpgsql`); + none(`insert into p (id) values (1); update p set id = 2`); + expect(many(`select extract(year from updated_at) as y from p`)).toEqual([{ y: 2000 }]); + }); + it('still refuses to change a return type', () => { + none(`create function f2() returns int language sql as $$ select 1 $$`); + expectQueryError(() => none(`create or replace function f2() returns text language sql as $$ select 'a' $$`), /cannot change return type/); + }); + }); }); diff --git a/src/tests/insert.queries.spec.ts b/src/tests/insert.queries.spec.ts index 3fe76d66..a33844bb 100644 --- a/src/tests/insert.queries.spec.ts +++ b/src/tests/insert.queries.spec.ts @@ -229,8 +229,10 @@ describe('Inserts', () => { }); it('ensures serials are transactional', () => { - expect(many(`create table test(id serial primary key, val text); - insert into test(val) values ('x'); + // the table is created in its own call: in one multi-statement call, ROLLBACK would undo + // the CREATE TABLE too (DDL is transactional, as in postgres) + none(`create table test(id serial primary key, val text)`); + expect(many(`insert into test(val) values ('x'); insert into test(val) values ('x'); insert into test(val) values ('x'); rollback; diff --git a/src/transaction.ts b/src/transaction.ts index c5676e1f..47453fd6 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -19,6 +19,33 @@ export class Transaction implements _Transaction { aborted = false; + /** puts the schema back as it was before this transaction's first DDL (see schema-snapshot.ts) */ + schemaRestore?: () => void; + + /** + * Called before a DDL statement runs in this transaction: this transaction and every enclosing + * one that has not seen DDL yet record the current schema, so whichever of them rolls back + * restores it. They all share one capture - the schema is the same for all of them right now. + */ + checkpointSchema(capture: () => () => void): void { + let restore: (() => void) | undefined; + for (let x: Transaction | null = this; x && x.isChild; x = x.parent) { + if (!x.schemaRestore) { + x.schemaRestore = restore ??= capture(); + } + } + } + + /** the BEGIN block this transaction belongs to, if any */ + get explicitBlock(): Transaction | null { + for (let x: Transaction | null = this; x; x = x.parent) { + if (x.explicit) { + return x; + } + } + return null; + } + get inExplicitBlock(): boolean { return this.explicit || !!this.parent?.inExplicitBlock; } @@ -49,7 +76,26 @@ export class Transaction implements _Transaction { return this; } if (this.parent.data !== this.origData) { - throw new NotSupported('Concurrent transactions'); + // the parent moved on while we were open - e.g. CREATE SCHEMA registers its catalogue + // tables straight into root. Rebase our changes onto it, key by key; only the same + // key changed on both sides is a real conflict. + let merged = this.parent.data; + for (const [k, v] of this.data) { + if (this.origData.get(k) === v) { + continue; + } + if (merged.get(k) !== this.origData.get(k)) { + throw new NotSupported('Concurrent transactions'); + } + merged = merged.set(k, v); + } + for (const k of this.origData.keys()) { + if (!this.data.has(k)) { + merged = merged.delete(k); + } + } + this.parent.data = merged; + return this.parent; } this.parent.data = this.data; return this.parent; @@ -63,20 +109,38 @@ export class Transaction implements _Transaction { } rollback() { + this.schemaRestore?.(); return this.parent ?? this; } - savepoint(name: string): void { + /** the outermost transaction of this call or block: discarding it undoes everything */ + discardAll(): void { + let outer: Transaction | null = null; + for (let x: Transaction | null = this; x && x.isChild; x = x.parent) { + if (x.schemaRestore) { + outer = x; + } + } + outer?.schemaRestore?.(); + } + + savepoint(name: string, captureSchema?: () => () => void): void { // re-declaring a name captures the current state under it (postgres hides the // older savepoint of the same name; we simply overwrite - close enough for v1) this.savepoints.set(name, this.data); + if (captureSchema) { + this.savepointSchemas.set(name, captureSchema()); + } } + private savepointSchemas = new Map void>(); + rollbackTo(name: string): void { const saved = this.savepoints.get(name); if (saved === undefined) { throw new QueryError(`savepoint "${name}" does not exist`); } + this.savepointSchemas.get(name)?.(); this.data = saved; // the savepoint survives (can be rolled back to again), but any savepoints // established after it are discarded diff --git a/src/utils.ts b/src/utils.ts index 43316a57..f26b2cf3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -82,6 +82,12 @@ export function watchUse(rootValue: T): { checked: T; check?: () => string | enumerable: true, }); } + // non-enumerable metadata (e.g. a numeric literal's text as written) rides along unwatched + for (const k of Object.getOwnPropertyNames(value)) { + if (!Object.prototype.propertyIsEnumerable.call(value, k)) { + Object.defineProperty(ret, k, { value: value[k], enumerable: false }); + } + } return ret; } diff --git a/tools/corpus-diff/run.ts b/tools/corpus-diff/run.ts index f7fc1e11..0580550b 100644 --- a/tools/corpus-diff/run.ts +++ b/tools/corpus-diff/run.ts @@ -250,7 +250,14 @@ function templateInsert(m: TableMeta, override: Record = {}): st return `insert into public.${ident(m.name)} (${cols.map((c) => ident(c.name)).join(', ')}) values (${vals.join(', ')})`; } -function probesFor(m: TableMeta, all: TableMeta[]): Probe[] { +/** + * A key value both engines share: one written in the project's SQL. A generated one + * (gen_random_uuid(), serial) differs between the engines, so probing for it on pg-mem with + * PGlite's value would compare two different rows. + */ +const sharedKey = (v: any, text: string) => v != null && (typeof v === 'number' ? text.includes(String(v)) : text.toLowerCase().includes(String(v).toLowerCase())); + +function probesFor(m: TableMeta, all: TableMeta[], text: string): Probe[] { const t = ident(m.name); const out: Probe[] = []; const P = (p: Omit) => out.push({ table: m.name, ...p }); @@ -266,6 +273,18 @@ function probesFor(m: TableMeta, all: TableMeta[]): Probe[] { P({ kind: 'rls-write', probe: `delete as ${label}`, sql: [`delete from public.${t}`], role }); } } + // the same through an index lookup (WHERE = ): index paths must not skip RLS + if (m.sample && m.pk.length === 1 && sharedKey(m.sample[m.pk[0]], text)) { + const pkCol = m.cols.find(c => c.name === m.pk[0])!; + const where = `where ${ident(pkCol.name)} = ${literal(m.sample[pkCol.name], pkCol.type)}`; + for (const [label, role] of [['anon', ANON], ['demo', DEMO], ['stranger', STRANGER]] as const) { + P({ kind: 'rls-read', probe: `pk lookup as ${label}`, sql: [`select count(*)::int as n from public.${t} ${where}`], role, rows: true }); + if (anyCol) { + P({ kind: 'rls-write', probe: `update by pk as ${label}`, sql: [`update public.${t} set ${ident(anyCol)} = ${ident(anyCol)} ${where}`], role }); + P({ kind: 'rls-write', probe: `delete by pk as ${label}`, sql: [`delete from public.${t} ${where}`], role }); + } + } + } // updated_at trigger: inside one transaction now() is constant, so a row the trigger touched has updated_at = now() if (m.sample && m.cols.some((c) => c.name === 'updated_at') && anyCol && anyCol !== 'updated_at') { P({ kind: 'trigger', probe: 'updated_at fires on update', sql: [`update public.${t} set ${ident(anyCol)} = ${ident(anyCol)} returning (updated_at = now()) as fired`], rows: true }); @@ -290,7 +309,7 @@ function probesFor(m: TableMeta, all: TableMeta[]): Probe[] { if (c.type === 'smallint') bad.push(['int2 overflow', '70000']); if (/^character varying/.test(c.type)) bad.push(['varchar too long (if bounded)', q('x'.repeat(10_001))]); if (m.fks.some((f) => f.cols.includes(c.name))) bad.push(['dangling foreign key', c.type === 'uuid' ? q(crypto.randomUUID()) : q('no-such-parent')]); - if (m.sample && [m.pk, ...m.unique].some((k) => k.length === 1 && k[0] === c.name)) bad.push(['duplicate key', literal(m.sample[c.name], c.type)]); + if (m.sample && [m.pk, ...m.unique].some((k) => k.length === 1 && k[0] === c.name) && sharedKey(m.sample[c.name], text)) bad.push(['duplicate key', literal(m.sample[c.name], c.type)]); for (const ch of m.checks.filter((ch) => ch.cols.includes(c.name))) { if (c.type === 'text' || c.type.startsWith('character')) bad.push([`check violation ${ch.def.slice(0, 60)}`, q('__bogus__')]); else if (/int|numeric|real|double/.test(c.type)) bad.push([`check violation ${ch.def.slice(0, 60)}`, '-999999']); @@ -417,7 +436,7 @@ async function diffProject(p: Project): Promise<{ diffs: Diff[]; stats: Record probesFor(m, metas)), ...globalProbes(metas)]; + const probes = [...metas.flatMap((m) => probesFor(m, metas, p.text)), ...globalProbes(metas)]; const memFp0 = await fingerprint(mem.eng, memTables); for (const pr of probes) { bump(`probe:${pr.kind}`); From 0f24e6a093c250e18d7c47761ff823a2e52a47bf Mon Sep 17 00:00:00 2001 From: Riya Date: Wed, 7 Oct 2026 12:32:23 +0530 Subject: [PATCH 3/5] fix: stale policy subqueries, stuck recursion check, IN (subquery) type check - CREATE POLICY's predicate check left its build in the expression cache, keyed on (selection, AST); enforcement then reused a subquery compiled under the other table's policies as they were at CREATE POLICY time (empty results in normal migration order, or rows a narrowed policy hides). Policy create/drop and RLS enable/disable now count as schema changes, and a schema change clears the build cache. - That also keys the recursion cache correctly: replacing a recursive policy no longer keeps reporting "infinite recursion", and adding one after a clean query reports it. - With the cache no longer short-circuiting it, compiling a policy that reads its own table recursed until the stack overflowed. A re-entrant compile now defers to the run-time recursion check (42P17 for the roles the policy applies to, as in postgres). - `x IN / NOT IN (select col ...)` checks x against the column's type category: `auth.uid() in (select )` is rejected like `=` is. Co-Authored-By: Claude Opus 5.5 --- src/db.ts | 2 ++ src/execution/rls-enforce.ts | 31 +++++++++++++++++--- src/parser/expression-builder.ts | 14 +++++++++ src/table.ts | 3 ++ src/tests/corpus-parity.spec.ts | 50 +++++++++++++++++++++++++++++++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/db.ts b/src/db.ts index 2c593ee8..f84dc66e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -9,6 +9,7 @@ import { buildSelection } from './transforms/selection'; import { buildAlias } from './transforms/alias'; import { buildFilter } from './transforms/build-filter'; import { Adapters } from './adapters'; +import { uncacheAll } from './parser/expression-builder'; import { Transaction } from './transaction'; import { buildGroupBy } from './transforms/aggregation'; import { buildLimit } from './transforms/limit'; @@ -81,6 +82,7 @@ class MemoryDb implements _IDb { onSchemaChange() { this.schemaVersion++; + uncacheAll(); this.raiseGlobal('schema-change', this); } diff --git a/src/execution/rls-enforce.ts b/src/execution/rls-enforce.ts index 4a7cb216..11acfa7c 100644 --- a/src/execution/rls-enforce.ts +++ b/src/execution/rls-enforce.ts @@ -151,9 +151,13 @@ function rowPasses(compiled: CompiledPolicy[], kind: 'using' | 'withCheck', role return sawPermissive && permissiveOk && restrictiveOk; } +/** tables whose policies are being compiled: a policy subquery reading one of them is a policy recursion */ +const compiling = new Set<_ITable>(); + /** Runtime read-visibility filter: applied only when RLS is on and the role doesn't bypass. */ class RlsSelection extends FilterBase { - private compiled: CompiledPolicy[]; + /** null: built while compiling this table's own policies (recursion, reported at run time) */ + private compiled: CompiledPolicy[] | null; get index() { return null; @@ -161,7 +165,25 @@ class RlsSelection extends FilterBase { constructor(private sel: _ISelection, private table: _ITable, private command: RlsCommand, private readsColumns = false) { super(sel); - this.compiled = compilePolicies(sel, table.rls.policies); + if (compiling.has(table)) { + // compiling would recurse forever. Postgres reports this only for the roles whose + // policies actually recurse, so leave it to assertNoPolicyRecursion at run time. + this.compiled = null; + return; + } + compiling.add(table); + try { + this.compiled = compilePolicies(sel, table.rls.policies); + } finally { + compiling.delete(table); + } + } + + private policies(): CompiledPolicy[] { + if (!this.compiled) { + throw new QueryError(`infinite recursion detected in policy for relation "${this.table.name}"`, '42P17'); + } + return this.compiled; } entropy(t: _Transaction) { @@ -179,7 +201,7 @@ class RlsSelection extends FilterBase { // index lookups (WHERE id = …) check rows here rather than enumerating assertNoPolicyRecursion(this.table, this.command, t, this.readsColumns); return this.sel.hasItem(raw, t) - && rowPasses(this.compiled, 'using', currentRole(t).name, this.command, raw, t); + && rowPasses(this.policies(), 'using', currentRole(t).name, this.command, raw, t); } /** @@ -206,8 +228,9 @@ class RlsSelection extends FilterBase { } assertNoPolicyRecursion(this.table, this.command, t, this.readsColumns); const roleName = currentRole(t).name; + const compiled = this.policies(); for (const raw of this.sel.enumerate(t)) { - if (rowPasses(this.compiled, 'using', roleName, this.command, raw, t)) { + if (rowPasses(compiled, 'using', roleName, this.command, raw, t)) { yield raw; } } diff --git a/src/parser/expression-builder.ts b/src/parser/expression-builder.ts index 1c5c3a3e..a7c6476c 100644 --- a/src/parser/expression-builder.ts +++ b/src/parser/expression-builder.ts @@ -39,6 +39,11 @@ export function uncache(data: _ISelection) { builtLru.del(data); } +/** Drops every cached build: a build can embed schema state (e.g. another table's policies in a subquery) */ +export function uncacheAll() { + builtLru.reset(); +} + function _buildValue(val: Expr): IValue { // cache expressions build (they almost are always rebuilt several times in a row) const data = buildCtx().selection; @@ -383,6 +388,15 @@ function buildIn(left: Expr, array: Expr, inclusive: boolean): IValue { } // `x IN (subquery)` needs the subquery's rows as a list, not a scalar let rightValue = isSubqueryNode(array) ? buildSelectAsArray(array as any) : _buildValue(array); + if (isSubqueryNode(array)) { + // `x IN (select col ...)` compares x with the column's type: `auth.uid() in (select )` + // is "operator does not exist: uuid = text", like the list and = forms + const col = (rightValue.type as ArrayType).of; + const leftUnknown = leftValue.isConstantLiteral && leftValue.type.primary === DataType.text; + if (col && !leftUnknown && crossesTypeCategory(leftValue.type, col)) { + throw new QueryError(`operator does not exist: ${leftValue.type.name} = ${col.name}`, '42883'); + } + } if (array.type !== 'list' && rightValue.type.primary !== DataType.list && rightValue.type.primary !== DataType.array) { // `x IN (y)` - the parser drops the parens of a one-element list reconciliateTypes([leftValue, rightValue]); diff --git a/src/table.ts b/src/table.ts index 4ee3fa1c..adb4b0de 100644 --- a/src/table.ts +++ b/src/table.ts @@ -72,6 +72,7 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I throw new QueryError(`policy "${policy.name}" for table "${this.name}" already exists`, '42710'); } this.rls.policies.push(policy); + this.db.onSchemaChange(); } dropPolicy(name: string, ifExists: boolean): void { @@ -83,6 +84,7 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I throw new QueryError(`policy "${name}" for table "${this.name}" does not exist`, '42704'); } this.rls.policies.splice(idx, 1); + this.db.onSchemaChange(); } setRowLevelSecurity(action: 'enable' | 'disable' | 'force' | 'no force'): void { @@ -92,6 +94,7 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I case 'force': this.rls.forced = true; break; case 'no force': this.rls.forced = false; break; } + this.db.onSchemaChange(); } readonly triggers: TableTriggers = emptyTriggers(); diff --git a/src/tests/corpus-parity.spec.ts b/src/tests/corpus-parity.spec.ts index f87c39b9..e105bccc 100644 --- a/src/tests/corpus-parity.spec.ts +++ b/src/tests/corpus-parity.spec.ts @@ -75,7 +75,8 @@ describe('corpus parity', () => { it('rejects comparisons postgres has no operator for', () => { for (const e of ['u = s', 's = u', 'b = s', 's = true', 'v = u', 'u in (s)', 'u is not distinct from s', - 'coalesce(u, s) is null', `s in ('a', 1)`, 'lower(u) = s', 's like 1', `i like 'x'`]) { + 'coalesce(u, s) is null', `s in ('a', 1)`, 'lower(u) = s', 's like 1', `i like 'x'`, + 'u in (select s from t)', 'u not in (select s from t)', 's in (select u from t)']) { rejects(e); } }); @@ -415,6 +416,53 @@ describe('corpus parity', () => { }); }); + describe('a policy subquery sees the other table\'s policies as they are when it runs', () => { + // the check CREATE POLICY runs used to leave its build cached, so enforcement reused a + // subquery compiled under the other table's policies as they were at CREATE POLICY time + beforeEach(() => none(`create role authenticated; + create table members (team_id int, user_id text); create table teams (id int primary key); + insert into teams values (1), (2); insert into members values (1, 'u1'), (2, 'u2'); + alter table teams enable row level security; alter table members enable row level security`)); + const teams = () => { + none(`set role authenticated`); + try { + return many(`select id from teams order by id`).map(r => r.id); + } finally { + none(`reset role`); + } + }; + + it('a policy created after the one that reads it applies', () => { + none(`create policy t on teams for select using (exists (select 1 from members m where m.team_id = teams.id)); + create policy m on members for select using (user_id = 'u1')`); + expect(teams()).toEqual([1]); + }); + + it('narrowing the other table\'s policy narrows the result', () => { + none(`create policy m on members for select using (true); + create policy t on teams for select using (exists (select 1 from members m where m.team_id = teams.id))`); + expect(teams()).toEqual([1, 2]); + none(`drop policy m on members; create policy m on members for select using (user_id = 'u1')`); + expect(teams()).toEqual([1]); + none(`alter table members disable row level security`); + expect(teams()).toEqual([1, 2]); + }); + + it('replacing a recursive policy clears the recursion error', () => { + none(`create policy t on teams for select using (exists (select 1 from teams x where x.id = teams.id))`); + expectQueryError(() => teams(), /infinite recursion detected in policy for relation "teams"/); + none(`drop policy t on teams; create policy t on teams for select using (id = 1)`); + expect(teams()).toEqual([1]); + }); + + it('adding a recursive policy after a clean query reports the recursion', () => { + none(`create policy t on teams for select using (id = 1)`); + expect(teams()).toEqual([1]); + none(`create policy t2 on teams for select using (exists (select 1 from teams x where x.id = teams.id))`); + expectQueryError(() => teams(), /infinite recursion detected in policy for relation "teams"/); + }); + }); + describe('CREATE OR REPLACE TRIGGER', () => { it('replaces an existing trigger', () => { none(`create table o (id int, n int); From b2e77e4009eea4e5c0531cc024e167684dfc72db Mon Sep 17 00:00:00 2001 From: Riya Date: Wed, 7 Oct 2026 12:37:48 +0530 Subject: [PATCH 4/5] fix: upsert RLS, aborted-block recovery, lazy savepoint schema capture - INSERT ... ON CONFLICT DO UPDATE checks the conflicting row against the UPDATE and SELECT USING policies (42501, as in postgres) and the updated row against UPDATE WITH CHECK; it updated other users' rows. - ON CONFLICT (cols) DO NOTHING no longer returns the existing row from RETURNING (the untargeted form already returned nothing). - ROLLBACK TO SAVEPOINT in an aborted block rewinds to the savepoint and keeps the block, instead of being taken as a full ROLLBACK that ended it - after any error, a savepoint-based retry lost the whole block. - A top-level statement that fails to compile (unknown column, uuid = text, ...) inside a BEGIN block aborts it, as one failing at run time does, so COMMIT no longer keeps the block's earlier writes. - SAVEPOINT no longer copies the schema: the first DDL after it does. RELEASE / rollback to an earlier savepoint drop the copies. 200 savepoints on a 150-table schema: 645 ms / +323 MB -> 69 ms / +5 MB. A re-declared savepoint name becomes the newest savepoint. Co-Authored-By: Claude Opus 5.5 --- src/execution/records-mutations/insert.ts | 8 ++- src/execution/rls-enforce.ts | 16 +++++ src/execution/transaction-statements.ts | 6 +- src/interfaces-private.ts | 4 +- src/schema/prepared.ts | 8 +++ src/schema/schema.ts | 8 ++- src/table.ts | 3 +- src/tests/corpus-parity.spec.ts | 78 +++++++++++++++++++++++ src/transaction.ts | 25 ++++++-- 9 files changed, 141 insertions(+), 15 deletions(-) diff --git a/src/execution/records-mutations/insert.ts b/src/execution/records-mutations/insert.ts index 6873c91d..7bc96115 100644 --- a/src/execution/records-mutations/insert.ts +++ b/src/execution/records-mutations/insert.ts @@ -1,5 +1,5 @@ import { _ITable, _Transaction, IValue, _Explainer, nil, _ISchema, asTable, _ISelection, _IIndex, QueryError, OnConflictHandler, ChangeOpts, _IStatement, NotSupported } from '../../interfaces-private'; -import { checkWriteRls } from '../rls-enforce'; +import { checkWriteRls, checkConflictUpdateRls } from '../rls-enforce'; import { fireRowTriggers, fireStatementTriggers, SKIP_ROW } from '../triggers'; import { InsertStatement } from 'pgsql-ast-parser'; import { buildValue } from '../../parser/expression-builder'; @@ -117,6 +117,9 @@ export class Insert extends MutationDataSourceBase { ignoreConflicts = { onIndex, update: (item, excluded, t) => { + // row-level security: the conflicting row must be one this role may update + checkConflictUpdateRls(this.table, item, t); + // build setter context const jitem = subject.buildItem(item, excluded); @@ -130,6 +133,9 @@ export class Insert extends MutationDataSourceBase { // execute set setter(t, item, jitem); + + // ... and the updated row must satisfy UPDATE WITH CHECK + checkWriteRls(this.table, 'update', item, t); }, } } diff --git a/src/execution/rls-enforce.ts b/src/execution/rls-enforce.ts index 11acfa7c..b21cbf7e 100644 --- a/src/execution/rls-enforce.ts +++ b/src/execution/rls-enforce.ts @@ -277,3 +277,19 @@ export function checkWriteRls(table: _ITable, command: 'insert' | 'update', row: throw new QueryError(`new row violates row-level security policy for table "${table.name}"`, '42501'); } } + +/** + * ON CONFLICT DO UPDATE on an RLS table: the existing row must pass the UPDATE (and, since the + * statement reads it, SELECT) USING policies. Postgres errors rather than skipping the row. + */ +export function checkConflictUpdateRls(table: _ITable, existing: Row, t: _Transaction): void { + if (!table.rls.enabled || bypassesRls(t)) { + return; + } + assertNoPolicyRecursion(table, 'update', t, true); + const compiled = compilePolicies(table.selection, table.rls.policies); + const role = currentRole(t).name; + if (!rowPasses(compiled, 'using', role, 'update', existing, t) || !rowPasses(compiled, 'using', role, 'select', existing, t)) { + throw new QueryError(`new row violates row-level security policy (USING expression) for table "${table.name}"`, '42501'); + } +} diff --git a/src/execution/transaction-statements.ts b/src/execution/transaction-statements.ts index 2d6c17e1..a004a286 100644 --- a/src/execution/transaction-statements.ts +++ b/src/execution/transaction-statements.ts @@ -1,8 +1,7 @@ import { _IStatementExecutor, _Transaction, StatementResult } from '../interfaces-private'; import { ExecHelper } from './exec-utils'; import { CommitStatement, RollbackStatement, StartTransactionStatement, BeginStatement, SavepointStatement, ReleaseSavepointStatement } from 'pgsql-ast-parser'; -import { ignore, executionCtx } from '../utils'; -import { captureSchema } from '../schema-snapshot'; +import { ignore } from '../utils'; export class CommitExecutor extends ExecHelper implements _IStatementExecutor { @@ -44,8 +43,7 @@ export class SavepointExecutor extends ExecHelper implements _IStatementExecutor } execute(t: _Transaction): StatementResult { - const db = executionCtx().schema.db; - t.savepoint(this.stmt.name.name, () => captureSchema(db)); + t.savepoint(this.stmt.name.name); return this.noData(t, 'SAVEPOINT'); } } diff --git a/src/interfaces-private.ts b/src/interfaces-private.ts index 17fb1cea..be6820b6 100644 --- a/src/interfaces-private.ts +++ b/src/interfaces-private.ts @@ -168,8 +168,8 @@ export interface _Transaction { /** Commits this transaction and all underlying transactions */ fullCommit(): _Transaction; rollback(): _Transaction; - /** Capture the current state under a named savepoint (and the schema, when given a capturer) */ - savepoint(name: string, captureSchema?: () => () => void): void; + /** Capture the current state under a named savepoint (the schema is captured lazily, by the next DDL) */ + savepoint(name: string): void; /** Record the schema before this transaction's first DDL, so a rollback can restore it */ checkpointSchema(capture: () => () => void): void; /** Roll back the schema of this transaction and every enclosing one (a failed call) */ diff --git a/src/schema/prepared.ts b/src/schema/prepared.ts index 3ab942a3..5b4a17b6 100644 --- a/src/schema/prepared.ts +++ b/src/schema/prepared.ts @@ -57,6 +57,14 @@ function startCall(db: _IDb, statements: Statement[]): _Transaction { } if (open.aborted) { const first = statements[0]?.type; + if (first === 'rollback' && (statements[0] as any).to) { + // ROLLBACK TO SAVEPOINT is how a client recovers an aborted block without ending it + // (what drivers' nested transactions do after an error): rewind to the savepoint and + // carry on in the block. If the savepoint does not exist, that statement fails and the + // block stays aborted. + open.aborted = false; + return open; + } if (first === 'rollback' || first === 'commit') { // the block is over either way (a COMMIT on an aborted block is a ROLLBACK in // postgres): undo it, schema included, and carry on in whatever enclosed it diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 8e4e4fd2..56673e8c 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -1,6 +1,6 @@ import { ISchema, DataType, IType, RelationNotFound, Schema, QueryResult, SchemaField, nil, FunctionDefinition, PermissionDeniedError, TypeNotFound, ArgDefDetails, IEquivalentType, QueryInterceptor, ISubscription, QueryError, typeDefToStr, OperatorDefinition, QueryOrAst, IPreparedQuery } from '../interfaces'; import { _IDb, _ISelection, _ISchema, _Transaction, _ITable, _SelectExplanation, _Explainer, IValue, _IIndex, _IType, _IRelation, QueryObjOpts, _ISequence, _INamedIndex, RegClass, Reg, TypeQuery, asType, _ArgDefDetails, BeingCreated, _FunctionDefinition, _OperatorDefinition } from '../interfaces-private'; -import { asSingleQName, ignore, isType, notNil, parseRegClass, randomString, schemaOf } from '../utils'; +import { asSingleQName, ignore, isType, notNil, parseRegClass, randomString, schemaOf, hasExecutionCtx } from '../utils'; import { typeSynonyms, Types } from '../datatypes'; import { DropFunctionStatement, BinaryOperator, QName, DataTypeDef, CreateSequenceOptions, CreateExtensionStatement, Statement } from 'pgsql-ast-parser'; import { MemoryTable } from '../table'; @@ -100,6 +100,12 @@ export class DbSchema implements _ISchema, ISchema { return ret; } catch (e) { this.db.raiseGlobal('query-failed', query); + // a top-level statement that fails to compile (unknown column, uuid = text, ...) inside a + // BEGIN block aborts it, like one failing at run time: COMMIT then rolls back + const open = this.db.sessionTx; + if (open?.inExplicitBlock && !hasExecutionCtx()) { + open.aborted = true; + } throw e; } } diff --git a/src/table.ts b/src/table.ts index adb4b0de..f5efcb2f 100644 --- a/src/table.ts +++ b/src/table.ts @@ -444,7 +444,8 @@ export class MemoryTable extends DataSourceBase implements IMemoryTable, _I const key = index.buildKey(toInsert, t); const found = index.eqFirst(key, t); if (found) { - return found; // ignore. + // skipped, like the untargeted form: RETURNING yields nothing for it + return null; } } } else { diff --git a/src/tests/corpus-parity.spec.ts b/src/tests/corpus-parity.spec.ts index e105bccc..3068b01b 100644 --- a/src/tests/corpus-parity.spec.ts +++ b/src/tests/corpus-parity.spec.ts @@ -49,6 +49,49 @@ describe('corpus parity', () => { expect(many(`select 1 as x`)).toEqual([{ x: 1 }]); }); + it('a statement that fails to compile aborts the block too', () => { + none(`begin`); + none(`insert into k values (3, 3)`); + expectQueryError(() => many(`select * from k where nope = 1`), /nope/); + expectQueryError(() => many(`select 1`), /current transaction is aborted/); + none(`commit`); + expect(ns()).toEqual([1, 2]); + }); + + it('ROLLBACK TO SAVEPOINT recovers an aborted block without ending it', () => { + // the agent validator's attempt(): savepoint, run the model's SQL, rewind on error + none(`begin`); + none(`create table keep (id int); insert into k values (3, 3)`); + for (const bad of [`create table t1 (id uuid); select * from t1 where id = 'x'::text`, `create table t2 (id int); insert into t2 values (1/0)`]) { + none(`savepoint attempt`); + expectQueryError(() => none(bad)); + none(`rollback to savepoint attempt`); + none(`release savepoint attempt`); + } + expectQueryError(() => none(`insert into k values (4, 1/0)`)); + expectQueryError(() => none(`rollback to savepoint nope`), /savepoint "nope" does not exist/); + expectQueryError(() => many(`select 1`), /current transaction is aborted/); + none(`rollback`); + none(`begin; savepoint s; insert into k values (5, 5)`); + expectQueryError(() => none(`insert into k values (6, 1/0)`)); + none(`rollback to savepoint s; insert into k values (7, 7); commit`); + expect(ns()).toEqual([1, 2, 7]); + expect(many(`select table_name from information_schema.tables where table_name in ('keep', 't1', 't2')`)).toEqual([]); + }); + + it('a re-declared savepoint is the newest one', () => { + none(`begin; savepoint s; savepoint t; savepoint s; rollback to savepoint s; release savepoint t; rollback`); + }); + + it('savepoints that see no DDL do not copy the schema', () => { + none(`begin`); + for (let i = 0; i < 50; i++) { + none(`savepoint s${i}; create table x${i} (id int); rollback to savepoint s${i}; release savepoint s${i}`); + } + none(`savepoint a; create table kept (id int); savepoint b; create table gone (id int); rollback to savepoint b; commit`); + expect(many(`select table_name from information_schema.tables where table_name in ('kept', 'gone', 'x0')`)).toEqual([{ table_name: 'kept' }]); + }); + it('savepoints work across calls', () => { none(`begin`); none(`insert into k values (3, 3)`); @@ -463,6 +506,41 @@ describe('corpus parity', () => { }); }); + describe('RLS on INSERT ... ON CONFLICT', () => { + // checked against PGlite: the conflicting row is subject to the UPDATE policies, and + // DO NOTHING never hands back a row the insert did not write + beforeEach(() => none(`create role alice; + create table docs (id int primary key, owner text, body text); + insert into docs values (1, 'alice', 'a1'), (2, 'bob', 'b2'); + alter table docs enable row level security; + create policy s on docs for select using (owner = current_user); + create policy i on docs for insert with check (owner = current_user); + create policy u on docs for update using (owner = current_user) with check (owner = current_user); + set role alice`)); + const rows = () => { + none(`reset role`); + return many(`select id, owner, body from docs order by id`); + }; + + it('DO UPDATE on a row the role may not update fails', () => { + expectQueryError(() => many(`insert into docs values (2, 'alice', 'x') on conflict (id) do update set body = 'upserted' returning *`), + /violates row-level security policy \(USING expression\)/); + expect(rows()[1]).toEqual({ id: 2, owner: 'bob', body: 'b2' }); + }); + + it('DO UPDATE checks the updated row against WITH CHECK, leaving it untouched on failure', () => { + expect(many(`insert into docs values (1, 'alice', 'x') on conflict (id) do update set body = 'mine' returning body`)).toEqual([{ body: 'mine' }]); + expectQueryError(() => none(`insert into docs values (1, 'alice', 'x') on conflict (id) do update set owner = 'bob'`), + /violates row-level security policy/); + expect(rows()[0]).toEqual({ id: 1, owner: 'alice', body: 'mine' }); + }); + + it('DO NOTHING returns nothing for a conflicting row', () => { + expect(many(`insert into docs values (2, 'alice', 'x') on conflict (id) do nothing returning *`)).toEqual([]); + expect(many(`insert into docs values (2, 'alice', 'x') on conflict do nothing returning *`)).toEqual([]); + }); + }); + describe('CREATE OR REPLACE TRIGGER', () => { it('replaces an existing trigger', () => { none(`create table o (id int, n int); diff --git a/src/transaction.ts b/src/transaction.ts index 47453fd6..6a142902 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -29,9 +29,17 @@ export class Transaction implements _Transaction { */ checkpointSchema(capture: () => () => void): void { let restore: (() => void) | undefined; + const get = () => restore ??= capture(); for (let x: Transaction | null = this; x && x.isChild; x = x.parent) { if (!x.schemaRestore) { - x.schemaRestore = restore ??= capture(); + x.schemaRestore = get(); + } + // savepoints declared since the last DDL capture the schema now, lazily: most + // savepoints see no DDL, and capturing at SAVEPOINT copied the whole schema each time + for (const [k, v] of x.savepointSchemas) { + if (!v) { + x.savepointSchemas.set(k, get()); + } } } } @@ -124,16 +132,17 @@ export class Transaction implements _Transaction { outer?.schemaRestore?.(); } - savepoint(name: string, captureSchema?: () => () => void): void { + savepoint(name: string): void { // re-declaring a name captures the current state under it (postgres hides the // older savepoint of the same name; we simply overwrite - close enough for v1) + this.savepoints.delete(name); // re-inserted last: it is now the newest savepoint this.savepoints.set(name, this.data); - if (captureSchema) { - this.savepointSchemas.set(name, captureSchema()); - } + // the schema is captured by the first DDL that follows (see checkpointSchema) + this.savepointSchemas.set(name, null); } - private savepointSchemas = new Map void>(); + /** savepoint → schema restore, or null while no DDL has run since it was declared */ + private savepointSchemas = new Map void) | null>(); rollbackTo(name: string): void { const saved = this.savepoints.get(name); @@ -141,6 +150,8 @@ export class Transaction implements _Transaction { throw new QueryError(`savepoint "${name}" does not exist`); } this.savepointSchemas.get(name)?.(); + // the schema is back to the savepoint's: the next DDL captures it afresh + this.savepointSchemas.set(name, null); this.data = saved; // the savepoint survives (can be rolled back to again), but any savepoints // established after it are discarded @@ -161,11 +172,13 @@ export class Transaction implements _Transaction { reached = true; if (inclusive) { this.savepoints.delete(k); + this.savepointSchemas.delete(k); } continue; } if (reached) { this.savepoints.delete(k); + this.savepointSchemas.delete(k); } } } From 34db0ea66d78bbea25d8c7d00a340ad775033129 Mon Sep 17 00:00:00 2001 From: Riya Date: Wed, 7 Oct 2026 14:19:17 +0530 Subject: [PATCH 5/5] chore: lockfile for @tinbase/pgsql-ast-parser 12.2.0 Co-Authored-By: Claude Opus 5.5 --- bun.lockb | Bin 479075 -> 488379 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bun.lockb b/bun.lockb index 66c738d50fe769119c97c29e62bfcc97be610464..9a61a74b3c98c85eafaad761caf23bdabcae4771 100755 GIT binary patch delta 16381 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 RlsRJ-O=vkqeY~Vf{~tvNZk+%C delta 15033 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 L#p2S1ZH4~