@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 +4 -2
- package/package.json +3 -3
- package/src/codecs.js +6 -1
- package/src/index.js +1 -0
- package/src/plugin.js +17 -1
- package/src/query/case-fold.js +88 -0
- package/src/query/compiler.js +47 -4
- package/types/index.d.ts +17 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
-
"@simtlix/simfinity-sql": "3.
|
|
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
|
-
|
|
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);
|
package/src/query/compiler.js
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
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} ${
|
|
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
|
-
/**
|
|
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'];
|