@simtlix/simfinity-postgres 3.10.0 → 3.12.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.10.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.12.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 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.
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, named views, 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.10.0 @modelcontextprotocol/sdk@^1.31.0
65
+ npm install @simtlix/simfinity-mcp@3.12.0 @modelcontextprotocol/sdk@^1.31.0
66
66
  ```
67
67
 
68
68
  ```javascript
@@ -79,7 +79,9 @@ 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. In this release, any other path that crosses or ends at a reference, a path that ends at a collection, such as `seasons`, and an embedded object with a reference member fail with `UNSUPPORTED_QUERY` (400). The [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) describes the rules.
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.
83
85
 
84
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).
85
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-postgres",
3
- "version": "3.10.0",
3
+ "version": "3.12.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.10.0",
31
- "@simtlix/simfinity-sql": "3.10.0",
30
+ "@simtlix/simfinity-core": "3.12.0",
31
+ "@simtlix/simfinity-sql": "3.12.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, registerCustomQuery, saveObject, preventCreatingCollection, withTransaction } = defaultInstance;
23
+ export const { configureQueryLimits, configureMutationLimits, configure, connect, addNoEndpointType, createSchema, getModel, getType, getInputType, getRegistrations, use, registerMutation, registerCustomQuery, registerView, 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
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,12 +1,21 @@
1
1
  import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
2
2
  import {
3
- describeEmptiness, describeModelEmptiness, isEmptinessOperator, textQueryOperator, validateTextFilterValue,
3
+ describeEmptiness, describeModelEmptiness, emptinessScopePaths, isEmptinessOperator, textQueryOperator, validateTextFilterValue,
4
4
  } from '@simtlix/simfinity-core/internal/query-operators';
5
5
  import { identifier as q, literal, qualified } from '../schema/sql.js';
6
6
  import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
7
7
  import { caseFoldExpression as fold } from './case-fold.js';
8
8
 
9
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'];
10
19
 
11
20
  // The case-insensitive text operators compare the case fold of both sides (see case-fold.js), so an application
12
21
  // index serves them only on that expression, which caseFoldExpression writes.
@@ -72,30 +81,58 @@ export const compileChildrenQuery = (models, database, plan, extra, configuratio
72
81
  // each parent with its own compileQuery.
73
82
  Object.defineProperty(compileChildrenQuery, 'compileQuery', { value: compileQuery });
74
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
+
75
105
  const compileSelect = (models, database, plan, extra, configuration, batched) => {
76
106
  const explicitNullsOrder = configuration?.explicitNullsOrder === true;
77
107
  const values = [];
78
- const joins = new Map();
79
108
  let serial = 0;
80
109
  const alias = () => `t${++serial}`;
81
110
  const tableByName = (name) => database.tables.find((item) => item.name === name);
82
111
  const root = tableByName(plan.entity);
83
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();
84
119
  const bind = (value, type) => { values.push(value); return `$${values.length}::${type}`; };
85
120
  const col = (name, field) => `${name}.${q(field)}`;
86
- const join = (key, table, condition) => {
121
+ const join = (frame, key, table, condition) => {
122
+ const { joins } = frame;
87
123
  if (!joins.has(key)) {
88
124
  const name = alias();
89
125
  joins.set(key, { alias: name, sql: `LEFT JOIN ${qualified(database.schema, table)} ${name} ON ${condition(name)}` });
90
126
  }
91
127
  return joins.get(key).alias;
92
128
  };
129
+ const joinsOf = (frame) => [...frame.joins.values()].map((item) => item.sql).join(' ');
93
130
  // The owned table that stores `field` of the row `context`, or undefined when the row stores it; values inside
94
131
  // jsonb never own tables.
95
132
  const ownedOf = (context, field) => context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
96
133
  // The LEFT JOIN of an owned single object, one per path, which filters and the emptiness forms share. A missed
97
134
  // join, and an object in another state, read as NULL.
98
- const joinOwned = (key, context, owned) => join(key, owned.name, (name) => `${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])} AND ${col(context.alias, owned.ownership.stateColumn)} = 'present'`);
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'`);
99
136
  const arrayFrom = (vector, expression) => `ARRAY(SELECT ${expression} FROM ${vector.from} WHERE ${vector.where})`;
100
137
  const jsonItems = (expression, missing = '[]') => `jsonb_array_elements(CASE WHEN jsonb_typeof(${expression}) = 'array' THEN ${expression} ELSE '${missing}'::jsonb END)`;
101
138
  const helper = (name, ...args) => `${qualified(database.schema, `__simfinity_${name}`)}(${args.join(', ')})`;
@@ -133,9 +170,9 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
133
170
  const child = project({ json: `${name}.value` }, rest);
134
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`;
135
172
  };
136
- const path = (parts, usage = 'filter') => {
137
- const steps = resolveModelPath(models, plan.entity, parts);
138
- 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 };
139
176
  for (let index = 0; index < steps.length; index++) {
140
177
  const field = steps[index];
141
178
  const key = parts.slice(0, index + 1).join('.');
@@ -154,7 +191,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
154
191
  const state = `${col(ownerAlias, owned.ownership.stateColumn)} = 'present'`;
155
192
  const presence = (name) => owned.ownership.nullableItems && usage !== 'sort' ? ` AND ${col(name, '__item_present')}` : '';
156
193
  if (!field.list && !context.vector) {
157
- context = { table: owned, alias: joinOwned(key, context, owned), json: null, vector: null };
194
+ context = { table: owned, alias: joinOwned(frame, key, context, owned), json: null, vector: null };
158
195
  } else {
159
196
  const name = alias();
160
197
  const stored = alias();
@@ -186,7 +223,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
186
223
  } else if (field.target && !last) {
187
224
  if (context.json) unsupported('Unnormalized embedded reference');
188
225
  const source = context;
189
- const joined = join(key, field.target, (name) => {
226
+ const joined = join(frame, key, field.target, (name) => {
190
227
  if (field.kind === 'collection') return `${col(name, field.connectionField)} = ${col(source.alias, source.table.primaryKey.columns[0])}`;
191
228
  const reference = col(source.alias, field.storageName);
192
229
  return source.vector
@@ -228,7 +265,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
228
265
  // A NOT NULL column of the root row has no nulls to place, so its sort needs no NULLS clause and a
229
266
  // default-order btree (the primary key or a declared index) can return it in order. Joined and
230
267
  // embedded paths can be NULL even for NOT NULL columns, so they keep the clause.
231
- const notNull = !context.json && !context.vector && !field.list && context.alias === 't0'
268
+ const notNull = !context.json && !context.vector && !field.list && context.alias === frame.alias
232
269
  && context.table.columns.find((item) => item.name === (identity ? 'id' : field.storageName))?.nullable === false;
233
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 };
234
271
  }
@@ -326,16 +363,81 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
326
363
  const left = ignoreCase ? uncollated : sql;
327
364
  return negated ? `COALESCE(${compare(left)}, FALSE)` : compare(left);
328
365
  };
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
+ };
329
429
  /**
330
430
  * The IS_EMPTY form of `field`, stored on the row or jsonb value `context`, by its emptiness shape, or with `empty`
331
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
332
- * owned form. Every form is two-valued and binds no parameters. A leaf is a column of any row, or the text of a
333
- * jsonb member, never cast, since a cast can fail; a list a native array column, a jsonb list, or an owned list,
334
- * whose null items are rows too, so `[null]` is not empty; an object a jsonb value or an owned single object whose
335
- * stored members are all empty. An owned table is read in a subquery with an alias of its own, correlated to the
336
- * key of the row that owns it: the root's id, or the `__id` of a joined or enclosing owned row.
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.
337
438
  */
338
- const emptyForm = (context, field, shape, empty = true) => {
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));
339
441
  const compare = empty ? '=' : '>';
340
442
  const negate = (form) => (empty ? form : `NOT ${form}`);
341
443
  const leaf = (value) => {
@@ -349,7 +451,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
349
451
  const forms = shape.members.map((member) => {
350
452
  const stored = field.fields?.find((item) => item.name === member.name);
351
453
  if (!stored) unsupported('Invalid query path');
352
- return emptyForm(row, stored, member.shape);
454
+ return emptyForm(row, stored, member.shape, true, { path: `${at.path}.${member.name}`, scopes: at.scopes });
353
455
  });
354
456
  return forms.length ? `(${forms.join(' AND ')})` : 'TRUE';
355
457
  };
@@ -378,34 +480,60 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
378
480
  return leaf(column);
379
481
  };
380
482
  /**
381
- * IS_EMPTY and IS_NOT_EMPTY (#108, part 2a). Every emptiness predicate, leaves included, compiles here from where
382
- * its path is stored, not from path(), whose values cast jsonb members and materialize jsonb lists, and which
383
- * rejects paths that end at an embedded field. The checks of createQueryPlan run again, with its codes, on the
384
- * entity's GraphQL type or on the field descriptions, so a plan built by hand gets the same answers; they leave
385
- * only single embedded objects before the last field, reached through the LEFT JOIN of each owned single object.
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.
386
493
  */
387
- const emptiness = ({ path: parts, operator, value }) => {
388
- const steps = resolveModelPath(models, plan.entity, parts);
389
- const options = { operator, value, path: parts.join('.') };
390
- const gqltype = models.entities.find((item) => item.name === plan.entity)?.gqltype;
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;
391
500
  const shape = gqltype ? describeEmptiness(gqltype, parts, options) : describeModelEmptiness(steps, options);
392
- let context = { table: root, alias: 't0', json: null };
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)}`;
393
507
  steps.slice(0, -1).forEach((field, index) => {
394
- if (field.kind !== 'embedded' || field.list) unsupported('Invalid query path');
395
- const owned = ownedOf(context, field);
396
- context = owned
397
- ? { table: owned, alias: joinOwned(parts.slice(0, index + 1).join('.'), context, owned), json: null }
398
- : { table: null, alias: null, json: context.json ? `(${context.json} -> ${literal(field.storageName)})` : col(context.alias, field.storageName) };
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');
399
523
  });
400
- return emptyForm(context, steps.at(-1), shape, operator === 'IS_EMPTY');
524
+ return emptyForm(context, steps.at(-1), shape, operator === 'IS_EMPTY', { path: label, scopes });
401
525
  };
402
526
  // A negation reaches every predicate below it, including those nested in AND/OR groups; the emptiness forms are
403
- // two-valued, so they need no null-safe form under it.
404
- const where = (node, negated = false) => {
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) => {
405
529
  if (!node) return 'TRUE';
406
- if (node.kind === 'predicate' && isEmptinessOperator(node.operator)) return `(${emptiness(node)})`;
407
- if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value, negated)})`;
408
- 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 ')})`;
409
537
  };
410
538
  let condition = where(plan.where);
411
539
  if (extra) {
@@ -465,7 +593,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
465
593
  }
466
594
  // Laterals follow every join, so their expressions can read the root row and any joined alias.
467
595
  const lateralSQL = [...laterals.values()].map((item) => ` CROSS JOIN LATERAL (SELECT ${item.sql} AS value OFFSET 0) ${item.alias}`).join('');
468
- 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}`;
469
597
  if (plan.mode === 'aggregate') text = `SELECT * FROM (${text}) aggregated`;
470
598
  if (order.length) text += ` ORDER BY ${order.join(', ')}`;
471
599
  if (plan.pagination) text += ` LIMIT ${bind(plan.pagination.limit, 'bigint')} OFFSET ${bind(plan.pagination.offset, 'bigint')}`;
package/types/index.d.ts CHANGED
@@ -22,6 +22,8 @@ export type {
22
22
  EnvelopSchemaPlugin,
23
23
  FieldValidations,
24
24
  FieldValidator,
25
+ FilterCondition,
26
+ FilterGroup,
25
27
  ItemValidators,
26
28
  MiddlewareContext,
27
29
  MutationLimitsOptions,
@@ -33,6 +35,9 @@ export type {
33
35
  StateMachine,
34
36
  TypePermissions,
35
37
  TypeScopes,
38
+ ViewOptions,
39
+ ViewParam,
40
+ ViewParamType,
36
41
  VirtualFieldDeclaration,
37
42
  VirtualFieldOptions,
38
43
  } from '@simtlix/simfinity-core';
@@ -242,8 +247,21 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
242
247
  * for a native array; jsonb members as text without a cast, and jsonb lists through a `jsonb_typeof` guard; an
243
248
  * embedded object as missing, JSON `null`, or every stored member empty; and owned objects and lists through their
244
249
  * 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. Declaring the two
246
- * operators does not lift the UNSUPPORTED_QUERY (400) that emptiness through references and collections gets.
250
+ * the positive form of a leaf, which a btree can serve, and the `NOT` of an object or owned form.
251
+ *
252
+ * The plugin also declares `queryFeatures.referenceEmptiness`. A client's condition reads as missing every record that
253
+ * the target's `find` scope hides. The references it crosses share the plain `LEFT JOIN` of other conditions while
254
+ * the scopes crossed so far add nothing; from the first scope that adds conditions on, each crossing is a
255
+ * `LEFT JOIN LATERAL (... LIMIT 1)` that admits the target only where its own scope admits it, and later hops
256
+ * correlate to that join even when their own scope adds nothing. A reference end or member whose scope adds
257
+ * conditions is a `NOT EXISTS` subquery with that scope (`EXISTS` for `IS_NOT_EMPTY`); one whose scope adds nothing
258
+ * compiles to `"ref" IS NULL` (`IS NOT NULL`), as a trusted one does. A collection end or member is always a
259
+ * `NOT EXISTS` subquery (`EXISTS`) on the child's connection column, with the scope only when it adds conditions.
260
+ * Scope values are parameters, and the target's `id` after a reference is its uuid key, under the null rule.
261
+ * Conditions from middleware and scopes read stored data: a reference is empty when its column is `NULL`, which its
262
+ * foreign key, validated and not deferred, keeps exact, a collection through `NOT EXISTS` on the child's column, and
263
+ * a leaf through a plain `LEFT JOIN`. A session that defers the foreign keys (`SET CONSTRAINTS ... DEFERRED`) can see
264
+ * a dangling reference as not empty in the `"ref" IS NULL` form, trusted or client, until it commits.
247
265
  */
248
266
  export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
249
267
  PostgresConfiguration,
@@ -279,6 +297,7 @@ export const getRegistrations: PostgresRuntime['getRegistrations'];
279
297
  export const use: PostgresRuntime['use'];
280
298
  export const registerMutation: PostgresRuntime['registerMutation'];
281
299
  export const registerCustomQuery: PostgresRuntime['registerCustomQuery'];
300
+ export const registerView: PostgresRuntime['registerView'];
282
301
  export const saveObject: PostgresRuntime['saveObject'];
283
302
  export const preventCreatingCollection: PostgresRuntime['preventCreatingCollection'];
284
303
  export const withTransaction: PostgresRuntime['withTransaction'];