@simtlix/simfinity-postgres 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,9 +31,9 @@ import { postgresPlugin } from '@simtlix/simfinity-postgres';
31
31
  const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'library' }) });
32
32
  ```
33
33
 
34
- Install `@simtlix/simfinity-sql@3.9.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
34
+ Install `@simtlix/simfinity-sql@3.11.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
35
35
 
36
- Both factories permanently bind one pool and schema, and five optional settings: `multiRowInserts: false` keeps one `INSERT` per owned row instead of multi-row statements; `explicitNullsOrder: true` keeps the `NULLS` clause on sorts of `id` and required fields, as 3.5.9 did, instead of letting indexes serve them; `batchedCollections: true` reads the generated collection fields of the parents that share their arguments with one statement per 100 parents, in a transaction that turns JIT off for that transaction only, instead of one statement per field as 3.6.0 did and as the default still does, and can return unsorted and tied rows in another order; the opt-in `refreshTriggers: 'perRoot'` refreshes each changed root's owned shape and unique keys once per transaction, while its guard trigger stays deferred, instead of once per changed row; and the opt-in `ownerIndexes: 'minimal'` leaves out the `(__owner_id)` indexes that the primary key or a unique index covers on new owned and guard tables. An unknown `refreshTriggers` or `ownerIndexes` value throws `INVALID_DATABASE_SCHEMA` (400). Read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-6-0) before enabling the opt-ins: versions up to 3.5.9 cannot validate databases created with them. The returned runtime supports registration, generated queries and mutations, middleware and scopes, controllers and validators, custom mutations, state machines, nested relationship writes, embedded reconstruction, and native PostgreSQL model handles. Native handles expose `findById`, `find`, `create`, `update`, and `delete`; they are not Mongoose models. `find(args, { session })` accepts the generated GraphQL list query arguments. Existing `pg.Pool` configuration and `pg.PoolClient` query interfaces remain compatible across both factories. A custom pool or client wrapper should pass `pg`'s `command` through in query results: the plugin reads it from the `COMMIT` result to detect a transaction that PostgreSQL rolled back because one of its statements failed, and without it such a transaction reports success with nothing stored.
36
+ Both factories permanently bind one pool and schema, and five optional settings: `multiRowInserts: false` keeps one `INSERT` per owned row instead of multi-row statements; `explicitNullsOrder: true` keeps the `NULLS` clause on sorts of `id` and required fields, as 3.5.9 did, instead of letting indexes serve them; `batchedCollections: true` reads the generated collection fields of the parents that share their arguments with one statement per 100 parents, in a transaction that turns JIT off for that transaction only, instead of one statement per field as 3.6.0 did and as the default still does, and can return unsorted and tied rows in another order; the opt-in `refreshTriggers: 'perRoot'` refreshes each changed root's owned shape and unique keys once per transaction, while its guard trigger stays deferred, instead of once per changed row; and the opt-in `ownerIndexes: 'minimal'` leaves out the `(__owner_id)` indexes that the primary key or a unique index covers on new owned and guard tables. An unknown `refreshTriggers` or `ownerIndexes` value throws `INVALID_DATABASE_SCHEMA` (400). Read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-6-0) before enabling the opt-ins: versions up to 3.5.9 cannot validate databases created with them. The returned runtime supports registration, generated queries and mutations, middleware and scopes, controllers and validators, custom queries and mutations, state machines, nested relationship writes, embedded reconstruction, and native PostgreSQL model handles. Native handles expose `findById`, `find`, `create`, `update`, and `delete`; they are not Mongoose models. `find(args, { session })` accepts the generated GraphQL list query arguments. Existing `pg.Pool` configuration and `pg.PoolClient` query interfaces remain compatible across both factories. A custom pool or client wrapper should pass `pg`'s `command` through in query results: the plugin reads it from the `COMMIT` result to detect a transaction that PostgreSQL rolled back because one of its statements failed, and without it such a transaction reports success with nothing stored.
37
37
 
38
38
  Operations attempted before successful initialization fail with `DATABASE_NOT_INITIALIZED`. Call `createSchema` before `initializeDatabase`. Configuration and registrations become immutable once schema preparation begins. `preventCreatingCollection(true)` makes instance initialization validate existing storage without issuing creation DDL. A rejected initialization request leaves readiness as it was; a ready instance keeps serving while it is initialized again, and becomes unavailable if that initialization fails. `create` mode reads the catalog without table locks and locks nothing when nothing is missing. When it creates objects, writers of the tables that it changes, of existing tables that a new foreign key references, or of roots whose keys it rebuilds wait until it commits, so use `mode: 'validate'` to check a live instance. `simfinity.compileDatabaseSchema()` returns the instance's DDL for review or export.
39
39
 
@@ -62,7 +62,7 @@ The module and every `createPostgres` instance expose the shared `auth`, `valida
62
62
  MCP integration is an independent opt-in and does not add MCP dependencies to PostgreSQL applications:
63
63
 
64
64
  ```sh
65
- npm install @simtlix/simfinity-mcp@3.9.0 @modelcontextprotocol/sdk@^1.31.0
65
+ npm install @simtlix/simfinity-mcp@3.11.0 @modelcontextprotocol/sdk@^1.31.0
66
66
  ```
67
67
 
68
68
  ```javascript
@@ -79,6 +79,10 @@ Queries support nested scalar/reference paths, logical filter groups, scopes, so
79
79
 
80
80
  The text operators take literal values as bound parameters. `LIKE`, `STARTS_WITH` and `ENDS_WITH` compare code points, and `STARTS_WITH` can use a column's btree index. `ILIKE`, `ISTARTS_WITH`, `IENDS_WITH` and `IEQ` fold both sides with the Unicode simple case foldings that MongoDB's case-insensitive matching applies, through one expression, `translate(lower((x) COLLATE "pg_catalog"."und-x-icu"), from, to)`, where `translate()` maps the 22 lowercase variants that ICU `lower()` keeps, such as `ſ`, `µ` and the final sigma `ς`, to their usual letters; so each returns every row that its case-sensitive form returns, and `ISTARTS_WITH "ΚΑΣ"` finds `ΚΑΣΤΡΟ`. The first three match a `LIKE` pattern whose `%`, `_` and `\` are escaped. The two backends still differ on `İ`, which ICU lowers to `i` followed by U+0307, and on letters newer than one side's Unicode tables. The fold needs a `UTF8` database on a server built with ICU: on another, such as one in `LATIN1`, these four operators fail with `UNSUPPORTED_QUERY` (400), and every other operation works. Indexes serve them only on exactly the compiled expression, which the exported `caseFoldExpression(sqlExpression)` returns, such as `caseFoldExpression('"name"')`: a btree with `text_pattern_ops` on it serves `IEQ` and `ISTARTS_WITH`, and a `pg_trgm` GIN index with `gin_trgm_ops` serves `ILIKE`, `ISTARTS_WITH` and `IENDS_WITH`. The [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html#case-folding) gives the details and the index statements.
81
81
 
82
+ `IS_EMPTY` and `IS_NOT_EMPTY` take no value and test the queried record's own fields: a missing, null or `""` String, a missing or null value of any other scalar, a missing, null or `[]` list, and an embedded object that is missing, null or has every stored member empty. They compile from where the path is stored and bind no parameters: `(c IS NULL OR c = '')` for a String column, so that a btree serves it through a `BitmapOr`, `c IS NULL` for any other column, including the uuid identity of a record whose `id` is declared `String`, and `COALESCE(cardinality(c), 0) = 0` for a native array, also on owned rows; a jsonb member is read as text with `->>` and never cast, a jsonb list through a `jsonb_typeof` guard, and a jsonb object as SQL `NULL`, JSON `null` or all of its members empty; an owned single object is empty when its state is not `present` or no owned row has a member that is not empty, and an owned list when its state is not `present` or it has no row, null items included, with a subquery alias of its own at each level of owned storage. `IS_NOT_EMPTY` is the positive form of a leaf and the `NOT` of an object or owned form, so every record matches exactly one of the two. A path that continues inside an embedded list or a collection, such as `lines.qty` or `seasons.year`, fails with the permanent `INVALID_FILTER_PATH` (400), since emptiness of a value inside a list is not defined. The [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) describes the rules.
83
+
84
+ The plugin also declares `queryFeatures.referenceEmptiness`, so `IS_EMPTY` and `IS_NOT_EMPTY` cross single references and end at references and collections, such as `serie.name`, `serie` and `seasons`, and an embedded object's reference members count. A client's condition reads as missing every record that the target's `find` scope hides. The references it crosses share the plain `LEFT JOIN` of other conditions while the scopes crossed so far add nothing, as for an administrator or a target without a `find` scope; from the first scope that adds conditions on, each crossing is a `LEFT JOIN LATERAL (SELECT ... WHERE id = <reference> AND <scope> LIMIT 1)`, and later hops correlate to that join even when their own scope adds nothing. A reference end, and a reference member of an owned row, whose scope adds conditions is a `NOT EXISTS` subquery with the scope, `EXISTS` for `IS_NOT_EMPTY`, which probes the target's primary key; one whose scope adds nothing compiles to `"ref" IS NULL`, `IS NOT NULL` for `IS_NOT_EMPTY`, as a trusted one does, which the generated btree on the reference column serves. A collection end or member is always a `NOT EXISTS` subquery, `EXISTS` for `IS_NOT_EMPTY`, on the child's indexed connection column, with the scope when it adds conditions. So a client's condition whose scopes all add nothing compiles exactly as a trusted one. The target's `id` right after a reference is its uuid key, under the null rule, whatever scalar it is declared with. Scope values are bound parameters, and each subquery has aliases of its own, numbered across the statement. Conditions that middleware and scopes add read stored data: a reference is empty when its column is `NULL`, which a btree serves and which its foreign key, validated and not deferred, keeps exact, a collection when no child row points to the record, and a leaf through a reference reads the plain `LEFT JOIN` that other conditions on that path share. A transaction that defers the foreign keys with `SET CONSTRAINTS ... DEFERRED` can read a reference to a row it deleted as not empty in the `"ref" IS NULL` form, trusted or client, until it commits. A copy such as `{ ...postgresPlugin(options), compileQuery: myCompileQuery }` inherits a declaration that does not describe its compiler, so it gets `UNSUPPORTED_QUERY` (400) for these paths, as for the operators added after 3.7.0, until it declares its own.
85
+
82
86
  Errors that PostgreSQL reports become `SimfinityError`s without SQL details, with the driver error as `getCause()`; errors that application code throws propagate unchanged. Text cannot contain NUL and native date-times start at 4714-11-24 BC: such input fails with `INVALID_VALUE` (400).
83
87
 
84
88
  PostgreSQL enforces required values, real FKs, JSONB/owned embedded shapes, root and embedded scalar/reference uniqueness, and scalar-list multikey uniqueness. Root unique indexes use `NULLS NOT DISTINCT`; private typed owner-key tables allow repeated keys within one owner but reject the same key across owners. Presence markers preserve missing versus explicit null fields. This is stronger and broader than generated Mongoose storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-postgres",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "PostgreSQL GraphQL runtime and schema generation with foreign keys for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -27,8 +27,8 @@
27
27
  "directory": "packages/postgres"
28
28
  },
29
29
  "dependencies": {
30
- "@simtlix/simfinity-core": "3.9.0",
31
- "@simtlix/simfinity-sql": "3.9.0",
30
+ "@simtlix/simfinity-core": "3.11.0",
31
+ "@simtlix/simfinity-sql": "3.11.0",
32
32
  "pg": "^8.16.3"
33
33
  },
34
34
  "peerDependencies": {
package/src/index.js CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  /** Each instance permanently binds one pool/schema; getModel exposes PostgreSQL native helpers. */
21
21
  export const createPostgres = (options) => createSQL({ plugin: postgresPlugin(options) });
22
22
  const defaultInstance = createPostgres();
23
- export const { configureQueryLimits, configureMutationLimits, configure, connect, addNoEndpointType, createSchema, getModel, getType, getInputType, getRegistrations, use, registerMutation, saveObject, preventCreatingCollection, withTransaction } = defaultInstance;
23
+ export const { configureQueryLimits, configureMutationLimits, configure, connect, addNoEndpointType, createSchema, getModel, getType, getInputType, getRegistrations, use, registerMutation, registerCustomQuery, saveObject, preventCreatingCollection, withTransaction } = defaultInstance;
24
24
 
25
25
  /** Preserve the foundation low-level API while allowing initialization of the configured default instance. */
26
26
  export const initializeDatabase = (poolOrOptions, description, options) => description
package/src/plugin.js CHANGED
@@ -19,10 +19,13 @@ export const postgresPlugin = (options) => ({
19
19
  // tables needs no repeatable-read transaction.
20
20
  capabilities: ['transactions', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique', 'singleStatementReads'],
21
21
  // Every filter operator compileQuery and compileChildrenQuery compile, listed one by one so that a later
22
- // QLOperator value is rejected until the compiler supports it. The declaration names the compileQuery it describes,
23
- // so a copy of this plugin with another compileQuery compiles the operators of 3.7.0 until it declares its own.
22
+ // QLOperator value is rejected until the compiler supports it, and emptiness through references and collections
23
+ // (#108 part 2b): the scoped joins and frames of client constructs, and stored semantics for the others. The
24
+ // declaration names the compileQuery it describes, so a copy of this plugin with another compileQuery compiles the
25
+ // operators of 3.7.0, without reference emptiness, until it declares its own.
24
26
  queryFeatures: {
25
- operators: ['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE', 'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ'],
27
+ operators: ['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE', 'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ', 'IS_EMPTY', 'IS_NOT_EMPTY'],
28
+ referenceEmptiness: true,
26
29
  compileQuery,
27
30
  },
28
31
  naming: { validateIdentifier: identifier, generatedName },
@@ -1,10 +1,21 @@
1
1
  import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
2
- import { textQueryOperator, validateTextFilterValue } from '@simtlix/simfinity-core/internal/query-operators';
2
+ import {
3
+ describeEmptiness, describeModelEmptiness, emptinessScopePaths, isEmptinessOperator, textQueryOperator, validateTextFilterValue,
4
+ } from '@simtlix/simfinity-core/internal/query-operators';
3
5
  import { identifier as q, literal, qualified } from '../schema/sql.js';
4
6
  import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
5
7
  import { caseFoldExpression as fold } from './case-fold.js';
6
8
 
7
9
  const unsupported = (message) => { throw new SimfinityError(message, 'UNSUPPORTED_QUERY', 400); };
10
+ // A predicate whose `scopes` do not hold what createQueryPlan writes for it, from a client scope marker the runtime
11
+ // filled, is never compiled as trusted (#108 part 2b).
12
+ const detached = (message) => {
13
+ throw new SimfinityError(`${message}; only createQueryPlan writes the scopes of a predicate, from the client scope markers the runtime fills (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500);
14
+ };
15
+ // The compiler compiles emptiness through single references and at references and collections (#108 part 2b), which
16
+ // the PostgreSQL plugin declares as queryFeatures.referenceEmptiness.
17
+ const REFERENCE_EMPTINESS = true;
18
+ const SCOPE_KINDS = ['predicate', 'and', 'or'];
8
19
 
9
20
  // The case-insensitive text operators compare the case fold of both sides (see case-fold.js), so an application
10
21
  // index serves them only on that expression, which caseFoldExpression writes.
@@ -70,24 +81,58 @@ export const compileChildrenQuery = (models, database, plan, extra, configuratio
70
81
  // each parent with its own compileQuery.
71
82
  Object.defineProperty(compileChildrenQuery, 'compileQuery', { value: compileQuery });
72
83
 
84
+ /**
85
+ * A text that identifies a scope predicate by its value, so that the client constructs of one statement whose chains
86
+ * carry equal scopes share their scoped joins (#108 part 2b). Plain objects, arrays, strings, numbers, booleans,
87
+ * bigints and dates compare by value; anything else, such as a class instance, identifies only itself, through
88
+ * `identities`, and is never shared.
89
+ */
90
+ const scopeIdentity = (value, identities) => {
91
+ if (value === null || value === undefined) return 'null';
92
+ if (typeof value === 'string') return JSON.stringify(value);
93
+ if (['number', 'boolean', 'bigint'].includes(typeof value)) return `${typeof value}:${String(value)}`;
94
+ if (value instanceof Date) return `date:${value.getTime()}`;
95
+ if (Array.isArray(value)) return `[${value.map((item) => scopeIdentity(item, identities)).join(',')}]`;
96
+ const prototype = typeof value === 'object' ? Object.getPrototypeOf(value) : undefined;
97
+ if (prototype === Object.prototype || prototype === null) {
98
+ const members = Object.keys(value).sort().filter((key) => value[key] !== undefined);
99
+ return `{${members.map((key) => `${JSON.stringify(key)}:${scopeIdentity(value[key], identities)}`).join(',')}}`;
100
+ }
101
+ if (!identities.has(value)) identities.set(value, identities.size);
102
+ return `#${identities.get(value)}`;
103
+ };
104
+
73
105
  const compileSelect = (models, database, plan, extra, configuration, batched) => {
74
106
  const explicitNullsOrder = configuration?.explicitNullsOrder === true;
75
107
  const values = [];
76
- const joins = new Map();
77
108
  let serial = 0;
78
109
  const alias = () => `t${++serial}`;
79
110
  const tableByName = (name) => database.tables.find((item) => item.name === name);
80
111
  const root = tableByName(plan.entity);
81
112
  if (!root) unsupported(`Unknown persistent entity ${plan.entity}`);
113
+ // Paths resolve, and their joins are kept, in a frame: the row of an entity and the joins its paths need. The
114
+ // statement's own frame is its root row, t0. Each subquery of reference and collection emptiness (#108 part 2b)
115
+ // reads a frame of its own, whose joins go into the subquery's FROM. Aliases and parameters stay numbered across
116
+ // the whole statement, so no alias shadows another one that an inner subquery reads.
117
+ const rootFrame = { entity: plan.entity, table: root, alias: 't0', joins: new Map() };
118
+ const identities = new Map();
82
119
  const bind = (value, type) => { values.push(value); return `$${values.length}::${type}`; };
83
120
  const col = (name, field) => `${name}.${q(field)}`;
84
- const join = (key, table, condition) => {
121
+ const join = (frame, key, table, condition) => {
122
+ const { joins } = frame;
85
123
  if (!joins.has(key)) {
86
124
  const name = alias();
87
125
  joins.set(key, { alias: name, sql: `LEFT JOIN ${qualified(database.schema, table)} ${name} ON ${condition(name)}` });
88
126
  }
89
127
  return joins.get(key).alias;
90
128
  };
129
+ const joinsOf = (frame) => [...frame.joins.values()].map((item) => item.sql).join(' ');
130
+ // The owned table that stores `field` of the row `context`, or undefined when the row stores it; values inside
131
+ // jsonb never own tables.
132
+ const ownedOf = (context, field) => context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
133
+ // The LEFT JOIN of an owned single object, one per path, which filters and the emptiness forms share. A missed
134
+ // join, and an object in another state, read as NULL.
135
+ const joinOwned = (frame, key, context, owned) => join(frame, key, owned.name, (name) => `${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])} AND ${col(context.alias, owned.ownership.stateColumn)} = 'present'`);
91
136
  const arrayFrom = (vector, expression) => `ARRAY(SELECT ${expression} FROM ${vector.from} WHERE ${vector.where})`;
92
137
  const jsonItems = (expression, missing = '[]') => `jsonb_array_elements(CASE WHEN jsonb_typeof(${expression}) = 'array' THEN ${expression} ELSE '${missing}'::jsonb END)`;
93
138
  const helper = (name, ...args) => `${qualified(database.schema, `__simfinity_${name}`)}(${args.join(', ')})`;
@@ -125,9 +170,9 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
125
170
  const child = project({ json: `${name}.value` }, rest);
126
171
  return `CASE WHEN jsonb_typeof(${value}) = 'array' THEN (SELECT COALESCE(jsonb_agg(${child} ORDER BY ${name}.ordinality) FILTER (WHERE ${child} IS NOT NULL), '[]'::jsonb) FROM ${jsonItems(value)} WITH ORDINALITY ${name}(value, ordinality)) END`;
127
172
  };
128
- const path = (parts, usage = 'filter') => {
129
- const steps = resolveModelPath(models, plan.entity, parts);
130
- let context = { table: root, alias: 't0', json: null, vector: null };
173
+ const path = (parts, usage = 'filter', frame = rootFrame) => {
174
+ const steps = resolveModelPath(models, frame.entity, parts);
175
+ let context = { table: frame.table, alias: frame.alias, json: null, vector: null };
131
176
  for (let index = 0; index < steps.length; index++) {
132
177
  const field = steps[index];
133
178
  const key = parts.slice(0, index + 1).join('.');
@@ -146,8 +191,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
146
191
  const state = `${col(ownerAlias, owned.ownership.stateColumn)} = 'present'`;
147
192
  const presence = (name) => owned.ownership.nullableItems && usage !== 'sort' ? ` AND ${col(name, '__item_present')}` : '';
148
193
  if (!field.list && !context.vector) {
149
- const joined = join(key, owned.name, (name) => `${col(name, '__owner_id')} = ${ownerId} AND ${state}`);
150
- context = { table: owned, alias: joined, json: null, vector: null };
194
+ context = { table: owned, alias: joinOwned(frame, key, context, owned), json: null, vector: null };
151
195
  } else {
152
196
  const name = alias();
153
197
  const stored = alias();
@@ -179,7 +223,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
179
223
  } else if (field.target && !last) {
180
224
  if (context.json) unsupported('Unnormalized embedded reference');
181
225
  const source = context;
182
- const joined = join(key, field.target, (name) => {
226
+ const joined = join(frame, key, field.target, (name) => {
183
227
  if (field.kind === 'collection') return `${col(name, field.connectionField)} = ${col(source.alias, source.table.primaryKey.columns[0])}`;
184
228
  const reference = col(source.alias, field.storageName);
185
229
  return source.vector
@@ -221,7 +265,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
221
265
  // A NOT NULL column of the root row has no nulls to place, so its sort needs no NULLS clause and a
222
266
  // default-order btree (the primary key or a declared index) can return it in order. Joined and
223
267
  // embedded paths can be NULL even for NOT NULL columns, so they keep the clause.
224
- const notNull = !context.json && !context.vector && !field.list && context.alias === 't0'
268
+ const notNull = !context.json && !context.vector && !field.list && context.alias === frame.alias
225
269
  && context.table.columns.find((item) => item.name === (identity ? 'id' : field.storageName))?.nullable === false;
226
270
  return { sql, uncollated, field: identity ? { ...field, scalar: 'ID' } : field, type: dateNumeric ? 'double precision' : type, list: field.list || !!context.vector, vector: !!context.vector, dateNumeric, sortKey, notNull };
227
271
  }
@@ -319,11 +363,177 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
319
363
  const left = ignoreCase ? uncollated : sql;
320
364
  return negated ? `COALESCE(${compare(left)}, FALSE)` : compare(left);
321
365
  };
322
- // A negation reaches every predicate below it, including those nested in AND/OR groups.
323
- const where = (node, negated = false) => {
366
+ /**
367
+ * A subquery on the entity `entity` in a frame of its own (#108 part 2b), as `{ alias, from }`, `from` being its
368
+ * text from FROM to the end of its WHERE. `correlation(alias)` ties its rows to the enclosing row, and `scope`, a
369
+ * predicate planned on the entity, or null, restricts them. The frame's alias comes first, then the scope's joins
370
+ * and conditions, which compile before the text is assembled so that the FROM holds their joins. A scope is trusted:
371
+ * it reads stored data through plain joins of its own frame.
372
+ */
373
+ const subquery = (entity, correlation, scope) => {
374
+ const table = tableByName(entity);
375
+ if (!table) unsupported(`Unknown persistent entity ${entity}`);
376
+ const frame = { entity, table, alias: alias(), joins: new Map() };
377
+ const restriction = scope ? ` AND ${where(scope, frame)}` : '';
378
+ const joins = joinsOf(frame);
379
+ return { alias: frame.alias, from: `FROM ${qualified(database.schema, table.name)} ${frame.alias}${joins ? ` ${joins}` : ''} WHERE ${correlation(frame.alias)}${restriction}` };
380
+ };
381
+ /**
382
+ * The scoped join of a client construct's crossing (#108 part 2b): the record that the reference `field` of the row
383
+ * `context` points to, when `scope` admits it, or NULLs, which is how a hidden record reads as a missing one. LIMIT 1
384
+ * keeps one row whatever joins the scope makes. Its key, apart from those of plain joins, names the path and the
385
+ * scopes of every reference crossed up to this one, so only constructs with the same chain share it.
386
+ */
387
+ const scopedJoin = (frame, key, field, context, scope) => {
388
+ if (!frame.joins.has(key)) {
389
+ const name = alias();
390
+ const target = subquery(field.target, (row) => `${col(row, 'id')} = ${col(context.alias, field.storageName)}`, scope);
391
+ frame.joins.set(key, { alias: name, sql: `LEFT JOIN LATERAL (SELECT ${target.alias}.* ${target.from} LIMIT 1) ${name} ON TRUE` });
392
+ }
393
+ return frame.joins.get(key).alias;
394
+ };
395
+ // The scope of `path` in a client construct's scopes, or null for a trusted construct and a scope that added nothing.
396
+ const scopeAt = (scopes, path) => (scopes && Object.hasOwn(scopes, path) ? scopes[path] : null);
397
+ /**
398
+ * The scopes of a client construct (#108 part 2b), checked against its shape: one own key for each scoped join and
399
+ * frame that emptinessScopePaths lists, each a predicate planned on its target, or null. A predicate without
400
+ * `scopes` is trusted. Anything else is not what createQueryPlan writes, and never compiles as trusted.
401
+ */
402
+ const scopesOf = (node, shape, label) => {
403
+ if (!Object.hasOwn(node, 'scopes') || node.scopes === undefined) return null;
404
+ const { scopes } = node;
405
+ if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} has scopes that are not an object`);
406
+ const { joins, frames } = emptinessScopePaths(shape, node.path);
407
+ for (const key of [...joins, ...frames]) {
408
+ if (!Object.hasOwn(scopes, key) || scopes[key] === undefined) detached(`${label} has no scope for ${key}`);
409
+ const scope = scopes[key];
410
+ if (scope !== null && (typeof scope !== 'object' || !SCOPE_KINDS.includes(scope.kind))) detached(`${label} has a scope for ${key} that is neither a query predicate nor null`);
411
+ }
412
+ return scopes;
413
+ };
414
+ /**
415
+ * NONE over a reference or a collection (#108 part 2b), the IS_EMPTY form, or with `empty` false the IS_NOT_EMPTY
416
+ * form, EXISTS: whether a record that `scope` admits is the one the reference `field` of the row `context` points
417
+ * to, or a child whose `shape.connectionField`, the child's storage column, points to that row. Without a scope a
418
+ * reference reads its own column, `IS NULL`, since a foreign key that is validated and not deferred keeps it from
419
+ * dangling; a collection keeps the subquery.
420
+ */
421
+ const frameForm = (context, field, shape, empty, scope) => {
422
+ if (!context.table) unsupported('Unnormalized embedded reference');
423
+ if (shape.rule === 'reference' && !scope) return `${col(context.alias, field.storageName)} IS ${empty ? '' : 'NOT '}NULL`;
424
+ const correlation = shape.rule === 'reference'
425
+ ? (row) => `${col(row, 'id')} = ${col(context.alias, field.storageName)}`
426
+ : (row) => `${col(row, shape.connectionField)} = ${col(context.alias, context.table.primaryKey.columns[0])}`;
427
+ return `${empty ? 'NOT ' : ''}EXISTS (SELECT 1 ${subquery(field.target, correlation, scope).from})`;
428
+ };
429
+ /**
430
+ * The IS_EMPTY form of `field`, stored on the row or jsonb value `context`, by its emptiness shape, or with `empty`
431
+ * false the IS_NOT_EMPTY form: the positive form of a leaf, which a btree can serve, and the NOT of an object or an
432
+ * owned form. Every form is two-valued, and only scopes bind parameters. A leaf is a column of any row, or the text
433
+ * of a jsonb member, never cast, since a cast can fail; a list a native array column, a jsonb list, or an owned
434
+ * list, whose null items are rows too, so `[null]` is not empty; an object a jsonb value or an owned single object
435
+ * whose stored members are all empty; a reference or a collection, members included, a frame (frameForm) with the
436
+ * scope `at.scopes` holds for its path, `at.path`. An owned table is read in a subquery with an alias of its own,
437
+ * correlated to the key of the row that owns it: the root's id, or the `__id` of a joined or enclosing owned row.
438
+ */
439
+ const emptyForm = (context, field, shape, empty = true, at = { path: field.name, scopes: null }) => {
440
+ if (shape.rule === 'reference' || shape.rule === 'collection') return frameForm(context, field, shape, empty, scopeAt(at.scopes, at.path));
441
+ const compare = empty ? '=' : '>';
442
+ const negate = (form) => (empty ? form : `NOT ${form}`);
443
+ const leaf = (value) => {
444
+ if (shape.rule === 'string') return empty ? `(${value} IS NULL OR ${value} = '')` : `(${value} IS NOT NULL AND ${value} <> '')`;
445
+ return `${value} IS ${empty ? '' : 'NOT '}NULL`;
446
+ };
447
+ // The CASE keeps jsonb_array_length from failing (22023) on a value that is not an array.
448
+ const jsonList = (json) => `COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(${json}) = 'array' THEN ${json} END), 0) ${compare} 0`;
449
+ // An embedded type without stored members is always empty.
450
+ const members = (row) => {
451
+ const forms = shape.members.map((member) => {
452
+ const stored = field.fields?.find((item) => item.name === member.name);
453
+ if (!stored) unsupported('Invalid query path');
454
+ return emptyForm(row, stored, member.shape, true, { path: `${at.path}.${member.name}`, scopes: at.scopes });
455
+ });
456
+ return forms.length ? `(${forms.join(' AND ')})` : 'TRUE';
457
+ };
458
+ const jsonObject = (json) => negate(`(${json} IS NULL OR ${json} = 'null'::jsonb OR ${members({ table: null, alias: null, json })})`);
459
+ if (context.json) {
460
+ const json = `(${context.json} -> ${literal(field.storageName)})`;
461
+ if (shape.rule === 'object') return jsonObject(json);
462
+ if (shape.rule === 'list') return jsonList(json);
463
+ return leaf(`(${context.json} ->> ${literal(field.storageName)})`);
464
+ }
465
+ const owned = ownedOf(context, field);
466
+ if (owned) {
467
+ // A missed join leaves the state NULL, which is not 'present'.
468
+ const state = `${col(context.alias, owned.ownership.stateColumn)} IS DISTINCT FROM 'present'`;
469
+ const name = alias();
470
+ const rows = `SELECT 1 FROM ${qualified(database.schema, owned.name)} ${name} WHERE ${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])}`;
471
+ if (shape.rule === 'list') return negate(`(${state} OR NOT EXISTS (${rows}))`);
472
+ // An object marked present without a row, which the owned-shape check rejects, is empty too.
473
+ return negate(`(${state} OR NOT EXISTS (${rows} AND NOT ${members({ table: owned, alias: name, json: null })}))`);
474
+ }
475
+ // The record identity is the uuid primary key, whatever scalar it is declared with.
476
+ const identity = !context.table.ownership && ['id', '_id'].includes(field.name);
477
+ const column = col(context.alias, identity ? 'id' : field.storageName);
478
+ if (field.kind === 'embedded') return shape.rule === 'object' ? jsonObject(column) : jsonList(column);
479
+ if (shape.rule === 'list') return `COALESCE(cardinality(${column}), 0) ${compare} 0`;
480
+ return leaf(column);
481
+ };
482
+ /**
483
+ * IS_EMPTY and IS_NOT_EMPTY (#108, parts 2a and 2b), on the row of `frame`. Every emptiness predicate, leaves
484
+ * included, compiles here from where its path is stored, not from path(), whose values cast jsonb members and
485
+ * materialize jsonb lists, and which rejects paths that end at an embedded field. The checks of createQueryPlan run
486
+ * again, with its codes, on the entity's GraphQL type or on the field descriptions, so a plan built by hand gets the
487
+ * same answers; they leave only single embedded objects and single references before the last field. An owned
488
+ * single object is reached through its LEFT JOIN. A reference is crossed through the plain LEFT JOIN that legacy
489
+ * paths share, for a trusted predicate, which reads stored data; a client construct, whose `scopes` the plan lifted
490
+ * from the marker the runtime filled, crosses it through a scoped join instead from its first crossing whose scope
491
+ * is not null on, so that every later hop, a null scope included, correlates through that scoped join and never
492
+ * shares a join with a trusted or legacy path. Its ends and members are frames with their own scopes.
493
+ */
494
+ const emptiness = (node, frame) => {
495
+ const { path: parts, operator, value } = node;
496
+ const steps = resolveModelPath(models, frame.entity, parts);
497
+ const label = parts.join('.');
498
+ const options = { operator, value, path: label, referenceEmptiness: REFERENCE_EMPTINESS };
499
+ const gqltype = models.entities.find((item) => item.name === frame.entity)?.gqltype;
500
+ const shape = gqltype ? describeEmptiness(gqltype, parts, options) : describeModelEmptiness(steps, options);
501
+ const scopes = scopesOf(node, shape, `${operator} on ${label}`);
502
+ let context = { table: frame.table, alias: frame.alias, json: null };
503
+ // The scopes of the references crossed so far; once one of them is not null, the chain is scoped.
504
+ const chain = [];
505
+ let scoped = false;
506
+ const chainKey = (key) => `scoped ${key} ${scopeIdentity(chain, identities)}`;
507
+ steps.slice(0, -1).forEach((field, index) => {
508
+ const key = parts.slice(0, index + 1).join('.');
509
+ if (field.kind === 'embedded' && !field.list) {
510
+ const owned = ownedOf(context, field);
511
+ context = owned
512
+ ? { table: owned, alias: joinOwned(frame, scoped ? chainKey(key) : key, context, owned), json: null }
513
+ : { table: null, alias: null, json: context.json ? `(${context.json} -> ${literal(field.storageName)})` : col(context.alias, field.storageName) };
514
+ } else if (field.kind === 'reference' && !field.inferred && context.table) {
515
+ const scope = scopeAt(scopes, key);
516
+ chain.push(scope);
517
+ scoped ||= scope !== null;
518
+ const joined = scoped
519
+ ? scopedJoin(frame, chainKey(key), field, context, scope)
520
+ : join(frame, key, field.target, (name) => `${col(name, 'id')} = ${col(context.alias, field.storageName)}`);
521
+ context = { table: tableByName(field.target), alias: joined, json: null };
522
+ } else unsupported('Invalid query path');
523
+ });
524
+ return emptyForm(context, steps.at(-1), shape, operator === 'IS_EMPTY', { path: label, scopes });
525
+ };
526
+ // A negation reaches every predicate below it, including those nested in AND/OR groups; the emptiness forms are
527
+ // two-valued, so they need no null-safe form under it. A frame's scope is never negated.
528
+ const where = (node, frame = rootFrame, negated = false) => {
324
529
  if (!node) return 'TRUE';
325
- if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value, negated)})`;
326
- return `(${node.terms.map((term) => where(term, negated)).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
530
+ if (node.kind === 'predicate' && isEmptinessOperator(node.operator)) return `(${emptiness(node, frame)})`;
531
+ if (node.kind === 'predicate') {
532
+ const sql = predicate(path(node.path, 'filter', frame), node.operator, node.value, negated);
533
+ if (Object.hasOwn(node, 'scopes') && node.scopes !== undefined) detached(`${node.operator} on ${[].concat(node.path).join('.')} has scopes, but is not an emptiness condition`);
534
+ return `(${sql})`;
535
+ }
536
+ return `(${node.terms.map((term) => where(term, frame, negated)).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
327
537
  };
328
538
  let condition = where(plan.where);
329
539
  if (extra) {
@@ -383,7 +593,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
383
593
  }
384
594
  // Laterals follow every join, so their expressions can read the root row and any joined alias.
385
595
  const lateralSQL = [...laterals.values()].map((item) => ` CROSS JOIN LATERAL (SELECT ${item.sql} AS value OFFSET 0) ${item.alias}`).join('');
386
- let text = `SELECT ${select} FROM ${qualified(database.schema, root.name)} t0 ${[...joins.values()].map((item) => item.sql).join(' ')}${lateralSQL} WHERE ${condition}${group}`;
596
+ let text = `SELECT ${select} FROM ${qualified(database.schema, root.name)} t0 ${joinsOf(rootFrame)}${lateralSQL} WHERE ${condition}${group}`;
387
597
  if (plan.mode === 'aggregate') text = `SELECT * FROM (${text}) aggregated`;
388
598
  if (order.length) text += ` ORDER BY ${order.join(', ')}`;
389
599
  if (plan.pagination) text += ` LIMIT ${bind(plan.pagination.limit, 'bigint')} OFFSET ${bind(plan.pagination.offset, 'bigint')}`;
package/types/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  AuthRule,
18
18
  AuthRuleFunction,
19
19
  AuthSchemaPlugin,
20
+ CreateSchemaOptions,
20
21
  EntityController,
21
22
  EnvelopSchemaPlugin,
22
23
  FieldValidations,
@@ -235,6 +236,27 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
235
236
  * `QLOperator` value. The case-insensitive text operators fold case with {@link caseFoldExpression}, which needs a UTF8
236
237
  * database with the ICU collation `und-x-icu`: on another database, such as one in LATIN1 or on a server built without
237
238
  * ICU, they fail with UNSUPPORTED_QUERY (400), and every other operation works.
239
+ *
240
+ * `IS_EMPTY` and `IS_NOT_EMPTY` compile from where a path is stored, binding no parameters: `(c IS NULL OR c = '')`
241
+ * for a String column and `c IS NULL` for any other, the uuid identity included; `COALESCE(cardinality(c), 0) = 0`
242
+ * for a native array; jsonb members as text without a cast, and jsonb lists through a `jsonb_typeof` guard; an
243
+ * embedded object as missing, JSON `null`, or every stored member empty; and owned objects and lists through their
244
+ * state column and a `NOT EXISTS` subquery on the owned table, null list items counting as items. `IS_NOT_EMPTY` is
245
+ * the positive form of a leaf, which a btree can serve, and the `NOT` of an object or owned form.
246
+ *
247
+ * The plugin also declares `queryFeatures.referenceEmptiness`. A client's condition reads as missing every record that
248
+ * the target's `find` scope hides. The references it crosses share the plain `LEFT JOIN` of other conditions while
249
+ * the scopes crossed so far add nothing; from the first scope that adds conditions on, each crossing is a
250
+ * `LEFT JOIN LATERAL (... LIMIT 1)` that admits the target only where its own scope admits it, and later hops
251
+ * correlate to that join even when their own scope adds nothing. A reference end or member whose scope adds
252
+ * conditions is a `NOT EXISTS` subquery with that scope (`EXISTS` for `IS_NOT_EMPTY`); one whose scope adds nothing
253
+ * compiles to `"ref" IS NULL` (`IS NOT NULL`), as a trusted one does. A collection end or member is always a
254
+ * `NOT EXISTS` subquery (`EXISTS`) on the child's connection column, with the scope only when it adds conditions.
255
+ * Scope values are parameters, and the target's `id` after a reference is its uuid key, under the null rule.
256
+ * Conditions from middleware and scopes read stored data: a reference is empty when its column is `NULL`, which its
257
+ * foreign key, validated and not deferred, keeps exact, a collection through `NOT EXISTS` on the child's column, and
258
+ * a leaf through a plain `LEFT JOIN`. A session that defers the foreign keys (`SET CONSTRAINTS ... DEFERRED`) can see
259
+ * a dangling reference as not empty in the `"ref" IS NULL` form, trusted or client, until it commits.
238
260
  */
239
261
  export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
240
262
  PostgresConfiguration,
@@ -269,6 +291,7 @@ export const getInputType: PostgresRuntime['getInputType'];
269
291
  export const getRegistrations: PostgresRuntime['getRegistrations'];
270
292
  export const use: PostgresRuntime['use'];
271
293
  export const registerMutation: PostgresRuntime['registerMutation'];
294
+ export const registerCustomQuery: PostgresRuntime['registerCustomQuery'];
272
295
  export const saveObject: PostgresRuntime['saveObject'];
273
296
  export const preventCreatingCollection: PostgresRuntime['preventCreatingCollection'];
274
297
  export const withTransaction: PostgresRuntime['withTransaction'];