@simtlix/simfinity-postgres 3.8.0 → 3.9.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,7 +31,7 @@ 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.9.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
35
35
 
36
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.
37
37
 
@@ -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.9.0 @modelcontextprotocol/sdk@^1.31.0
66
66
  ```
67
67
 
68
68
  ```javascript
@@ -77,6 +77,8 @@ 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
+
80
82
  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
83
 
82
84
  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.9.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.9.0",
31
+ "@simtlix/simfinity-sql": "3.9.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,
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'],
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,17 @@
1
1
  import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
2
+ import { textQueryOperator, validateTextFilterValue } from '@simtlix/simfinity-core/internal/query-operators';
2
3
  import { identifier as q, literal, qualified } from '../schema/sql.js';
3
4
  import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
5
+ import { caseFoldExpression as fold } from './case-fold.js';
4
6
 
5
7
  const unsupported = (message) => { throw new SimfinityError(message, 'UNSUPPORTED_QUERY', 400); };
6
8
 
9
+ // The case-insensitive text operators compare the case fold of both sides (see case-fold.js), so an application
10
+ // index serves them only on that expression, which caseFoldExpression writes.
11
+ // LIKE's default escape character is the backslash, so escaping it, % and _ leaves only literal characters.
12
+ const likeLiteral = (value) => value.replace(/[\\%_]/g, '\\$&');
13
+ const likePatterns = { contains: (value) => `%${likeLiteral(value)}%`, prefix: (value) => `${likeLiteral(value)}%`, suffix: (value) => `%${likeLiteral(value)}` };
14
+
7
15
  // Alias of each per-parent statement in the batched children statement. The per-parent statement only uses t<n>,
8
16
  // f<n> and short subquery aliases, so it never shadows this one.
9
17
  const CHILDREN = 'simfinity_children';
@@ -207,14 +215,15 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
207
215
  const sql = arrayFrom(vector, `${name}.value`);
208
216
  return { sql: type === 'text' ? `(${sql}) COLLATE "pg_catalog"."C"` : sql, field, type: dateNumeric ? 'double precision' : type, list: true, vector: true, dateNumeric, sortKey };
209
217
  }
210
- let sql = context.vector ? arrayFrom(context.vector, expression) : expression;
211
- if (type === 'text') sql = `(${sql}) COLLATE "pg_catalog"."C"`;
218
+ // `uncollated` is the value before the C collation, which the case-insensitive text operators replace.
219
+ const uncollated = context.vector ? arrayFrom(context.vector, expression) : expression;
220
+ const sql = type === 'text' ? `(${uncollated}) COLLATE "pg_catalog"."C"` : uncollated;
212
221
  // A NOT NULL column of the root row has no nulls to place, so its sort needs no NULLS clause and a
213
222
  // default-order btree (the primary key or a declared index) can return it in order. Joined and
214
223
  // embedded paths can be NULL even for NOT NULL columns, so they keep the clause.
215
224
  const notNull = !context.json && !context.vector && !field.list && context.alias === 't0'
216
225
  && 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 };
226
+ 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
227
  }
219
228
  }
220
229
  unsupported('Invalid query path');
@@ -266,16 +275,50 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
266
275
  }
267
276
  if (operator === 'BTW') return `(${predicate(expression, 'GTE', value[0], negated)} AND ${predicate(expression, 'LTE', value[1], negated)})`;
268
277
  if (value == null || Array.isArray(value)) invalidValue(`${operator} requires a non-null scalar value`);
278
+ const text = textQueryOperator(operator);
279
+ if (text) return textPredicate(expression, operator, text, value, negated);
280
+ const ordering = { LT: '<', LTE: '<=', GT: '>', GTE: '>=' }[operator];
281
+ if (!ordering && operator !== 'LIKE') unsupported(`Unsupported filter operator ${operator}`);
269
282
  const parameter = scalarBind(value);
270
283
  const compare = (left) => {
271
284
  if (operator === 'LIKE') {
272
285
  return `strpos(${left}, ${parameter}) > 0`;
273
286
  }
274
- return `${left} ${{ LT: '<', LTE: '<=', GT: '>', GTE: '>=' }[operator]} ${parameter}`;
287
+ return `${left} ${ordering} ${parameter}`;
275
288
  };
276
289
  if (list) return `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})`;
277
290
  return negated ? `COALESCE(${compare(sql)}, FALSE)` : compare(sql);
278
291
  };
292
+ /**
293
+ * The literal text operators (ILIKE, STARTS_WITH, ISTARTS_WITH, ENDS_WITH, IENDS_WITH and IEQ) on a String leaf or
294
+ * list, where some element must match. Values stay bound parameters. The case-sensitive forms, STARTS_WITH and
295
+ * ENDS_WITH, compare code points with starts_with and right under the C collation of text paths, so a column's
296
+ * btree serves STARTS_WITH. The case-insensitive forms compare the case fold of both sides (see case-fold.js): IEQ
297
+ * with =, the others with LIKE and a bound pattern whose metacharacters are escaped, so that a btree on the folded
298
+ * column with text_pattern_ops serves IEQ and ISTARTS_WITH, and a trigram index on it serves ILIKE, ISTARTS_WITH and
299
+ * IENDS_WITH.
300
+ */
301
+ const textPredicate = ({ sql, uncollated, field, list }, operator, { match, ignoreCase }, value, negated) => {
302
+ // createQueryPlan has validated the value; a plan built by hand gets the same INVALID_FILTER_VALUE.
303
+ validateTextFilterValue(operator, value, field.scalar);
304
+ let compare;
305
+ if (!ignoreCase) {
306
+ const parameter = bind(value, 'text');
307
+ compare = match === 'prefix'
308
+ ? (left) => `starts_with(${left}, ${parameter})`
309
+ : (left) => `right(${left}, char_length(${parameter})) = ${parameter}`;
310
+ } else if (match === 'exact') {
311
+ const parameter = bind(value, 'text');
312
+ compare = (left) => `${fold(left)} = ${fold(parameter)}`;
313
+ } else {
314
+ const pattern = bind(likePatterns[match](value), 'text');
315
+ compare = (left) => `${fold(left)} LIKE ${fold(pattern)}`;
316
+ }
317
+ // fold puts its argument in parentheses, so the uncollated value goes in as it is.
318
+ if (list) return `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})`;
319
+ const left = ignoreCase ? uncollated : sql;
320
+ return negated ? `COALESCE(${compare(left)}, FALSE)` : compare(left);
321
+ };
279
322
  // A negation reaches every predicate below it, including those nested in AND/OR groups.
280
323
  const where = (node, negated = false) => {
281
324
  if (!node) return 'TRUE';
package/types/index.d.ts CHANGED
@@ -230,7 +230,12 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
230
230
  initializeDatabase(options?: InitializationOptions): Promise<InitializationResult>;
231
231
  withTransaction<T>(session: PostgresSession | null | undefined, callback: (session: PostgresSession) => Promise<T> | T): Promise<T>;
232
232
  }
233
- /** PostgreSQL 15+ plugin for createSQL; omitted options allow later runtime.configure. */
233
+ /**
234
+ * PostgreSQL 15+ plugin for createSQL; omitted options allow later runtime.configure. Its `queryFeatures` lists every
235
+ * `QLOperator` value. The case-insensitive text operators fold case with {@link caseFoldExpression}, which needs a UTF8
236
+ * database with the ICU collation `und-x-icu`: on another database, such as one in LATIN1 or on a server built without
237
+ * ICU, they fail with UNSUPPORTED_QUERY (400), and every other operation works.
238
+ */
234
239
  export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
235
240
  PostgresConfiguration,
236
241
  DatabaseQueryable,
@@ -243,6 +248,17 @@ export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
243
248
  }
244
249
  >;
245
250
  export function createPostgres(options?: PostgresConfiguration): PostgresRuntime;
251
+ /**
252
+ * The SQL text with which ILIKE, ISTARTS_WITH, IENDS_WITH and IEQ fold both sides of their comparison, for an SQL
253
+ * expression such as a quoted column name or a jsonb member: `translate(lower((<expression>) COLLATE
254
+ * "pg_catalog"."und-x-icu"), '<22 characters>', '<their simple case foldings>')`, the Unicode simple case foldings that
255
+ * MongoDB's case-insensitive matching applies. Indexes serve these operators only on exactly this expression, so build
256
+ * them with it, for example `CREATE INDEX ... (${caseFoldExpression('"name"')} text_pattern_ops)` or
257
+ * `CREATE INDEX ... (${caseFoldExpression('"details" ->> \'label\'')})`. The expression is inserted as given, in
258
+ * parentheses, so that COLLATE applies to all of it; pass trusted SQL, never request input. Throws a TypeError for
259
+ * anything other than a non-empty string.
260
+ */
261
+ export function caseFoldExpression(sqlExpression: string): string;
246
262
  export const configure: PostgresRuntime['configure'];
247
263
  export const connect: PostgresRuntime['connect'];
248
264
  export const addNoEndpointType: PostgresRuntime['addNoEndpointType'];