From 0a2cfd1e1a1cbc10f0a6dc358c17c4a1dd818d8d Mon Sep 17 00:00:00 2001 From: Riya Date: Thu, 8 Oct 2026 11:35:50 +0530 Subject: [PATCH] fix: compatible-type foreign keys, search_path setting (4.0.5) Foreign keys were rejected unless both columns had the identical type; postgres accepts any pair with an equality operator (varchar -> text, integer -> bigint). Key values are converted between the two numeric representations for the checks and cascades. Incompatible pairs use postgres' "cannot be implemented ... incompatible types" message. search_path has postgres' default and reads back through SHOW and current_setting; SET with a list value is stored. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++ package.json | 2 +- src/constraints/foreign-key.ts | 45 ++++++++++++++++++++++++++------- src/datatypes/datatypes.ts | 2 +- src/db.ts | 5 +++- src/execution/set.ts | 44 +++++++++++++++++++++++++++++--- src/tests/corpus-parity.spec.ts | 28 ++++++++++++++++++++ 7 files changed, 118 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3aa6ef..7e219266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Notable changes to `@tinbase/pg-mem`, the tinbase fork of pg-mem. Released from `main`, which carries the scoped package name. Upstream is tracked through the `upstream` remote (`oguimbal/pg-mem`) rather than a branch; the leftover `master` is vestigial. +## 4.0.5 + +From a real 4-turn agent build on rapidnative-website's validator. + +- Foreign keys between compatible types are accepted, as in postgres: `varchar -> text`, `integer -> bigint`, `integer -> numeric`, ... (they were rejected unless the two types were identical), and enforced, cascades included. Incompatible types (`text -> uuid`) fail with postgres' reason: `foreign key constraint "x" cannot be implemented: key columns "a" and "b" are of incompatible types: text and uuid` (was `Foreign key column type mismatch`). +- `search_path` exists: `SHOW search_path` / `current_setting('search_path')` return postgres' default `"$user", public`; `SET search_path TO a, b` and `TO DEFAULT` read back as postgres prints them. SET with a list value is stored (it was ignored). + ## 4.0.4 From mutation probes and seeded reads over 120 production projects (validator on PGlite vs pg-mem). diff --git a/package.json b/package.json index 08819638..c561eafb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinbase/pg-mem", - "version": "4.0.4", + "version": "4.0.5", "description": "Fork of pg-mem with extended Postgres conformance (PL/pgSQL, triggers, RLS, correlated subqueries, MERGE, ranges, full-text, partitioning, ...). Tracks oguimbal/pg-mem; pending upstream PR #476.", "main": "index.js", "publishConfig": { diff --git a/src/constraints/foreign-key.ts b/src/constraints/foreign-key.ts index f07c0f78..6f6eaf77 100644 --- a/src/constraints/foreign-key.ts +++ b/src/constraints/foreign-key.ts @@ -1,9 +1,10 @@ -import { ISubscription, NotSupported, QueryError } from '../interfaces'; +import { ISubscription, NotSupported, QueryError, DataType } from '../interfaces'; import { Expr, ExprBinary, TableConstraintForeignKey } from 'pgsql-ast-parser'; -import { asTable, CreateIndexColDef, _IConstraint, _ITable, _Transaction } from '../interfaces-private'; +import { asTable, CreateIndexColDef, _IConstraint, _ITable, _IType, _Transaction } from '../interfaces-private'; import { nullIsh } from '../utils'; import { deferCheck } from '../execution/deferred-checks'; import { enqueueRi } from '../execution/ri-queue'; +import { typeCategory } from '../datatypes'; export class ForeignKey implements _IConstraint { @@ -72,8 +73,14 @@ export class ForeignKey implements _IConstraint { throw new QueryError('Foreign key count mismatch'); } cols.forEach((c, i) => { - if (fcols[i].expression.type !== c.expression.type) { - throw new QueryError(`Foreign key column type mismatch`); + // postgres needs an equality operator between the two types: varchar -> text and + // int -> bigint are fine (the checks below compare through typed expressions), + // text -> uuid is not + const lt = c.expression.type, ft = fcols[i].expression.type; + const category = typeCategory(lt); + const compatible = lt === ft || lt.primary === ft.primary || (!!category && category === typeCategory(ft)); + if (!compatible) { + throw new QueryError(`foreign key constraint "${this.name}" cannot be implemented: key columns "${cst.localColumns[i].name}" and "${cst.foreignColumns[i].name}" are of incompatible types: ${lt.name} and ${ft.name}`, '42804'); } }); @@ -107,7 +114,7 @@ export class ForeignKey implements _IConstraint { if (!old) { return; } - const oVals = fcols.map(x => old[x.expression.id!]); + const oVals = fcols.map((x, i) => toType(old[x.expression.id!], x.expression.type, cols[i].expression.type)); if (oVals.some(nullIsh)) { return; } @@ -120,7 +127,7 @@ export class ForeignKey implements _IConstraint { right: { type: 'constant', value: oVals[i], - dataType: fcols[i].expression.type as any, // hack + dataType: cols[i].expression.type as any, // hack (value already in this type) }, })); const expr = equals.slice(1).reduce((a, b) => ({ @@ -170,7 +177,7 @@ export class ForeignKey implements _IConstraint { if (!neu) { return; } - const vals = cols.map(x => (neu as any)[x.expression.id!]); + const vals = cols.map((x, i) => toType((neu as any)[x.expression.id!], x.expression.type, fcols[i].expression.type)); if (vals.some(nullIsh)) { return; } @@ -183,7 +190,7 @@ export class ForeignKey implements _IConstraint { right: { type: 'constant', value: vals[i], - dataType: cols[i].expression.type as any, // hack + dataType: fcols[i].expression.type as any, // hack (value already in this type) }, })); const expr = equals.slice(1).reduce((a, b) => ({ @@ -261,4 +268,24 @@ export class ForeignKey implements _IConstraint { // via the table's own ConstraintWrapper - or the catalogues keep listing a dead FK this.onUninstalled?.(); } -} \ No newline at end of file +} + +/** + * A key value converted between the two (compatible) column types: they share a category, so + * only the numeric representation can differ - integer/float are JS numbers, bigint/numeric + * digit strings (bigint '1' <-> integer 1). Strings and dates are held the same way. + */ +function toType(value: any, from: _IType, to: _IType): any { + if (nullIsh(value) || from === to || from.primary === to.primary) { + return value; + } + switch (to.primary) { + case DataType.integer: + case DataType.float: + return typeof value === 'number' ? value : Number(value); + case DataType.bigint: + case DataType.decimal: + return typeof value === 'string' ? value : String(value); + } + return value; +} diff --git a/src/datatypes/datatypes.ts b/src/datatypes/datatypes.ts index 56467c9a..f6df9746 100644 --- a/src/datatypes/datatypes.ts +++ b/src/datatypes/datatypes.ts @@ -967,7 +967,7 @@ export function reconciliateTypes(values: IValue[], nullIfNoMatch?: boolean, str * 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 { +export function typeCategory(t: _IType): string | null { switch (t.primary) { case DataType.text: case DataType.citext: diff --git a/src/db.ts b/src/db.ts index f84dc66e..2168c151 100644 --- a/src/db.ts +++ b/src/db.ts @@ -11,6 +11,7 @@ import { buildFilter } from './transforms/build-filter'; import { Adapters } from './adapters'; import { uncacheAll } from './parser/expression-builder'; import { Transaction } from './transaction'; +import { DEFAULT_SEARCH_PATH } from './execution/set'; import { buildGroupBy } from './transforms/aggregation'; import { buildLimit } from './transforms/limit'; import { buildUnion } from './transforms/union'; @@ -37,7 +38,9 @@ export function newDb(opts?: MemoryDbOptions): IMemoryDb { const globals = root.getMap(GLOBAL_VARS) .set('server_version', '12.2 (pg-mem)') // pg-mem has no timezone support: everything behaves as UTC - .set('timezone', 'UTC'); + .set('timezone', 'UTC') + // postgres' default; readable via SHOW / current_setting (pg-mem resolves names its own way) + .set('search_path', DEFAULT_SEARCH_PATH); root.set(GLOBAL_VARS, globals); // create db diff --git a/src/execution/set.ts b/src/execution/set.ts index 27d95d02..86ebf3be 100644 --- a/src/execution/set.ts +++ b/src/execution/set.ts @@ -14,10 +14,48 @@ export class SetExecutor extends ExecHelper implements _IStatementExecutor { execute(t: _Transaction): StatementResult { const p = this.p; - if (p.type === 'set' && p.set.type === 'value') { - t.set(GLOBAL_VARS, t.getMap(GLOBAL_VARS) - .set(p.variable.name, p.set.value)); + if (p.type === 'set') { + const value = settingText(p.variable.name, p.set); + if (value !== null) { + t.set(GLOBAL_VARS, t.getMap(GLOBAL_VARS).set(p.variable.name, value)); + } } return this.noData(t, 'SET'); } } + +export const DEFAULT_SEARCH_PATH = '"$user", public'; + +const DEFAULTS: { [name: string]: string } = { + search_path: DEFAULT_SEARCH_PATH, +}; + +/** + * The text SHOW / current_setting return for a SET value: `set search_path to '$user', public` + * reads back as `"$user", public`, `set x to default` as the default. null: leave unchanged. + */ +function settingText(name: string, set: any): string | null { + const items: any[] = []; + const flatten = (v: any) => { + if (Array.isArray(v)) { + v.forEach(flatten); + } else if (v && (v.type === 'identifier' || v.type === 'value')) { + items.push(v); + } + }; + switch (set?.type) { + case 'value': + return String(set.value); + case 'identifier': + return set.name; + case 'list': + flatten(set.values); + return items.map(v => v.type === 'identifier' + ? v.name + // a quoted name keeps its quotes in a list ('$user' -> "$user") + : (/^[a-z_][a-z0-9_]*$/.test(String(v.value)) ? String(v.value) : `"${v.value}"`)).join(', '); + case 'default': + return DEFAULTS[name.toLowerCase()] ?? null; + } + return null; +} diff --git a/src/tests/corpus-parity.spec.ts b/src/tests/corpus-parity.spec.ts index e396c327..03b05963 100644 --- a/src/tests/corpus-parity.spec.ts +++ b/src/tests/corpus-parity.spec.ts @@ -736,6 +736,34 @@ describe('corpus parity', () => { }); }); + describe('foreign keys between compatible types', () => { + // postgres accepts any pair with an equality operator (varchar -> text, int -> bigint) + it('are accepted and enforced, cascades included', () => { + none(`create table p (id bigint primary key); create table c (id int, pid integer references p(id) on delete cascade); + create table p2 (id varchar(20) primary key); create table c2 (pid text references p2(id) on update cascade); + insert into p values (1), (2); insert into c values (1, 1), (2, 2); insert into p2 values ('a'); insert into c2 values ('a')`); + expectQueryError(() => none(`insert into c values (9, 99)`), /violates foreign key constraint "c_pid_fkey"/); + none(`delete from p where id = 1; update p2 set id = 'b'`); + expect(many(`select id from c`)).toEqual([{ id: 2 }]); + expect(many(`select pid from c2`)).toEqual([{ pid: 'b' }]); + }); + it('incompatible types fail with postgres\' reason', () => { + none(`create table u (id uuid primary key)`); + expectQueryError(() => none(`create table pr (id text references u(id))`), + /foreign key constraint "pr_id_fkey" cannot be implemented: key columns "id" and "id" are of incompatible types: text and uuid/); + }); + }); + + describe('search_path', () => { + it('reads back through SHOW and current_setting, as postgres prints it', () => { + expect(many(`show search_path`)).toEqual([{ search_path: '"$user", public' }]); + none(`set search_path to public, extensions`); + expect(many(`select current_setting('search_path') as s`)).toEqual([{ s: 'public, extensions' }]); + none(`set search_path to default`); + expect(many(`show search_path`)).toEqual([{ search_path: '"$user", public' }]); + }); + }); + describe('CREATE OR REPLACE TRIGGER', () => { it('replaces an existing trigger', () => { none(`create table o (id int, n int);