Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
45 changes: 36 additions & 9 deletions src/constraints/foreign-key.ts
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down Expand Up @@ -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');
}
});

Expand Down Expand Up @@ -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;
}
Expand All @@ -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<Expr>((a, b) => ({
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<Expr>((a, b) => ({
Expand Down Expand Up @@ -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?.();
}
}
}

/**
* 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;
}
2 changes: 1 addition & 1 deletion src/datatypes/datatypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
44 changes: 41 additions & 3 deletions src/execution/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
28 changes: 28 additions & 0 deletions src/tests/corpus-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading