@simtlix/simfinity-postgres 3.7.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 +7 -3
- package/package.json +3 -3
- package/src/codecs.js +6 -1
- package/src/index.js +1 -0
- package/src/plugin.js +19 -2
- package/src/query/case-fold.js +88 -0
- package/src/query/compiler.js +47 -4
- package/src/schema/initialize.js +177 -7
- package/types/index.d.ts +21 -2
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,13 +77,17 @@ 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.
|
|
83
85
|
|
|
86
|
+
Virtual fields (`extensions.virtual`), on entities and on members of embedded values, have no storage: no column, presence marker, check, JSON validation function, owned table, index or foreign key, and any output type works as their result. Adding one to a deployed type is therefore not a schema change. A field that a database already stores and that becomes virtual leaves its storage behind, so initialization keeps failing with `SCHEMA_MISMATCH` until the database is migrated. When the leftover is a column named after that field, its presence marker or owned-records state, its owned table, a validation or maintenance function of its own column or root, or the owned table of an embedded value whose last member with its own table it was, the error names the field and lists the migration. Storage named otherwise is not attributed: the `connectionField` column of a former reference, and the private back-reference column of a former collection in its child's table.
|
|
87
|
+
|
|
84
88
|
Scalar-list leaves inside nested embedded lists support filters, sorts, ragged group projections, and array-valued facts. Array `MIN`/`MAX` compares complete arrays; result sorting uses immediate extrema. Date parameters use per-query UTC encoding without changing global `pg` behavior.
|
|
85
89
|
|
|
86
|
-
The following mappings remain unsupported and fail explicitly: implicit many-to-many reciprocal lists, non-embedded collections inside embedded objects, embedded cycles, nested list wrappers, whole embedded-object uniqueness, conflicting relationship metadata, enum values that collide after text conversion, and custom scalars without a recognized base scalar. Query paths cannot sort or group whole embedded objects. Arbitrary MongoDB pipelines and Mongoose-native methods are outside the PostgreSQL contract.
|
|
90
|
+
The following mappings remain unsupported and fail explicitly: implicit many-to-many reciprocal lists, non-embedded collections inside embedded objects, embedded cycles, nested list wrappers, whole embedded-object uniqueness, conflicting relationship metadata, enum values that collide after text conversion, and stored custom scalars without a recognized base scalar. Query paths cannot sort or group whole embedded objects. Arbitrary MongoDB pipelines and Mongoose-native methods are outside the PostgreSQL contract.
|
|
87
91
|
|
|
88
92
|
## Low-level schema API
|
|
89
93
|
|
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,10 +18,18 @@ 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 }),
|
|
23
|
-
|
|
31
|
+
// `context.virtualFields` locates virtual fields, so that SCHEMA_MISMATCH can name one whose earlier storage is left.
|
|
32
|
+
initialize: (configuration, description, initialization, context) => initializeDatabase(configuration.pool, description, initialization, context),
|
|
24
33
|
compileSchema: compileDatabaseSchema,
|
|
25
34
|
compileQuery,
|
|
26
35
|
// Optional member: the find statements of many parents in one statement, for findChildrenBatch, which the SQL
|
|
@@ -36,7 +45,15 @@ export const postgresPlugin = (options) => ({
|
|
|
36
45
|
assertConfiguration(configuration) {
|
|
37
46
|
if (!configuration?.pool?.connect || !configuration.pool.query) throw new SimfinityError('Configure a pg.Pool before creating the schema', 'DATABASE_NOT_CONFIGURED', 500);
|
|
38
47
|
},
|
|
39
|
-
|
|
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
|
+
},
|
|
40
57
|
acquire: (configuration) => configuration.pool.connect(),
|
|
41
58
|
begin: (client) => client.query('BEGIN ISOLATION LEVEL REPEATABLE READ'),
|
|
42
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/src/schema/initialize.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
2
|
-
import { identifier, qualified, generatedName } from './sql.js';
|
|
2
|
+
import { identifier, qualified, generatedName, literal } from './sql.js';
|
|
3
3
|
import { createTableSQL, createForeignKeySQL, createIndexSQL, createFunctionSQL, createTriggerSQL, triggerEvents } from './ddl.js';
|
|
4
4
|
import { maintenanceLayouts, layoutOf, withLayouts } from './constraints.js';
|
|
5
5
|
|
|
@@ -342,8 +342,18 @@ const traverse = async (client, description, catalog, pass, missing, created) =>
|
|
|
342
342
|
return legacy;
|
|
343
343
|
};
|
|
344
344
|
|
|
345
|
-
//
|
|
346
|
-
|
|
345
|
+
// The table that an embedded value holding a virtual member owned while that member was stored, when the description
|
|
346
|
+
// no longer has it: the value then has no member with its own table, so it is stored as JSON.
|
|
347
|
+
const formerOwnedTable = (description, location) => {
|
|
348
|
+
if (!location.path.length) return null;
|
|
349
|
+
const name = generatedName(location.table, location.path[0]);
|
|
350
|
+
return description.tables.some((table) => table.name === name) ? null : name;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Names to read from the catalog: the description's, plus the refresh-trigger objects of both layouts. With virtual
|
|
354
|
+
// fields, also the touch trigger of each root that owns nothing, and the former owned table of each embedded value
|
|
355
|
+
// that holds one (see formerOwnedTable), which only storage no longer described can leave.
|
|
356
|
+
const catalogNames = (description, layouts, virtualFields) => {
|
|
347
357
|
const functions = new Set((description.functions || []).map((fn) => fn.name));
|
|
348
358
|
const triggers = new Set((description.triggers || []).map((trigger) => trigger.name));
|
|
349
359
|
for (const layout of layouts.values()) {
|
|
@@ -352,7 +362,18 @@ const catalogNames = (description, layouts) => {
|
|
|
352
362
|
for (const trigger of objects.triggers) triggers.add(trigger.name);
|
|
353
363
|
}
|
|
354
364
|
}
|
|
355
|
-
|
|
365
|
+
const tables = new Set(description.tables.map((table) => table.name));
|
|
366
|
+
if (virtualFields.length) {
|
|
367
|
+
const maintained = new Set((description.maintenance || []).map((item) => item.rootTable));
|
|
368
|
+
for (const table of description.tables) {
|
|
369
|
+
if (!table.ownership && !table.auxiliary && !maintained.has(table.name)) triggers.add(generatedName(table.name, 'touch'));
|
|
370
|
+
}
|
|
371
|
+
for (const location of virtualFields) {
|
|
372
|
+
const former = formerOwnedTable(description, location);
|
|
373
|
+
if (former) tables.add(former);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { tables: [...tables], functions: [...functions], triggers: [...triggers] };
|
|
356
377
|
};
|
|
357
378
|
|
|
358
379
|
// Each maintained root keeps the refresh-trigger layout its database already has: a function or trigger
|
|
@@ -412,6 +433,148 @@ const lockRoots = async (client, description, catalog, roots) => {
|
|
|
412
433
|
if (names.length) await client.query(`LOCK TABLE ${names.map((table) => qualified(description.schema, table.name)).join(', ')} IN SHARE ROW EXCLUSIVE MODE`);
|
|
413
434
|
};
|
|
414
435
|
|
|
436
|
+
const UPGRADE_NOTES = 'https://simtlix.github.io/simfinity.js/resources/compatibility.html';
|
|
437
|
+
const series = (items) => (items.length < 3 ? items.join(' and ') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`);
|
|
438
|
+
// A name longer than PostgreSQL identifiers was never stored, so nothing can refer to it.
|
|
439
|
+
const quotedName = (name) => (Buffer.byteLength(name) <= 63 ? identifier(name) : null);
|
|
440
|
+
|
|
441
|
+
// The described functions whose bodies could refer to the storage of a virtual field's location: for a member of a
|
|
442
|
+
// JSON value, the validation functions of its column, which the column's shape check calls; for a column, the
|
|
443
|
+
// maintenance functions of its table's root and the functions of that root's triggers. Each set includes the
|
|
444
|
+
// described functions that its functions call.
|
|
445
|
+
const functionOwners = (description) => {
|
|
446
|
+
const functions = description.functions || [];
|
|
447
|
+
const called = (text) => functions.filter((fn) => text.includes(qualified(description.schema, fn.name)));
|
|
448
|
+
const closure = (seeds) => {
|
|
449
|
+
const names = new Set();
|
|
450
|
+
const queue = [...seeds];
|
|
451
|
+
while (queue.length) {
|
|
452
|
+
const fn = queue.pop();
|
|
453
|
+
if (names.has(fn.name)) continue;
|
|
454
|
+
names.add(fn.name);
|
|
455
|
+
queue.push(...called(fn.body));
|
|
456
|
+
}
|
|
457
|
+
return names;
|
|
458
|
+
};
|
|
459
|
+
const ofRoot = (table) => {
|
|
460
|
+
const root = rootOf(description, table);
|
|
461
|
+
const item = (description.maintenance || []).find((entry) => entry.rootTable === root);
|
|
462
|
+
const triggered = new Set((description.triggers || []).filter((trigger) => rootOf(description, trigger.table) === root).map((trigger) => trigger.function));
|
|
463
|
+
return closure(functions.filter((fn) => triggered.has(fn.name) || (item && [item.validateFunction, item.refreshFunction].includes(fn.name))));
|
|
464
|
+
};
|
|
465
|
+
const ofColumn = (tableName, column) => {
|
|
466
|
+
const table = description.tables.find((item) => item.name === tableName);
|
|
467
|
+
const check = table?.checks.find((item) => item.name === generatedName(tableName, column, 'shape'));
|
|
468
|
+
return closure(check ? called(check.expression) : []);
|
|
469
|
+
};
|
|
470
|
+
return { ofRoot, ofColumn };
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// Storage that virtual fields (`extensions.virtual`) left in a database created while they were stored, found in
|
|
474
|
+
// the catalog this run has read, with no further statement: a column of a described table named as such a field's
|
|
475
|
+
// column, presence flag or owned-records state, a described function whose stored body still refers to such a
|
|
476
|
+
// field while its description does not, and the owned table of an embedded value that such a field, as its last
|
|
477
|
+
// member with its own table, made the value own. A state column means the field owned tables, which are dropped. An
|
|
478
|
+
// embedded value that no longer owns a table is stored as JSON instead, which needs a data migration. A root that
|
|
479
|
+
// now owns nothing but still has its refresh triggers loses them and its guard table, since the functions they call
|
|
480
|
+
// refer to the storage being dropped. Only the functions of a field's own column or root are compared (see
|
|
481
|
+
// functionOwners). Returns the explanation, with the migration, that a SCHEMA_MISMATCH appends, or null when
|
|
482
|
+
// nothing is left.
|
|
483
|
+
const virtualLeftovers = (description, catalog, virtualFields) => {
|
|
484
|
+
const { schema } = description;
|
|
485
|
+
const maintained = new Set((description.maintenance || []).map((item) => item.rootTable));
|
|
486
|
+
const owners = functionOwners(description);
|
|
487
|
+
const left = new Set();
|
|
488
|
+
const columns = new Map();
|
|
489
|
+
const owned = [];
|
|
490
|
+
const moved = new Map();
|
|
491
|
+
const has = (actual, name) => actual.columns.some((column) => column.name === name);
|
|
492
|
+
for (const location of virtualFields) {
|
|
493
|
+
const former = formerOwnedTable(description, location);
|
|
494
|
+
const table = former && description.tables.find((item) => item.name === location.table);
|
|
495
|
+
const actual = table && catalog.tables.get(table.name);
|
|
496
|
+
const ownedActual = former && catalog.tables.get(former);
|
|
497
|
+
const [value, member = location.name] = location.path;
|
|
498
|
+
const state = `__${value}_state`;
|
|
499
|
+
if (!actual || !ownedActual || table.columns.some((column) => column.name === state) || !has(actual, state)) continue;
|
|
500
|
+
// The member, or the value inside the owned table that holds it, had storage there.
|
|
501
|
+
if (![member, generatedName('__field', member, 'present'), `__${member}_state`].some((name) => has(ownedActual, name))) continue;
|
|
502
|
+
left.add(location.field);
|
|
503
|
+
moved.set(former, { table: table.name, value, state, location });
|
|
504
|
+
}
|
|
505
|
+
for (const location of virtualFields.filter((item) => !item.path.length)) {
|
|
506
|
+
const table = description.tables.find((item) => item.name === location.table);
|
|
507
|
+
const actual = table && catalog.tables.get(table.name);
|
|
508
|
+
if (!actual) continue;
|
|
509
|
+
const state = `__${location.name}_state`;
|
|
510
|
+
const names = [location.name, generatedName('__field', location.name, 'present'), state].filter((name) => !table.columns.some((column) => column.name === name)
|
|
511
|
+
&& actual.columns.some((column) => column.name === name));
|
|
512
|
+
if (!names.length) continue;
|
|
513
|
+
left.add(location.field);
|
|
514
|
+
columns.set(table.name, [...(columns.get(table.name) || []), ...names]);
|
|
515
|
+
if (names.includes(state)) owned.push(location);
|
|
516
|
+
}
|
|
517
|
+
const statements = [...columns].map(([table, names]) => `ALTER TABLE ${qualified(schema, table)} ${names.map((name) => `DROP COLUMN ${identifier(name)}`).join(', ')};`);
|
|
518
|
+
const add = (statement) => { if (!statements.includes(statement)) statements.push(statement); };
|
|
519
|
+
for (const location of owned) add(`DROP TABLE IF EXISTS ${qualified(schema, generatedName(location.table, location.name))} CASCADE;`);
|
|
520
|
+
for (const [former, { table, value, state }] of moved) {
|
|
521
|
+
const json = [value, generatedName('__field', value, 'present')].map(identifier).join(' and ');
|
|
522
|
+
add(`The embedded value ${value} of ${qualified(schema, table)} now has no member with its own table, so it is stored as JSON in the columns ${json} `
|
|
523
|
+
+ `of that table, with the checks and functions that compileDatabaseSchema() gives them, instead of in the table ${qualified(schema, former)}: `
|
|
524
|
+
+ `add them, fill them from the rows of ${qualified(schema, former)}, then drop the column ${identifier(state)} and run DROP TABLE IF EXISTS ${qualified(schema, former)} CASCADE;`);
|
|
525
|
+
}
|
|
526
|
+
for (const root of new Set([...columns.keys(), ...[...moved.values()].map((item) => item.table)].map((table) => rootOf(description, table)))) {
|
|
527
|
+
if (maintained.has(root) || !catalog.triggers(root, generatedName(root, 'touch')).length) continue;
|
|
528
|
+
for (const suffix of ['touch', 'deferred']) add(`DROP TRIGGER IF EXISTS ${identifier(generatedName(root, suffix))} ON ${qualified(schema, root)};`);
|
|
529
|
+
add(`DROP TABLE IF EXISTS ${qualified(schema, generatedName(root, 'guard'))};`);
|
|
530
|
+
}
|
|
531
|
+
// A column is referred to as `alias."name"`, a member of a JSON value as `($1 -> 'name')`, and a former owned table
|
|
532
|
+
// by its qualified name.
|
|
533
|
+
const references = [
|
|
534
|
+
...virtualFields.map((location) => ({
|
|
535
|
+
field: location.field,
|
|
536
|
+
functions: location.path.length ? owners.ofColumn(location.table, location.path[0]) : owners.ofRoot(location.table),
|
|
537
|
+
tokens: location.path.length
|
|
538
|
+
? [`($1 -> ${literal(location.name)})`]
|
|
539
|
+
: [location.name, generatedName('__field', location.name, 'present'), `__${location.name}_state`].map(quotedName).filter(Boolean).map((name) => `.${name}`),
|
|
540
|
+
})),
|
|
541
|
+
...[...moved].map(([former, { table, location }]) => ({ field: location.field, functions: owners.ofRoot(table), tokens: [qualified(schema, former)] })),
|
|
542
|
+
];
|
|
543
|
+
const functions = [];
|
|
544
|
+
for (const fn of description.functions || []) {
|
|
545
|
+
const rows = catalog.functions(fn.name);
|
|
546
|
+
if (rows.length !== 1 || rows[0].body === fn.body) continue;
|
|
547
|
+
const matched = references.filter(({ functions: owned, tokens }) => owned.has(fn.name)
|
|
548
|
+
&& tokens.some((token) => rows[0].body.includes(token) && !fn.body.includes(token)));
|
|
549
|
+
if (!matched.length) continue;
|
|
550
|
+
functions.push(qualified(schema, fn.name));
|
|
551
|
+
for (const { field } of matched) left.add(field);
|
|
552
|
+
}
|
|
553
|
+
if (!left.size) return null;
|
|
554
|
+
const fields = [...new Set(virtualFields.map((item) => item.field))].filter((field) => left.has(field));
|
|
555
|
+
const many = fields.length > 1;
|
|
556
|
+
if (functions.length) {
|
|
557
|
+
statements.push(`Replace ${functions.length > 1 ? 'the functions' : 'the function'} ${series(functions)} with ${functions.length > 1 ? 'their CREATE FUNCTION statements' : 'its CREATE FUNCTION statement'} `
|
|
558
|
+
+ 'from compileDatabaseSchema(), run as CREATE OR REPLACE FUNCTION.');
|
|
559
|
+
}
|
|
560
|
+
return `${series(fields)} ${many ? 'are' : 'is'} virtual (extensions.virtual), so ${many ? 'they are' : 'it is'} never stored, but this database still holds ${many ? 'their' : 'its'} `
|
|
561
|
+
+ `earlier storage. Once nothing reads that storage, migrate it: ${statements.join(' ')} See the upgrade notes at ${UPGRADE_NOTES}`;
|
|
562
|
+
};
|
|
563
|
+
// A SCHEMA_MISMATCH of a database that still holds storage of fields that are now virtual also names them.
|
|
564
|
+
const explainMismatch = (error, description, catalog, layouts, virtualFields) => {
|
|
565
|
+
if (!virtualFields.length || !catalog || !(error instanceof SimfinityError) || error.extensions?.code !== 'SCHEMA_MISMATCH') return error;
|
|
566
|
+
let resolved = description;
|
|
567
|
+
// Refresh-trigger objects of two layouts in one root are themselves the mismatch; compare with the description.
|
|
568
|
+
try { resolved = resolveLayouts(description, catalog, layouts); } catch { /* keep the description */ }
|
|
569
|
+
try {
|
|
570
|
+
const explanation = virtualLeftovers(resolved, catalog, virtualFields);
|
|
571
|
+
return explanation ? new SimfinityError(`${error.message} ${explanation}`, 'SCHEMA_MISMATCH', 409) : error;
|
|
572
|
+
} catch {
|
|
573
|
+
// An explanation never replaces the mismatch it explains.
|
|
574
|
+
return error;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
415
578
|
const backfill = async (client, description, roots) => {
|
|
416
579
|
// The keys of these roots can be stale because maintenance objects were recreated. Their tables are
|
|
417
580
|
// locked; rebuild their complete derived key set before testing owners against one another. Rollback
|
|
@@ -430,18 +593,23 @@ const backfill = async (client, description, roots) => {
|
|
|
430
593
|
* Create missing objects or validate an existing schema; never alter/drop existing storage. The catalog is
|
|
431
594
|
* read in a fixed number of statements without table locks. Create mode changes nothing, and locks nothing,
|
|
432
595
|
* when nothing is missing; otherwise it locks only the tables of roots whose keys it must rebuild.
|
|
596
|
+
* `context.virtualFields`, which the SQL runtime passes when its types declare virtual fields, lets a
|
|
597
|
+
* SCHEMA_MISMATCH name such a field whose earlier storage the database still holds, with its migration.
|
|
433
598
|
*/
|
|
434
|
-
export const initializeDatabase = async (pool, description, { mode = 'create' } = {}) => {
|
|
599
|
+
export const initializeDatabase = async (pool, description, { mode = 'create' } = {}, context) => {
|
|
435
600
|
if (!['create', 'validate'].includes(mode)) throw new SimfinityError(`Unknown initialization mode: ${mode}`, 'INVALID_INITIALIZATION_MODE', 400);
|
|
436
601
|
const { schema } = description;
|
|
437
602
|
identifier(schema);
|
|
438
603
|
if (schema.startsWith('pg_') || schema === 'information_schema') throw new SimfinityError('System schemas cannot be managed', 'INVALID_DATABASE_SCHEMA', 400);
|
|
439
604
|
const layouts = maintenanceLayouts(description);
|
|
440
|
-
const
|
|
605
|
+
const virtualFields = Array.isArray(context?.virtualFields) ? context.virtualFields : [];
|
|
606
|
+
const names = catalogNames(description, layouts, virtualFields);
|
|
441
607
|
const client = await pool.connect();
|
|
442
608
|
const created = [];
|
|
443
609
|
let legacy = [];
|
|
444
610
|
let rollbackError;
|
|
611
|
+
// The catalog most recently read, which explains a mismatch.
|
|
612
|
+
let latest = null;
|
|
445
613
|
try {
|
|
446
614
|
await client.query(mode === 'validate' ? 'BEGIN READ ONLY' : 'BEGIN');
|
|
447
615
|
await client.query('SET LOCAL search_path = pg_catalog');
|
|
@@ -451,6 +619,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
|
|
|
451
619
|
// Serializes initializers of this schema before anything is read.
|
|
452
620
|
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`simfinity:${schema}`]);
|
|
453
621
|
const inspected = await loadCatalog(client, schema, names);
|
|
622
|
+
latest = inspected;
|
|
454
623
|
if (mode === 'validate') legacy = await traverse(client, resolveLayouts(description, inspected, layouts), inspected, 'validate', [], created);
|
|
455
624
|
else {
|
|
456
625
|
// Inspect first, without table locks or DDL. Drift before the first missing object is reported as
|
|
@@ -468,6 +637,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
|
|
|
468
637
|
const roots = staleRoots(planned, missing);
|
|
469
638
|
await lockRoots(client, planned, inspected, roots);
|
|
470
639
|
const catalog = await loadCatalog(client, schema, names);
|
|
640
|
+
latest = catalog;
|
|
471
641
|
const effective = resolveLayouts(description, catalog, layouts);
|
|
472
642
|
const objects = [];
|
|
473
643
|
legacy = await traverse(client, effective, catalog, 'create', objects, created);
|
|
@@ -487,7 +657,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
|
|
|
487
657
|
return { mode, created };
|
|
488
658
|
} catch (error) {
|
|
489
659
|
try { await client.query('ROLLBACK'); } catch (failure) { rollbackError = failure; }
|
|
490
|
-
throw error;
|
|
660
|
+
throw explainMismatch(error, description, latest, layouts, virtualFields);
|
|
491
661
|
} finally {
|
|
492
662
|
// A failed ROLLBACK can leave the transaction open; the error makes pg-pool destroy the connection.
|
|
493
663
|
client.release(rollbackError);
|
package/types/index.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type {
|
|
|
32
32
|
StateMachine,
|
|
33
33
|
TypePermissions,
|
|
34
34
|
TypeScopes,
|
|
35
|
+
VirtualFieldDeclaration,
|
|
36
|
+
VirtualFieldOptions,
|
|
35
37
|
} from '@simtlix/simfinity-core';
|
|
36
38
|
|
|
37
39
|
export interface DatabaseColumn {
|
|
@@ -222,12 +224,18 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
|
|
|
222
224
|
* Must be awaited before operations execute. Does not synchronize incompatible schemas. A request rejected before
|
|
223
225
|
* storage is touched, such as an unknown mode (INVALID_INITIALIZATION_MODE, 400) or, on a validation-only runtime,
|
|
224
226
|
* a non-empty mode other than validate (DATABASE_CREATION_DISABLED, 409), keeps the current readiness; a ready runtime keeps serving while it is initialized again, and any failure of the latest
|
|
225
|
-
* initialization makes it unavailable.
|
|
227
|
+
* initialization makes it unavailable. When the database still holds storage of a field that is now virtual (`extensions.virtual`), such as its column,
|
|
228
|
+
* the SCHEMA_MISMATCH (409) message also names the field and gives the migration.
|
|
226
229
|
*/
|
|
227
230
|
initializeDatabase(options?: InitializationOptions): Promise<InitializationResult>;
|
|
228
231
|
withTransaction<T>(session: PostgresSession | null | undefined, callback: (session: PostgresSession) => Promise<T> | T): Promise<T>;
|
|
229
232
|
}
|
|
230
|
-
/**
|
|
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
|
+
*/
|
|
231
239
|
export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
|
|
232
240
|
PostgresConfiguration,
|
|
233
241
|
DatabaseQueryable,
|
|
@@ -240,6 +248,17 @@ export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
|
|
|
240
248
|
}
|
|
241
249
|
>;
|
|
242
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;
|
|
243
262
|
export const configure: PostgresRuntime['configure'];
|
|
244
263
|
export const connect: PostgresRuntime['connect'];
|
|
245
264
|
export const addNoEndpointType: PostgresRuntime['addNoEndpointType'];
|