From 758355b2d7d0abd06f0d47f7e9587992708a3f6a Mon Sep 17 00:00:00 2001 From: Riya Date: Wed, 7 Oct 2026 15:52:29 +0530 Subject: [PATCH 1/2] fix: "operator does not exist" for comparisons across type categories `uuid_col = text_col` was rejected with "cannot cast type text to uuid"; postgres says "operator does not exist: uuid = text", which is what the agent validator shows the model and what tells it which side to cast. Types are spelled as postgres spells them (boolean, double precision, timestamp with time zone). Same for x IN (select col ...). Co-Authored-By: Claude Opus 5.5 --- src/parser/expression-builder.ts | 22 +++++++++++++++++++++- src/tests/corpus-parity.spec.ts | 7 +++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/parser/expression-builder.ts b/src/parser/expression-builder.ts index a7c6476..f268c3c 100644 --- a/src/parser/expression-builder.ts +++ b/src/parser/expression-builder.ts @@ -394,7 +394,7 @@ function buildIn(left: Expr, array: Expr, inclusive: boolean): IValue { 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'); + throw new QueryError(`operator does not exist: ${pgTypeName(leftValue.type)} = ${pgTypeName(col)}`, '42883'); } } if (array.type !== 'list' && rightValue.type.primary !== DataType.list && rightValue.type.primary !== DataType.array) { @@ -405,6 +405,19 @@ function buildIn(left: Expr, array: Expr, inclusive: boolean): IValue { } +/** a type as postgres spells it in error messages (format_type) */ +function pgTypeName(t: _IType): string { + switch (t.primary) { + case DataType.bool: return 'boolean'; + case DataType.float: return 'double precision'; + case DataType.decimal: return 'numeric'; + case DataType.timestamptz: return 'timestamp with time zone'; + case DataType.timestamp: return 'timestamp without time zone'; + case DataType.time: return 'time without time zone'; + } + return t.name; +} + function buildBinary(val: ExprBinary): IValue { let leftValue = _buildValue(val.left); let rightValue = _buildValue(val.right); @@ -437,6 +450,13 @@ export function buildBinaryValue(leftValue: IValue, op: BinaryOperator, rightVal rightValue = doMap(rightValue); } } + // two typed operands from different type categories: postgres has no such operator, and + // says so ("operator does not exist: uuid = text") - which is also what tells the author + // which side to cast + const unknown = (v: IValue) => v.isConstantLiteral && v.type.primary === DataType.text; + if (!unknown(leftValue) && !unknown(rightValue) && crossesTypeCategory(leftValue.type, rightValue.type)) { + throw new QueryError(`operator does not exist: ${pgTypeName(leftValue.type)} ${op} ${pgTypeName(rightValue.type)}`, '42883'); + } const type: _IType = reconciliateTypes([leftValue, rightValue]); leftValue = leftValue.cast(type); rightValue = rightValue.cast(type); diff --git a/src/tests/corpus-parity.spec.ts b/src/tests/corpus-parity.spec.ts index 3068b01..b24dcb5 100644 --- a/src/tests/corpus-parity.spec.ts +++ b/src/tests/corpus-parity.spec.ts @@ -124,6 +124,13 @@ describe('corpus parity', () => { } }); + it('says which operator does not exist, as postgres does', () => { + expectQueryError(() => many(`select 1 from t where u = s`), /operator does not exist: uuid = text/); + expectQueryError(() => many(`select 1 from t where b = s`), /operator does not exist: boolean = text/); + expectQueryError(() => many(`select 1 from t where s = i`), /operator does not exist: text = integer/); + expectQueryError(() => many(`select 1 from t where u in (select s from t)`), /operator does not exist: uuid = text/); + }); + 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`]) { From cad59e97fc6b518bc90780a3efcec0eadf91eb4f Mon Sep 17 00:00:00 2001 From: Riya Date: Wed, 7 Oct 2026 15:54:19 +0530 Subject: [PATCH 2/2] release: 4.0.1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a003fcd..7c9bd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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.1 + +- Comparisons across type categories fail with Postgres' own error, `operator does not exist: uuid = text` (was `cannot cast type text to uuid`), types spelled as Postgres spells them. Also for `x IN (select col ...)`. The SQL that is rejected does not change. + ## 4.0.0 Postgres parity for validating real Supabase migrations: pg-mem now rejects what Postgres rejects, rolls back schema changes, and enforces row-level security on every read and write path. Found by running 250 real RapidNative projects' migrations and seeds through pg-mem and PGlite side by side (`tools/corpus-diff`); all 5 sets of 50 now match. Requires `@tinbase/pgsql-ast-parser` 12.2.0. diff --git a/package.json b/package.json index 3d66eb4..16ed6af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinbase/pg-mem", - "version": "4.0.0", + "version": "4.0.1", "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": {