@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 +6 -4
- package/package.json +3 -3
- package/src/index.js +1 -1
- package/src/plugin.js +5 -2
- package/src/query/compiler.js +167 -39
- package/types/index.d.ts +21 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
-
"@simtlix/simfinity-sql": "3.
|
|
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
|
|
23
|
-
//
|
|
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 },
|
package/src/query/compiler.js
CHANGED
|
@@ -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,
|
|
138
|
-
let context = { table:
|
|
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 ===
|
|
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
|
|
333
|
-
* jsonb member, never cast, since a cast can fail; a list a native array column, a jsonb list, or an owned
|
|
334
|
-
* whose null items are rows too, so `[null]` is not empty; an object a jsonb value or an owned single object
|
|
335
|
-
* stored members are all empty
|
|
336
|
-
*
|
|
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,
|
|
382
|
-
* its path is stored, not from path(), whose values cast jsonb members and
|
|
383
|
-
* rejects paths that end at an embedded field. The checks of createQueryPlan run
|
|
384
|
-
* entity's GraphQL type or on the field descriptions, so a plan built by hand gets the
|
|
385
|
-
* only single embedded objects before the last field
|
|
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 = (
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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')
|
|
408
|
-
|
|
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 ${
|
|
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.
|
|
246
|
-
*
|
|
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'];
|