@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 +7 -3
- package/package.json +3 -3
- package/src/index.js +1 -1
- package/src/plugin.js +6 -3
- package/src/query/compiler.js +225 -15
- package/types/index.d.ts +23 -0
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.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.
|
|
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.
|
|
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.
|
|
31
|
-
"@simtlix/simfinity-sql": "3.
|
|
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
|
|
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
|
-
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 },
|
package/src/query/compiler.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
|
|
2
|
-
import {
|
|
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,
|
|
130
|
-
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 };
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
323
|
-
|
|
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 `(${
|
|
326
|
-
|
|
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 ${
|
|
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'];
|