@simtlix/simfinity-postgres 3.8.0 → 3.10.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.8.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.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.
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.8.0 @modelcontextprotocol/sdk@^1.31.0
65
+ npm install @simtlix/simfinity-mcp@3.10.0 @modelcontextprotocol/sdk@^1.31.0
66
66
  ```
67
67
 
68
68
  ```javascript
@@ -77,6 +77,10 @@ Entity identities and references use UUIDs. Scalars use native PostgreSQL types,
77
77
 
78
78
  Queries support nested scalar/reference paths, logical filter groups, scopes, sort, pagination, count, and grouped `SUM`, `COUNT`, `AVG`, `MIN`, and `MAX` facts. The PostgreSQL implementation preserves root multiplicity when joining collection paths.
79
79
 
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
+
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.
83
+
80
84
  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).
81
85
 
82
86
  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.8.0",
3
+ "version": "3.10.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.8.0",
31
- "@simtlix/simfinity-sql": "3.8.0",
30
+ "@simtlix/simfinity-core": "3.10.0",
31
+ "@simtlix/simfinity-sql": "3.10.0",
32
32
  "pg": "^8.16.3"
33
33
  },
34
34
  "peerDependencies": {
package/src/codecs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getNamedType, isEnumType } from 'graphql';
2
2
  import { SimfinityError } from '@simtlix/simfinity-core';
3
+ import { isCaseFoldUnsupported } from './query/case-fold.js';
3
4
 
4
5
  export const storageTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone' };
5
6
  export const invalidValue = (message) => { throw new SimfinityError(message, 'INVALID_FILTER_VALUE', 400); };
@@ -69,10 +70,14 @@ export const isRetryableDatabaseError = (error) => ['40001', '40P01'].includes(e
69
70
  // An application error that only has a `code`, such as ECONNREFUSED or a domain code, is not one.
70
71
  export const isPostgresError = (error) => typeof error?.code === 'string' && /^[0-9A-Z]{5}$/.test(error.code) && typeof error.severity === 'string';
71
72
 
73
+ // A statement that folds case on a database that cannot evaluate the fold (see isCaseFoldUnsupported): the request
74
+ // uses an operator that this database cannot run, while every other operation keeps working.
75
+ const CASE_FOLD_UNSUPPORTED = ['ILIKE, ISTARTS_WITH, IENDS_WITH and IEQ need a UTF8 database with the ICU collation und-x-icu', 'UNSUPPORTED_QUERY', 400];
76
+
72
77
  /** Maps PostgreSQL errors (and exhausted retries) without SQL details; returns every other value unchanged. */
73
78
  export const normalizeDatabaseError = (error) => {
74
79
  if (error instanceof SimfinityError || !(isPostgresError(error) || isRetryableDatabaseError(error))) return error;
75
- const normalized = new SimfinityError(...(databaseErrors[error.code] || ['Database operation failed', 'DATABASE_ERROR', 500]));
80
+ const normalized = new SimfinityError(...(isCaseFoldUnsupported(error) ? CASE_FOLD_UNSUPPORTED : databaseErrors[error.code] || ['Database operation failed', 'DATABASE_ERROR', 500]));
76
81
  // Kept for server logging, but non-enumerable as on a native Error, so serializing or spreading the
77
82
  // client-facing error never copies the driver's detail, table or constraint.
78
83
  Object.defineProperty(normalized, 'cause', { value: error, writable: true, configurable: true, enumerable: false });
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { compileDatabaseSchema as compileSchemaSQL } from './schema/ddl.js';
5
5
  import { initializeDatabase as initializeSchema } from './schema/initialize.js';
6
6
 
7
7
  export { postgresPlugin } from './plugin.js';
8
+ export { caseFoldExpression } from './query/case-fold.js';
8
9
  export {
9
10
  auth,
10
11
  buildErrorFormatter,
@@ -19,7 +20,7 @@ export {
19
20
  /** Each instance permanently binds one pool/schema; getModel exposes PostgreSQL native helpers. */
20
21
  export const createPostgres = (options) => createSQL({ plugin: postgresPlugin(options) });
21
22
  const defaultInstance = createPostgres();
22
- 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;
23
24
 
24
25
  /** Preserve the foundation low-level API while allowing initialization of the configured default instance. */
25
26
  export const initializeDatabase = (poolOrOptions, description, options) => description
package/src/plugin.js CHANGED
@@ -5,6 +5,7 @@ import { describeRelationalSchema } from './schema/describe.js';
5
5
  import { initializeDatabase } from './schema/initialize.js';
6
6
  import { compileDatabaseSchema } from './schema/ddl.js';
7
7
  import { compileChildrenQuery, compileQuery } from './query/compiler.js';
8
+ import { markCaseFoldFailure } from './query/case-fold.js';
8
9
  import { compileRecord, compileRecordBatch, MAX_RECORD_BATCH_VALUES } from './record-statements.js';
9
10
  import { castId, encodeScalar, decodeScalar, encodeParameter, isRetryableDatabaseError, normalizeDatabaseError } from './codecs.js';
10
11
 
@@ -17,6 +18,13 @@ export const postgresPlugin = (options) => ({
17
18
  // singleStatementReads: one statement reads one snapshot at any isolation level, so a read without owned
18
19
  // tables needs no repeatable-read transaction.
19
20
  capabilities: ['transactions', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique', 'singleStatementReads'],
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.
24
+ queryFeatures: {
25
+ 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'],
26
+ compileQuery,
27
+ },
20
28
  naming: { validateIdentifier: identifier, generatedName },
21
29
  // The runtime passes its bound configuration: createPostgres(options) or configure(value).
22
30
  describeSchema: (plan, configuration) => describeRelationalSchema(plan, { refreshTriggers: configuration?.refreshTriggers, ownerIndexes: configuration?.ownerIndexes }),
@@ -37,7 +45,15 @@ export const postgresPlugin = (options) => ({
37
45
  assertConfiguration(configuration) {
38
46
  if (!configuration?.pool?.connect || !configuration.pool.query) throw new SimfinityError('Configure a pg.Pool before creating the schema', 'DATABASE_NOT_CONFIGURED', 500);
39
47
  },
40
- query: (configuration, statement, client) => (client || configuration.pool).query(statement.text, statement.values?.map(encodeParameter)),
48
+ // The error of a statement that folds case is marked, unchanged, so that normalizeError reports a database that
49
+ // cannot evaluate the fold as UNSUPPORTED_QUERY.
50
+ query: async (configuration, statement, client) => {
51
+ try {
52
+ return await (client || configuration.pool).query(statement.text, statement.values?.map(encodeParameter));
53
+ } catch (error) {
54
+ throw markCaseFoldFailure(statement, error);
55
+ }
56
+ },
41
57
  acquire: (configuration) => configuration.pool.connect(),
42
58
  begin: (client) => client.query('BEGIN ISOLATION LEVEL REPEATABLE READ'),
43
59
  // PostgreSQL answers COMMIT with ROLLBACK, and no error, when a statement in the transaction failed
@@ -0,0 +1,88 @@
1
+ // The case fold of the case-insensitive text operators (ILIKE, ISTARTS_WITH, IENDS_WITH and IEQ), the shared
2
+ // contract of #108 section 3.1: the Unicode simple case foldings that MongoDB's case-insensitive regular expressions
3
+ // apply. lower() under ICU's root collation applies them to every script the same way on every server, whatever the
4
+ // database's default collation (text paths are `C` columns, under which lower() folds ASCII only), except for the
5
+ // lowercase variants below, which it keeps and simple folding maps to another lowercase letter; translate() then
6
+ // maps them. lower() also turns a capital sigma that ends a word into ς and any other into σ, and the value is folded
7
+ // apart from the stored text, so mapping ς to σ also makes a value fold as it does inside the stored text: a value
8
+ // that starts or ends with Σ finds every row that its case-sensitive form finds. The fold is one inline expression,
9
+ // without a database function, so it needs no schema object and no migration, and an application index serves the
10
+ // operators only on exactly this expression, which caseFoldExpression writes.
11
+
12
+ /**
13
+ * The one-to-one simple case foldings (CaseFolding.txt, status C) that ICU lower() does not apply and MongoDB 8.0
14
+ * does, found by comparing both over every code point with a case mapping, on PostgreSQL 16.15 with an ICU of Unicode
15
+ * 16 and MongoDB 8.0.32: with them, the two differ only on letters newer than MongoDB's Unicode tables. Changing the
16
+ * list changes every statement and the expression that application indexes must repeat, so it is part of the release
17
+ * contract.
18
+ */
19
+ const CASE_FOLD_MAPPINGS = Object.freeze([
20
+ ['\u00B5', '\u03BC'], // MICRO SIGN, GREEK SMALL LETTER MU
21
+ ['\u017F', '\u0073'], // LATIN SMALL LETTER LONG S, LATIN SMALL LETTER S
22
+ ['\u0345', '\u03B9'], // COMBINING GREEK YPOGEGRAMMENI, GREEK SMALL LETTER IOTA
23
+ ['\u03C2', '\u03C3'], // GREEK SMALL LETTER FINAL SIGMA, GREEK SMALL LETTER SIGMA
24
+ ['\u03D0', '\u03B2'], // GREEK BETA SYMBOL, GREEK SMALL LETTER BETA
25
+ ['\u03D1', '\u03B8'], // GREEK THETA SYMBOL, GREEK SMALL LETTER THETA
26
+ ['\u03D5', '\u03C6'], // GREEK PHI SYMBOL, GREEK SMALL LETTER PHI
27
+ ['\u03D6', '\u03C0'], // GREEK PI SYMBOL, GREEK SMALL LETTER PI
28
+ ['\u03F0', '\u03BA'], // GREEK KAPPA SYMBOL, GREEK SMALL LETTER KAPPA
29
+ ['\u03F1', '\u03C1'], // GREEK RHO SYMBOL, GREEK SMALL LETTER RHO
30
+ ['\u03F5', '\u03B5'], // GREEK LUNATE EPSILON SYMBOL, GREEK SMALL LETTER EPSILON
31
+ ['\u1C80', '\u0432'], // CYRILLIC SMALL LETTER ROUNDED VE, CYRILLIC SMALL LETTER VE
32
+ ['\u1C81', '\u0434'], // CYRILLIC SMALL LETTER LONG-LEGGED DE, CYRILLIC SMALL LETTER DE
33
+ ['\u1C82', '\u043E'], // CYRILLIC SMALL LETTER NARROW O, CYRILLIC SMALL LETTER O
34
+ ['\u1C83', '\u0441'], // CYRILLIC SMALL LETTER WIDE ES, CYRILLIC SMALL LETTER ES
35
+ ['\u1C84', '\u0442'], // CYRILLIC SMALL LETTER TALL TE, CYRILLIC SMALL LETTER TE
36
+ ['\u1C85', '\u0442'], // CYRILLIC SMALL LETTER THREE-LEGGED TE, CYRILLIC SMALL LETTER TE
37
+ ['\u1C86', '\u044A'], // CYRILLIC SMALL LETTER TALL HARD SIGN, CYRILLIC SMALL LETTER HARD SIGN
38
+ ['\u1C87', '\u0463'], // CYRILLIC SMALL LETTER TALL YAT, CYRILLIC SMALL LETTER YAT
39
+ ['\u1C88', '\uA64B'], // CYRILLIC SMALL LETTER UNBLENDED UK, CYRILLIC SMALL LETTER MONOGRAPH UK
40
+ ['\u1E9B', '\u1E61'], // LATIN SMALL LETTER LONG S WITH DOT ABOVE, LATIN SMALL LETTER S WITH DOT ABOVE
41
+ ['\u1FBE', '\u03B9'], // GREEK PROSGEGRAMMENI, GREEK SMALL LETTER IOTA
42
+ ]);
43
+
44
+ const COLLATION = '"pg_catalog"."und-x-icu"';
45
+ // No character of either list is a quote or a backslash, so both are plain string literals.
46
+ const FROM = `'${CASE_FOLD_MAPPINGS.map(([from]) => from).join('')}'`;
47
+ const TO = `'${CASE_FOLD_MAPPINGS.map(([, to]) => to).join('')}'`;
48
+
49
+ /**
50
+ * The SQL text that folds `sqlExpression` for the case-insensitive text operators, exactly as the compiler writes it on
51
+ * both sides of each comparison: `translate(lower((<expression>) COLLATE "pg_catalog"."und-x-icu"), '<22 characters>',
52
+ * '<their simple case foldings>')`. Applications build their expression indexes with it, such as
53
+ * `CREATE INDEX ... (${caseFoldExpression('"name"')} text_pattern_ops)`. The expression is trusted SQL, inserted as
54
+ * given: quote identifiers yourself and never pass request input. It goes in parentheses, because COLLATE binds more
55
+ * tightly than `->>` and every other binary operator: without them, `"details" ->> 'label'` would collate only
56
+ * 'label', and an index on that expression would never serve the operators. PostgreSQL keeps no trace of parentheses
57
+ * in the parse tree, so an expression gives the same index expression with or without parentheses of its own.
58
+ */
59
+ export const caseFoldExpression = (sqlExpression) => {
60
+ if (typeof sqlExpression !== 'string' || !sqlExpression.trim()) throw new TypeError('caseFoldExpression expects a non-empty SQL expression string');
61
+ return `translate(lower((${sqlExpression}) COLLATE ${COLLATION}), ${FROM}, ${TO})`;
62
+ };
63
+
64
+ // Each statement that folds case contains the list of characters, which a GraphQL name, a generated identifier or a
65
+ // compiled literal never contains, since values are bound parameters.
66
+ const foldsCase = (statement) => typeof statement?.text === 'string' && statement.text.includes(FROM);
67
+ // The errors of statements that fold case, which normalizeDatabaseError reports as unsupported when the database
68
+ // cannot evaluate the fold.
69
+ const failures = new WeakSet();
70
+
71
+ /**
72
+ * Records that `error`, which the driver raised for `statement`, comes from a statement that folds case, and returns
73
+ * it unchanged, so that retries and normalization see the driver's own error.
74
+ */
75
+ export const markCaseFoldFailure = (statement, error) => {
76
+ if (error !== null && typeof error === 'object' && foldsCase(statement)) failures.add(error);
77
+ return error;
78
+ };
79
+
80
+ /**
81
+ * Whether the database cannot evaluate the fold of a statement that failed: one whose server encoding lacks the
82
+ * characters of the fold (22P05, untranslatable character, such as LATIN1, which fails the statement's text before
83
+ * any row is read, whatever the value), or a server without the ICU collation und-x-icu, built without ICU or with a
84
+ * database encoding that ICU does not support, such as SQL_ASCII (42704, undefined object). Compiled statements
85
+ * cannot raise either code otherwise in a UTF8 database with ICU: the collation is their only object that can be
86
+ * missing, and the database stores no text that UTF8 cannot convert.
87
+ */
88
+ export const isCaseFoldUnsupported = (error) => failures.has(error) && ['22P05', '42704'].includes(error.code);
@@ -1,9 +1,19 @@
1
1
  import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
2
+ import {
3
+ describeEmptiness, describeModelEmptiness, isEmptinessOperator, textQueryOperator, validateTextFilterValue,
4
+ } from '@simtlix/simfinity-core/internal/query-operators';
2
5
  import { identifier as q, literal, qualified } from '../schema/sql.js';
3
6
  import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
7
+ import { caseFoldExpression as fold } from './case-fold.js';
4
8
 
5
9
  const unsupported = (message) => { throw new SimfinityError(message, 'UNSUPPORTED_QUERY', 400); };
6
10
 
11
+ // The case-insensitive text operators compare the case fold of both sides (see case-fold.js), so an application
12
+ // index serves them only on that expression, which caseFoldExpression writes.
13
+ // LIKE's default escape character is the backslash, so escaping it, % and _ leaves only literal characters.
14
+ const likeLiteral = (value) => value.replace(/[\\%_]/g, '\\$&');
15
+ const likePatterns = { contains: (value) => `%${likeLiteral(value)}%`, prefix: (value) => `${likeLiteral(value)}%`, suffix: (value) => `%${likeLiteral(value)}` };
16
+
7
17
  // Alias of each per-parent statement in the batched children statement. The per-parent statement only uses t<n>,
8
18
  // f<n> and short subquery aliases, so it never shadows this one.
9
19
  const CHILDREN = 'simfinity_children';
@@ -80,6 +90,12 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
80
90
  }
81
91
  return joins.get(key).alias;
82
92
  };
93
+ // The owned table that stores `field` of the row `context`, or undefined when the row stores it; values inside
94
+ // jsonb never own tables.
95
+ const ownedOf = (context, field) => context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
96
+ // The LEFT JOIN of an owned single object, one per path, which filters and the emptiness forms share. A missed
97
+ // 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'`);
83
99
  const arrayFrom = (vector, expression) => `ARRAY(SELECT ${expression} FROM ${vector.from} WHERE ${vector.where})`;
84
100
  const jsonItems = (expression, missing = '[]') => `jsonb_array_elements(CASE WHEN jsonb_typeof(${expression}) = 'array' THEN ${expression} ELSE '${missing}'::jsonb END)`;
85
101
  const helper = (name, ...args) => `${qualified(database.schema, `__simfinity_${name}`)}(${args.join(', ')})`;
@@ -138,8 +154,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
138
154
  const state = `${col(ownerAlias, owned.ownership.stateColumn)} = 'present'`;
139
155
  const presence = (name) => owned.ownership.nullableItems && usage !== 'sort' ? ` AND ${col(name, '__item_present')}` : '';
140
156
  if (!field.list && !context.vector) {
141
- const joined = join(key, owned.name, (name) => `${col(name, '__owner_id')} = ${ownerId} AND ${state}`);
142
- context = { table: owned, alias: joined, json: null, vector: null };
157
+ context = { table: owned, alias: joinOwned(key, context, owned), json: null, vector: null };
143
158
  } else {
144
159
  const name = alias();
145
160
  const stored = alias();
@@ -207,14 +222,15 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
207
222
  const sql = arrayFrom(vector, `${name}.value`);
208
223
  return { sql: type === 'text' ? `(${sql}) COLLATE "pg_catalog"."C"` : sql, field, type: dateNumeric ? 'double precision' : type, list: true, vector: true, dateNumeric, sortKey };
209
224
  }
210
- let sql = context.vector ? arrayFrom(context.vector, expression) : expression;
211
- if (type === 'text') sql = `(${sql}) COLLATE "pg_catalog"."C"`;
225
+ // `uncollated` is the value before the C collation, which the case-insensitive text operators replace.
226
+ const uncollated = context.vector ? arrayFrom(context.vector, expression) : expression;
227
+ const sql = type === 'text' ? `(${uncollated}) COLLATE "pg_catalog"."C"` : uncollated;
212
228
  // A NOT NULL column of the root row has no nulls to place, so its sort needs no NULLS clause and a
213
229
  // default-order btree (the primary key or a declared index) can return it in order. Joined and
214
230
  // embedded paths can be NULL even for NOT NULL columns, so they keep the clause.
215
231
  const notNull = !context.json && !context.vector && !field.list && context.alias === 't0'
216
232
  && context.table.columns.find((item) => item.name === (identity ? 'id' : field.storageName))?.nullable === false;
217
- return { sql, field: identity ? { ...field, scalar: 'ID' } : field, type: dateNumeric ? 'double precision' : type, list: field.list || !!context.vector, vector: !!context.vector, dateNumeric, sortKey, notNull };
233
+ 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 };
218
234
  }
219
235
  }
220
236
  unsupported('Invalid query path');
@@ -266,19 +282,128 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
266
282
  }
267
283
  if (operator === 'BTW') return `(${predicate(expression, 'GTE', value[0], negated)} AND ${predicate(expression, 'LTE', value[1], negated)})`;
268
284
  if (value == null || Array.isArray(value)) invalidValue(`${operator} requires a non-null scalar value`);
285
+ const text = textQueryOperator(operator);
286
+ if (text) return textPredicate(expression, operator, text, value, negated);
287
+ const ordering = { LT: '<', LTE: '<=', GT: '>', GTE: '>=' }[operator];
288
+ if (!ordering && operator !== 'LIKE') unsupported(`Unsupported filter operator ${operator}`);
269
289
  const parameter = scalarBind(value);
270
290
  const compare = (left) => {
271
291
  if (operator === 'LIKE') {
272
292
  return `strpos(${left}, ${parameter}) > 0`;
273
293
  }
274
- return `${left} ${{ LT: '<', LTE: '<=', GT: '>', GTE: '>=' }[operator]} ${parameter}`;
294
+ return `${left} ${ordering} ${parameter}`;
275
295
  };
276
296
  if (list) return `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})`;
277
297
  return negated ? `COALESCE(${compare(sql)}, FALSE)` : compare(sql);
278
298
  };
279
- // A negation reaches every predicate below it, including those nested in AND/OR groups.
299
+ /**
300
+ * The literal text operators (ILIKE, STARTS_WITH, ISTARTS_WITH, ENDS_WITH, IENDS_WITH and IEQ) on a String leaf or
301
+ * list, where some element must match. Values stay bound parameters. The case-sensitive forms, STARTS_WITH and
302
+ * ENDS_WITH, compare code points with starts_with and right under the C collation of text paths, so a column's
303
+ * btree serves STARTS_WITH. The case-insensitive forms compare the case fold of both sides (see case-fold.js): IEQ
304
+ * with =, the others with LIKE and a bound pattern whose metacharacters are escaped, so that a btree on the folded
305
+ * column with text_pattern_ops serves IEQ and ISTARTS_WITH, and a trigram index on it serves ILIKE, ISTARTS_WITH and
306
+ * IENDS_WITH.
307
+ */
308
+ const textPredicate = ({ sql, uncollated, field, list }, operator, { match, ignoreCase }, value, negated) => {
309
+ // createQueryPlan has validated the value; a plan built by hand gets the same INVALID_FILTER_VALUE.
310
+ validateTextFilterValue(operator, value, field.scalar);
311
+ let compare;
312
+ if (!ignoreCase) {
313
+ const parameter = bind(value, 'text');
314
+ compare = match === 'prefix'
315
+ ? (left) => `starts_with(${left}, ${parameter})`
316
+ : (left) => `right(${left}, char_length(${parameter})) = ${parameter}`;
317
+ } else if (match === 'exact') {
318
+ const parameter = bind(value, 'text');
319
+ compare = (left) => `${fold(left)} = ${fold(parameter)}`;
320
+ } else {
321
+ const pattern = bind(likePatterns[match](value), 'text');
322
+ compare = (left) => `${fold(left)} LIKE ${fold(pattern)}`;
323
+ }
324
+ // fold puts its argument in parentheses, so the uncollated value goes in as it is.
325
+ if (list) return `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})`;
326
+ const left = ignoreCase ? uncollated : sql;
327
+ return negated ? `COALESCE(${compare(left)}, FALSE)` : compare(left);
328
+ };
329
+ /**
330
+ * The IS_EMPTY form of `field`, stored on the row or jsonb value `context`, by its emptiness shape, or with `empty`
331
+ * 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.
337
+ */
338
+ const emptyForm = (context, field, shape, empty = true) => {
339
+ const compare = empty ? '=' : '>';
340
+ const negate = (form) => (empty ? form : `NOT ${form}`);
341
+ const leaf = (value) => {
342
+ if (shape.rule === 'string') return empty ? `(${value} IS NULL OR ${value} = '')` : `(${value} IS NOT NULL AND ${value} <> '')`;
343
+ return `${value} IS ${empty ? '' : 'NOT '}NULL`;
344
+ };
345
+ // The CASE keeps jsonb_array_length from failing (22023) on a value that is not an array.
346
+ const jsonList = (json) => `COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(${json}) = 'array' THEN ${json} END), 0) ${compare} 0`;
347
+ // An embedded type without stored members is always empty.
348
+ const members = (row) => {
349
+ const forms = shape.members.map((member) => {
350
+ const stored = field.fields?.find((item) => item.name === member.name);
351
+ if (!stored) unsupported('Invalid query path');
352
+ return emptyForm(row, stored, member.shape);
353
+ });
354
+ return forms.length ? `(${forms.join(' AND ')})` : 'TRUE';
355
+ };
356
+ const jsonObject = (json) => negate(`(${json} IS NULL OR ${json} = 'null'::jsonb OR ${members({ table: null, alias: null, json })})`);
357
+ if (context.json) {
358
+ const json = `(${context.json} -> ${literal(field.storageName)})`;
359
+ if (shape.rule === 'object') return jsonObject(json);
360
+ if (shape.rule === 'list') return jsonList(json);
361
+ return leaf(`(${context.json} ->> ${literal(field.storageName)})`);
362
+ }
363
+ const owned = ownedOf(context, field);
364
+ if (owned) {
365
+ // A missed join leaves the state NULL, which is not 'present'.
366
+ const state = `${col(context.alias, owned.ownership.stateColumn)} IS DISTINCT FROM 'present'`;
367
+ const name = alias();
368
+ const rows = `SELECT 1 FROM ${qualified(database.schema, owned.name)} ${name} WHERE ${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])}`;
369
+ if (shape.rule === 'list') return negate(`(${state} OR NOT EXISTS (${rows}))`);
370
+ // An object marked present without a row, which the owned-shape check rejects, is empty too.
371
+ return negate(`(${state} OR NOT EXISTS (${rows} AND NOT ${members({ table: owned, alias: name, json: null })}))`);
372
+ }
373
+ // The record identity is the uuid primary key, whatever scalar it is declared with.
374
+ const identity = !context.table.ownership && ['id', '_id'].includes(field.name);
375
+ const column = col(context.alias, identity ? 'id' : field.storageName);
376
+ if (field.kind === 'embedded') return shape.rule === 'object' ? jsonObject(column) : jsonList(column);
377
+ if (shape.rule === 'list') return `COALESCE(cardinality(${column}), 0) ${compare} 0`;
378
+ return leaf(column);
379
+ };
380
+ /**
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.
386
+ */
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;
391
+ const shape = gqltype ? describeEmptiness(gqltype, parts, options) : describeModelEmptiness(steps, options);
392
+ let context = { table: root, alias: 't0', json: null };
393
+ 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) };
399
+ });
400
+ return emptyForm(context, steps.at(-1), shape, operator === 'IS_EMPTY');
401
+ };
402
+ // 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.
280
404
  const where = (node, negated = false) => {
281
405
  if (!node) return 'TRUE';
406
+ if (node.kind === 'predicate' && isEmptinessOperator(node.operator)) return `(${emptiness(node)})`;
282
407
  if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value, negated)})`;
283
408
  return `(${node.terms.map((term) => where(term, negated)).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
284
409
  };
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,
@@ -230,7 +231,20 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
230
231
  initializeDatabase(options?: InitializationOptions): Promise<InitializationResult>;
231
232
  withTransaction<T>(session: PostgresSession | null | undefined, callback: (session: PostgresSession) => Promise<T> | T): Promise<T>;
232
233
  }
233
- /** PostgreSQL 15+ plugin for createSQL; omitted options allow later runtime.configure. */
234
+ /**
235
+ * PostgreSQL 15+ plugin for createSQL; omitted options allow later runtime.configure. Its `queryFeatures` lists every
236
+ * `QLOperator` value. The case-insensitive text operators fold case with {@link caseFoldExpression}, which needs a UTF8
237
+ * database with the ICU collation `und-x-icu`: on another database, such as one in LATIN1 or on a server built without
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. Declaring the two
246
+ * operators does not lift the UNSUPPORTED_QUERY (400) that emptiness through references and collections gets.
247
+ */
234
248
  export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
235
249
  PostgresConfiguration,
236
250
  DatabaseQueryable,
@@ -243,6 +257,17 @@ export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
243
257
  }
244
258
  >;
245
259
  export function createPostgres(options?: PostgresConfiguration): PostgresRuntime;
260
+ /**
261
+ * The SQL text with which ILIKE, ISTARTS_WITH, IENDS_WITH and IEQ fold both sides of their comparison, for an SQL
262
+ * expression such as a quoted column name or a jsonb member: `translate(lower((<expression>) COLLATE
263
+ * "pg_catalog"."und-x-icu"), '<22 characters>', '<their simple case foldings>')`, the Unicode simple case foldings that
264
+ * MongoDB's case-insensitive matching applies. Indexes serve these operators only on exactly this expression, so build
265
+ * them with it, for example `CREATE INDEX ... (${caseFoldExpression('"name"')} text_pattern_ops)` or
266
+ * `CREATE INDEX ... (${caseFoldExpression('"details" ->> \'label\'')})`. The expression is inserted as given, in
267
+ * parentheses, so that COLLATE applies to all of it; pass trusted SQL, never request input. Throws a TypeError for
268
+ * anything other than a non-empty string.
269
+ */
270
+ export function caseFoldExpression(sqlExpression: string): string;
246
271
  export const configure: PostgresRuntime['configure'];
247
272
  export const connect: PostgresRuntime['connect'];
248
273
  export const addNoEndpointType: PostgresRuntime['addNoEndpointType'];
@@ -253,6 +278,7 @@ export const getInputType: PostgresRuntime['getInputType'];
253
278
  export const getRegistrations: PostgresRuntime['getRegistrations'];
254
279
  export const use: PostgresRuntime['use'];
255
280
  export const registerMutation: PostgresRuntime['registerMutation'];
281
+ export const registerCustomQuery: PostgresRuntime['registerCustomQuery'];
256
282
  export const saveObject: PostgresRuntime['saveObject'];
257
283
  export const preventCreatingCollection: PostgresRuntime['preventCreatingCollection'];
258
284
  export const withTransaction: PostgresRuntime['withTransaction'];