biz-a-cli 2.3.80-15372 → 2.3.80-15375
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/engine/orm/apiRoute.js +46 -1
- package/engine/orm/changeInference.js +48 -8
- package/engine/orm/completeness.js +62 -0
- package/engine/orm/configVersion.js +28 -0
- package/engine/orm/crudQuery.js +30 -9
- package/engine/orm/entityConfig.js +109 -1
- package/engine/orm/graphRead.js +94 -0
- package/engine/orm/index.js +22 -5
- package/engine/orm/listQuery.js +11 -7
- package/engine/orm/masterDetail.js +43 -3
- package/engine/orm/sqlGuard.js +172 -0
- package/package.json +1 -1
package/engine/orm/apiRoute.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* TOrmMethod/"orm" -> {"data":[{...}],"success":"OK"}
|
|
20
20
|
* failure -> {"error":"..."} , which db/ds.js's dsReq already looks for.
|
|
21
21
|
*/
|
|
22
|
+
import { UNSAFE_SQL_CODE } from "./sqlGuard.js";
|
|
23
|
+
import { INCOMPLETE_COLLECTION_CODE } from "./completeness.js";
|
|
24
|
+
import { isGraphRead, assertGraphRequest, attachCollections, GRAPH_READ_KEY_REQUIRED_CODE } from "./graphRead.js";
|
|
22
25
|
import { json2List, addTableNameToArray, lookupToSql, lookupTotalToSql, resolveDbIndex, buildWriteSql } from "./index.js";
|
|
23
26
|
import { nextGeneratorValue } from "./dialect.js";
|
|
24
27
|
import { createReaders } from "./changeInference.js";
|
|
@@ -364,9 +367,33 @@ const lookupResponse = async (param, exec, dbIndex, isTotal, tenantIds) => {
|
|
|
364
367
|
/* `param.version` selects the persistence semantics (Doc 3 §6.1). The envelope already reached here
|
|
365
368
|
whole — `parseDataSnapRequest` passes `param` through untouched — so carrying it needed no
|
|
366
369
|
wire-format change, only for this function to stop discarding the key. */
|
|
370
|
+
/*
|
|
371
|
+
* Doc 2 section 5.2 item 5 - a delete the database refuses because another row still REFERENCES the record
|
|
372
|
+
* (a shared relationship, e.g. a category using a template) is a caller-visible state, not a server fault:
|
|
373
|
+
* answered 409 IN_USE naming the referencing table. Only for a DELETE - an FK violation on a save means a
|
|
374
|
+
* bad reference, which is a different thing. PostgreSQL reports the referencing table as error.table.
|
|
375
|
+
*/
|
|
376
|
+
export const IN_USE_CODE = "IN_USE";
|
|
377
|
+
const asInUse = (error, param) => {
|
|
378
|
+
if (error?.code !== "23503" || String(param?.method ?? "").toLowerCase() !== "delete") return error;
|
|
379
|
+
/* ⚠️ The transaction runner (dialect.js decorate) wraps the driver error to add the failing statement
|
|
380
|
+
and copies only `code`, so the table survives on `cause` - or only in PostgreSQL's detail text. */
|
|
381
|
+
const original = error.cause ?? {};
|
|
382
|
+
const table =
|
|
383
|
+
error.table ??
|
|
384
|
+
original.table ??
|
|
385
|
+
/referenced from table "([^"]+)"/.exec(String(original.detail ?? error.detail ?? ""))?.[1];
|
|
386
|
+
const by = table ? ` by ${table}` : "";
|
|
387
|
+
return Object.assign(new Error(`This record cannot be deleted: it is still used${by}.`), { code: IN_USE_CODE });
|
|
388
|
+
};
|
|
389
|
+
|
|
367
390
|
const ormResponse = async (param, exec, execStatements, dbIndex, principal) => {
|
|
368
391
|
const tenantIds = principal?.tenantIds;
|
|
369
392
|
const readers = createReaders(exec, dbIndex, tenantIds);
|
|
393
|
+
/* Doc 2 section 5.2 item 1 - a v2 get that names collections loads the whole graph. Validated
|
|
394
|
+
BEFORE anything is read; see graphRead.js for why the collections use `readers.readRows`. */
|
|
395
|
+
const graph = isGraphRead(param);
|
|
396
|
+
if (graph) assertGraphRequest(param.object, dbIndex);
|
|
370
397
|
const sql = await buildWriteSql({
|
|
371
398
|
method: param.method,
|
|
372
399
|
object: param.object,
|
|
@@ -381,7 +408,12 @@ const ormResponse = async (param, exec, execStatements, dbIndex, principal) => {
|
|
|
381
408
|
tenantIds,
|
|
382
409
|
...readers,
|
|
383
410
|
});
|
|
384
|
-
const rows = await runStatements(exec, execStatements, sql, dbIndex)
|
|
411
|
+
const rows = await runStatements(exec, execStatements, sql, dbIndex).catch((error) => {
|
|
412
|
+
throw asInUse(error, param);
|
|
413
|
+
});
|
|
414
|
+
if (graph) await attachCollections({ object: param.object, masterRows: rows, readRows: readers.readRows, dbIndex });
|
|
415
|
+
/* Shallow on purpose: the master keeps the key casing the client has always seen, and the rows
|
|
416
|
+
inside a collection keep PostgreSQL columns - the shape the save takes straight back. */
|
|
385
417
|
const body = { data: upperCaseKeys(rows), success: "OK" };
|
|
386
418
|
|
|
387
419
|
/*
|
|
@@ -829,6 +861,9 @@ export const routeApiRequest = async (
|
|
|
829
861
|
/* Doc 3 §6.7 — a concurrency conflict is not a server fault, and must be distinguishable from
|
|
830
862
|
one WITHOUT parsing a message, so an application can offer reload/retry instead of showing a
|
|
831
863
|
generic failure. */
|
|
864
|
+
if (error?.code === IN_USE_CODE) {
|
|
865
|
+
return { status: 409, body: { error: error.message, code: IN_USE_CODE } };
|
|
866
|
+
}
|
|
832
867
|
if (error?.code === CONFLICT_CODE) {
|
|
833
868
|
return { status: 409, body: { error: error.message, code: CONFLICT_CODE } };
|
|
834
869
|
}
|
|
@@ -844,6 +879,16 @@ export const routeApiRequest = async (
|
|
|
844
879
|
if (error?.code === AMBIGUOUS_TENANT_CODE) {
|
|
845
880
|
return { status: 400, body: { error: error.message, code: AMBIGUOUS_TENANT_CODE } };
|
|
846
881
|
}
|
|
882
|
+
/* Request input the ORM will not put into SQL text (sqlGuard.js) - a caller error, named. */
|
|
883
|
+
if (error?.code === GRAPH_READ_KEY_REQUIRED_CODE) {
|
|
884
|
+
return { status: 400, body: { error: error.message, code: GRAPH_READ_KEY_REQUIRED_CODE } };
|
|
885
|
+
}
|
|
886
|
+
if (error?.code === INCOMPLETE_COLLECTION_CODE) {
|
|
887
|
+
return { status: 400, body: { error: error.message, code: INCOMPLETE_COLLECTION_CODE } };
|
|
888
|
+
}
|
|
889
|
+
if (error?.code === UNSAFE_SQL_CODE) {
|
|
890
|
+
return { status: 400, body: { error: error.message, code: UNSAFE_SQL_CODE } };
|
|
891
|
+
}
|
|
847
892
|
if (error?.code === SHARED_RELATIONSHIP_CODE) {
|
|
848
893
|
return { status: 400, body: { error: error.message, code: SHARED_RELATIONSHIP_CODE } };
|
|
849
894
|
}
|
|
@@ -20,11 +20,34 @@
|
|
|
20
20
|
* enforcing completeness is the APPLICATION's responsibility — the ORM cannot distinguish a partial
|
|
21
21
|
* load from a legitimate delete-all-but-these, and deliberately does not try.
|
|
22
22
|
*/
|
|
23
|
-
import { hasRealKey, scalarEntries, detailEntries,
|
|
23
|
+
import { hasRealKey, scalarEntries, detailEntries, renderValue } from "./masterDetail.js";
|
|
24
24
|
import { PRIMARY_KEY_COLUMN } from "./crudQuery.js";
|
|
25
25
|
import { projectRowVersion } from "./concurrency.js";
|
|
26
|
-
import { assertOwnedCollection, tenantFilter } from "./entityConfig.js";
|
|
26
|
+
import { assertOwnedCollection, tenantFilter, softDeleteFilter, foreignKeyFor } from "./entityConfig.js";
|
|
27
27
|
import { runTrusted } from "./tenantContext.js";
|
|
28
|
+
import { assertIdentifier } from "./sqlGuard.js";
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* `col [ASC|DESC], ...` checked as identifiers and rendered, always followed by `id` - rows with
|
|
32
|
+
* equal or null sort values would otherwise come back in heap order, which an UPDATE changes on
|
|
33
|
+
* PostgreSQL. tests/orm-v2-order-by.test.js.
|
|
34
|
+
*/
|
|
35
|
+
const renderOrderBy = (orderBy) => {
|
|
36
|
+
const terms = String(orderBy ?? "")
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((term) => term.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((term) => {
|
|
41
|
+
const [column, direction, ...rest] = term.split(/\s+/);
|
|
42
|
+
const dir = String(direction ?? "").toUpperCase();
|
|
43
|
+
if (rest.length > 0 || (direction !== undefined && dir !== "ASC" && dir !== "DESC")) {
|
|
44
|
+
assertIdentifier(term, "orderBy term");
|
|
45
|
+
}
|
|
46
|
+
assertIdentifier(column, "orderBy column");
|
|
47
|
+
return direction === undefined ? column : `${column} ${dir}`;
|
|
48
|
+
});
|
|
49
|
+
return ` ORDER BY ${[...terms, PRIMARY_KEY_COLUMN].join(", ")}`;
|
|
50
|
+
};
|
|
28
51
|
|
|
29
52
|
/*
|
|
30
53
|
* The two reads the inference needs, defined ONCE and shared by both entrances (apiRoute.js and
|
|
@@ -41,16 +64,29 @@ export const createReaders = (exec, dbIndex, tenantIds) => {
|
|
|
41
64
|
dangerous half of the filter, not the cosmetic one. Unfiltered, another tenant's rows are drawn
|
|
42
65
|
into the comparison; filtered here while the write stays unfiltered, rows the caller cannot see
|
|
43
66
|
are inferred `Deleted` and destroyed. Both halves move together or neither does. */
|
|
67
|
+
const hideDeleted = (table) => {
|
|
68
|
+
const predicate = softDeleteFilter(table, dbIndex);
|
|
69
|
+
return predicate ? ` AND ${predicate}` : "";
|
|
70
|
+
};
|
|
44
71
|
const scope = (table) => {
|
|
45
72
|
const predicate = tenantFilter(table, dbIndex, tenantIds);
|
|
46
73
|
return predicate ? ` AND ${predicate}` : "";
|
|
47
74
|
};
|
|
48
75
|
return {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
/* ⚠️ Soft-deleted rows are EXCLUDED here, and the reason is not tidiness. Deletion is inferred from
|
|
77
|
+
absence, and the display read already hides soft-deleted rows (Doc 3 section 6.6 rule 2), so no
|
|
78
|
+
client ever sends them back. Included here, every one of them was "missing", therefore Deleted,
|
|
79
|
+
therefore stamped again on EVERY save - overwriting who deleted it and when. The load and the
|
|
80
|
+
comparison must see the same rows. tests/orm-soft-delete-resave.test.js. */
|
|
81
|
+
readRows: async (table, fkColumn, parentKeyValue, orderBy = null) => {
|
|
82
|
+
const order = renderOrderBy(orderBy);
|
|
83
|
+
return (
|
|
84
|
+
(await exec(
|
|
85
|
+
`SELECT * FROM ${table} WHERE ${fkColumn} = ${renderValue(parentKeyValue)}${scope(table)}${hideDeleted(table)}${order}`,
|
|
86
|
+
dbIndex,
|
|
87
|
+
)) ?? []
|
|
88
|
+
);
|
|
89
|
+
},
|
|
54
90
|
/* ⚠️ `xmin` is named explicitly because it is a SYSTEM column and `SELECT *` does not include it.
|
|
55
91
|
Without it the §6.7 comparison reads `undefined` on every save and silently never conflicts —
|
|
56
92
|
a security-blanket failure that looks exactly like the feature working. */
|
|
@@ -219,7 +255,8 @@ const planNested = async ({ parentTable, parentRow, parentPlan, readRows, dbInde
|
|
|
219
255
|
const collection = await planCollection({
|
|
220
256
|
table,
|
|
221
257
|
rows,
|
|
222
|
-
|
|
258
|
+
/* Declared first, the convention otherwise - the SAME resolver the graph read uses. */
|
|
259
|
+
fkColumn: foreignKeyFor(parentTable, table, dbIndex),
|
|
223
260
|
parentKeyValue,
|
|
224
261
|
readRows,
|
|
225
262
|
});
|
|
@@ -231,6 +268,9 @@ const planNested = async ({ parentTable, parentRow, parentPlan, readRows, dbInde
|
|
|
231
268
|
parentRow: rows[index],
|
|
232
269
|
parentPlan: rowPlan,
|
|
233
270
|
readRows,
|
|
271
|
+
/* ⚠️ Passed down. Dropped here, every level below the first looked up an unprimed index,
|
|
272
|
+
so a SHARED subdetail was never refused. tests/orm-v2-foreign-key.test.js. */
|
|
273
|
+
dbIndex,
|
|
234
274
|
});
|
|
235
275
|
}
|
|
236
276
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Doc 2 section 5.5, ruling 5 (2026-09-26) - an INCOMPLETE detail collection is rejected, never read as
|
|
3
|
+
* deletions.
|
|
4
|
+
*
|
|
5
|
+
* Deletion is inferred from a row's absence (Doc 3 section 6.2), and the ORM cannot tell a partially
|
|
6
|
+
* loaded collection from "delete all but these". Doc 3 made completeness the application's
|
|
7
|
+
* responsibility; the application meets it by MARKING what it loaded partially, on the parent row:
|
|
8
|
+
*
|
|
9
|
+
* { st_order: { id: 7, _incomplete: ["st_order_line"], st_order_line: [ ...one page... ] } }
|
|
10
|
+
*
|
|
11
|
+
* and this refuses a save that carries a marked collection. A marked collection that is ABSENT is fine
|
|
12
|
+
* - absent already means "leave alone". tests/orm-incomplete-collection.test.js.
|
|
13
|
+
*/
|
|
14
|
+
export const INCOMPLETE_KEY = "_incomplete";
|
|
15
|
+
export const INCOMPLETE_COLLECTION_CODE = "INCOMPLETE_COLLECTION";
|
|
16
|
+
|
|
17
|
+
const isIncompleteKey = (key) => String(key).toLowerCase() === INCOMPLETE_KEY;
|
|
18
|
+
|
|
19
|
+
const refuse = (message) => Object.assign(new Error(message), { code: INCOMPLETE_COLLECTION_CODE });
|
|
20
|
+
|
|
21
|
+
/* An unreadable mark refuses: it is not proof that the collections are complete. */
|
|
22
|
+
const markedTables = (mark) => {
|
|
23
|
+
if (!Array.isArray(mark) || !mark.every((t) => typeof t === "string" && t.trim() !== "")) {
|
|
24
|
+
throw refuse(`${INCOMPLETE_KEY} must be a list of detail table names`);
|
|
25
|
+
}
|
|
26
|
+
return mark.map((t) => t.trim().toLowerCase());
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const checkRow = (row) => {
|
|
30
|
+
const out = {};
|
|
31
|
+
let marked = [];
|
|
32
|
+
for (const [key, value] of Object.entries(row)) {
|
|
33
|
+
if (isIncompleteKey(key)) {
|
|
34
|
+
marked = markedTables(value);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out[key] = check(value);
|
|
38
|
+
}
|
|
39
|
+
for (const table of marked) {
|
|
40
|
+
const present = Object.keys(row).find((key) => key.toLowerCase() === table && Array.isArray(row[key]));
|
|
41
|
+
if (present) {
|
|
42
|
+
throw refuse(
|
|
43
|
+
`${present} was loaded incompletely and cannot be saved: its missing rows would be read as deletions. ` +
|
|
44
|
+
"Load the whole collection, or leave it out of the save.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const check = (value) => {
|
|
52
|
+
if (Array.isArray(value)) return value.map(check);
|
|
53
|
+
if (value === null || typeof value !== "object" || value instanceof Date) return value;
|
|
54
|
+
return checkRow(value);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* Throw if any row, at any depth, marks a collection it also carries; otherwise return a copy with every
|
|
59
|
+
* mark removed. ⚠️ The strip is not tidiness: masterDetail.js scalarEntries turns every non-detail key
|
|
60
|
+
* into a column, so an unstripped mark would become `INSERT INTO ... (_incomplete)`.
|
|
61
|
+
*/
|
|
62
|
+
export const assertCompleteCollections = (object) => check(object);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Doc 2 section 2 - what a template config's `version` means to the ORM.
|
|
3
|
+
*
|
|
4
|
+
* Absent means 1 (section 2.1 rule 4). 2 OR HIGHER selects the new behaviour (rule 2) - today that is
|
|
5
|
+
* change inference in place of delete-and-replace (Doc 3 section 6.1).
|
|
6
|
+
*
|
|
7
|
+
* WARNING THE CLIENT HAS A TWIN OF THIS (client/src/app/services/config-version.ts) and the two must
|
|
8
|
+
* never disagree - a screen would render as v2 and persist as v1, each side correct on its own. Both
|
|
9
|
+
* are held to shared/domain/config-version-cases.json; change the rule there, and both sides fail
|
|
10
|
+
* until both agree.
|
|
11
|
+
*
|
|
12
|
+
* WARNING THIS IS NOT metadata.version. That key is a required semver identifying the domain package
|
|
13
|
+
* ("0.1.0"), and it reads here as undeclared, not as 0.1.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* Anything that is not a finite number, or a string holding one, was never a version. */
|
|
17
|
+
export const configVersion = (value) => {
|
|
18
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 1;
|
|
19
|
+
if (typeof value === "string") {
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (trimmed === "") return 1;
|
|
22
|
+
const parsed = Number(trimmed);
|
|
23
|
+
return Number.isFinite(parsed) ? parsed : 1;
|
|
24
|
+
}
|
|
25
|
+
return 1;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const isConfigV2 = (value) => configVersion(value) >= 2;
|
package/engine/orm/crudQuery.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* string becomes a quoted literal, a JSON number stays bare, JSON null becomes `null`. Callers
|
|
6
6
|
* quote their own values (the same contract db/ds.js already documents for filter values).
|
|
7
7
|
*/
|
|
8
|
+
import { assertQualifiedIdentifier, assertKeyLiteral, assertSqlFragment, assertPayloadIdentifiers } from "./sqlGuard.js";
|
|
8
9
|
import { buildSequenceNextValue, renderTimestamp } from "./dialect.js";
|
|
9
10
|
import { buildOrderClause } from "./listQuery.js";
|
|
10
11
|
import { projectRowVersion } from "./concurrency.js";
|
|
@@ -76,8 +77,9 @@ const renderValue = (value, isBoolean = false) => {
|
|
|
76
77
|
/* `get` and `delete` interpolate the value VERBATIM — the caller quotes its own, which is the
|
|
77
78
|
contract orm.pas set and db/ds.js documents (`cuti.id=-1`, unquoted). A Date has no verbatim
|
|
78
79
|
form that PostgreSQL accepts, so it alone is rendered; everything else is untouched. */
|
|
80
|
+
/* Verbatim by contract (a GUID arrives pre-quoted) - but only ever ONE literal. sqlGuard.js. */
|
|
79
81
|
const renderKeyValue = (value) =>
|
|
80
|
-
value instanceof Date ? `'${renderTimestamp(value)}'` : `${value}`;
|
|
82
|
+
value instanceof Date ? `'${renderTimestamp(value)}'` : `${assertKeyLiteral(value, "key value")}`;
|
|
81
83
|
|
|
82
84
|
const entriesOf = (row) => Object.entries(row ?? {});
|
|
83
85
|
|
|
@@ -123,13 +125,16 @@ const buildDelete = (tableName, row, dbIndex, deletedBy, tenantIds) => {
|
|
|
123
125
|
|
|
124
126
|
/* A row whose primary key is null is unambiguously new -> a plain INSERT with the key omitted, so
|
|
125
127
|
the sequence/GUID default supplies it. Otherwise an upsert on the primary key. */
|
|
126
|
-
const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext) => {
|
|
128
|
+
const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext, returningKey = false) => {
|
|
127
129
|
/* Doc 3 §11 — resolve the tenant ONCE for the whole statement: the INSERT branches stamp it and
|
|
128
130
|
the UPDATE branch is constrained by it. Null when there is nothing to enforce, in which case
|
|
129
131
|
every branch below emits exactly what it emitted before tenancy existed. */
|
|
130
132
|
const rawEntries = entriesOf(row);
|
|
131
133
|
const primaryKeyRaw = rawEntries.find(([column]) => column.toLowerCase() === PRIMARY_KEY_COLUMN);
|
|
132
|
-
|
|
134
|
+
/* ⚠️ A row with NO key column is new too - there is nothing to upsert on. It used to fall into the
|
|
135
|
+
upsert branch and crash on the missing key (a 500); a v2 master saved new with no collections sends
|
|
136
|
+
exactly that. tests/orm-keyless-insert.test.js. */
|
|
137
|
+
const isNew = !primaryKeyRaw || primaryKeyRaw[1] === null || primaryKeyRaw[1] === undefined;
|
|
133
138
|
/* ⚠️ `isNew` decides whether an absent tenant is fatal. A keyed row is an UPDATE as far as this
|
|
134
139
|
statement is concerned; the case where it turns out NOT to exist yet is caught by the async
|
|
135
140
|
authority check in index.js, which is the only layer that can know. */
|
|
@@ -154,9 +159,11 @@ const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext) => {
|
|
|
154
159
|
|
|
155
160
|
if (isNew) {
|
|
156
161
|
const insertable = entries.filter(([column]) => column.toLowerCase() !== PRIMARY_KEY_COLUMN);
|
|
162
|
+
/* v2 hands a new master its key back, as the master-detail path does - without it the client reloads
|
|
163
|
+
the record as NEW and a second save inserts a duplicate. tests/orm-keyless-insert.test.js. */
|
|
157
164
|
return `INSERT INTO ${tableName} (${insertable.map(([c]) => c).join(", ")}) VALUES (${insertable
|
|
158
165
|
.map(([c, v]) => renderValue(v, isBool(c)))
|
|
159
|
-
.join(", ")})`;
|
|
166
|
+
.join(", ")})${returningKey ? ` RETURNING ${PRIMARY_KEY_COLUMN}` : ""}`;
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
/* UPDATE first, INSERT only if it matched nothing.
|
|
@@ -210,7 +217,9 @@ const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext) => {
|
|
|
210
217
|
keep working. TestORM.pas TestPutArray. */
|
|
211
218
|
export const STATEMENT_SEPARATOR = ";eof ";
|
|
212
219
|
|
|
213
|
-
export const buildCrudSql = (method, object, dbIndex, deletedBy, tenantIds, tenantContext) => {
|
|
220
|
+
export const buildCrudSql = (method, object, dbIndex, deletedBy, tenantIds, tenantContext, { returningKey = false } = {}) => {
|
|
221
|
+
/* Every table and column NAME the caller supplied, before any of them reaches SQL text. */
|
|
222
|
+
assertPayloadIdentifiers(object);
|
|
214
223
|
const [tableName, payload] = Object.entries(object ?? {})[0] ?? [];
|
|
215
224
|
if (!tableName) {
|
|
216
225
|
return "";
|
|
@@ -239,7 +248,7 @@ export const buildCrudSql = (method, object, dbIndex, deletedBy, tenantIds, tena
|
|
|
239
248
|
.map((row) => buildUpsert(tableName, row, dbIndex, tenantIds, tenantContext))
|
|
240
249
|
.join(STATEMENT_SEPARATOR);
|
|
241
250
|
}
|
|
242
|
-
return buildUpsert(tableName, payload, dbIndex, tenantIds, tenantContext);
|
|
251
|
+
return buildUpsert(tableName, payload, dbIndex, tenantIds, tenantContext, returningKey);
|
|
243
252
|
};
|
|
244
253
|
|
|
245
254
|
/* orm.pas:1695-1745 (lookupToSQLCustom). Both forms honour `lookup.filter`; only the page form
|
|
@@ -251,7 +260,9 @@ const buildLookupWhere = (lookup) => {
|
|
|
251
260
|
if (filter === undefined || filter === null || String(filter) === "null" || String(filter) === "") {
|
|
252
261
|
return "";
|
|
253
262
|
}
|
|
254
|
-
|
|
263
|
+
/* A raw where string by contract; refused only for structure that would escape the parentheses
|
|
264
|
+
the platform wraps it in (sqlGuard.assertSqlFragment). */
|
|
265
|
+
return ` where ${assertSqlFragment(filter, "lookup filter")}`;
|
|
255
266
|
};
|
|
256
267
|
|
|
257
268
|
/*
|
|
@@ -272,6 +283,7 @@ const lookupWhereWithSoftDelete = (lookup, dbIndex, tenantIds) => {
|
|
|
272
283
|
};
|
|
273
284
|
|
|
274
285
|
export const buildLookupSql = (lookup, dbIndex, tenantIds) => {
|
|
286
|
+
assertLookupShape(lookup);
|
|
275
287
|
const order = Array.isArray(lookup?.order) && lookup.order.length > 0
|
|
276
288
|
? buildOrderClause(lookup.order).replace(/^ ORDER BY /, " order by ")
|
|
277
289
|
: " order by 2";
|
|
@@ -282,7 +294,16 @@ export const buildLookupSql = (lookup, dbIndex, tenantIds) => {
|
|
|
282
294
|
);
|
|
283
295
|
};
|
|
284
296
|
|
|
285
|
-
|
|
286
|
-
|
|
297
|
+
/* key and display may be expressions (`name || ' - ' || code`); the table may not. */
|
|
298
|
+
const assertLookupShape = (lookup) => {
|
|
299
|
+
assertQualifiedIdentifier(lookup?.table, "lookup table");
|
|
300
|
+
assertSqlFragment(lookup?.key, "lookup key");
|
|
301
|
+
assertSqlFragment(lookup?.display, "lookup display");
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export const buildLookupTotalSql = (lookup, dbIndex, tenantIds) => {
|
|
305
|
+
assertLookupShape(lookup);
|
|
306
|
+
return `select count(*) total from ${lookup.table}${lookupWhereWithSoftDelete(lookup, dbIndex, tenantIds)}`;
|
|
307
|
+
};
|
|
287
308
|
|
|
288
309
|
export const buildGenIdSql = (sequenceName) => buildSequenceNextValue(sequenceName);
|
|
@@ -125,6 +125,111 @@ export const buildRelationshipMap = (parsedDomainConfigs = []) => {
|
|
|
125
125
|
return map;
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
/*
|
|
129
|
+
* The FK a one-to-many relationship DECLARES, keyed parentTable|childTable.
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ Entity keys are resolved to their TABLES here. The ownership map above keys on the raw names,
|
|
132
|
+
* which works only because real configs happen to name entities after their tables.
|
|
133
|
+
*/
|
|
134
|
+
export const buildForeignKeyMap = (parsedDomainConfigs = []) => {
|
|
135
|
+
const map = new Map();
|
|
136
|
+
for (const parsed of parsedDomainConfigs) {
|
|
137
|
+
const entities = parsed?.model?.entities ?? {};
|
|
138
|
+
const relationships = parsed?.model?.relationships;
|
|
139
|
+
if (!relationships || typeof relationships !== "object") continue;
|
|
140
|
+
const tableOf = (name) => String(entities?.[name]?.table ?? name ?? "").trim().toLowerCase();
|
|
141
|
+
for (const relationship of Object.values(relationships)) {
|
|
142
|
+
const foreignKey = String(relationship?.foreignKey ?? "").trim();
|
|
143
|
+
const from = tableOf(relationship?.from);
|
|
144
|
+
const to = tableOf(relationship?.to);
|
|
145
|
+
if (!foreignKey || !from || !to) continue;
|
|
146
|
+
map.set(`${from}|${to}`, foreignKey);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return map;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* The ORDER a one-to-many relationship DECLARES for its rows (`orderBy: 'sequence_no'`), keyed
|
|
154
|
+
* parentTable|childTable like the FK map and resolved to tables the same way. Doc 2 section 5: a
|
|
155
|
+
* v2 screen shows a collection in the order the graph read returns it, and nothing else orders it.
|
|
156
|
+
*/
|
|
157
|
+
export const buildOrderByMap = (parsedDomainConfigs = []) => {
|
|
158
|
+
const map = new Map();
|
|
159
|
+
for (const parsed of parsedDomainConfigs) {
|
|
160
|
+
const entities = parsed?.model?.entities ?? {};
|
|
161
|
+
const relationships = parsed?.model?.relationships;
|
|
162
|
+
if (!relationships || typeof relationships !== "object") continue;
|
|
163
|
+
const tableOf = (name) => String(entities?.[name]?.table ?? name ?? "").trim().toLowerCase();
|
|
164
|
+
for (const relationship of Object.values(relationships)) {
|
|
165
|
+
const orderBy = String(relationship?.orderBy ?? "").trim();
|
|
166
|
+
const from = tableOf(relationship?.from);
|
|
167
|
+
const to = tableOf(relationship?.to);
|
|
168
|
+
if (!orderBy || !from || !to) continue;
|
|
169
|
+
map.set(`${from}|${to}`, orderBy);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return map;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/* The declared order of a parent's collection, or null. Unchecked here - readRows checks it as
|
|
176
|
+
identifiers before it reaches SQL (sqlGuard.js). */
|
|
177
|
+
/*
|
|
178
|
+
* Doc 2 section 5.2 item 5 - the OWNED children of each table: one-to-many relationships declared
|
|
179
|
+
* ownership: parent, keyed by parent TABLE, each child resolved to its table, in declaration order.
|
|
180
|
+
* A v2 delete cascades through exactly these (masterDetail.buildGraphDeleteSql) - never through a
|
|
181
|
+
* shared or independent relationship, whose rows only REFERENCE the parent.
|
|
182
|
+
*/
|
|
183
|
+
export const buildOwnedChildrenMap = (parsedDomainConfigs = []) => {
|
|
184
|
+
const map = new Map();
|
|
185
|
+
for (const parsed of parsedDomainConfigs) {
|
|
186
|
+
const entities = parsed?.model?.entities ?? {};
|
|
187
|
+
const relationships = parsed?.model?.relationships;
|
|
188
|
+
if (!relationships || typeof relationships !== "object") continue;
|
|
189
|
+
const tableOf = (name) => String(entities?.[name]?.table ?? name ?? "").trim().toLowerCase();
|
|
190
|
+
for (const relationship of Object.values(relationships)) {
|
|
191
|
+
if (String(relationship?.type ?? "").trim().toLowerCase() !== "one-to-many") continue;
|
|
192
|
+
if (String(relationship?.ownership ?? "").trim().toLowerCase() !== "parent") continue;
|
|
193
|
+
const from = tableOf(relationship?.from);
|
|
194
|
+
const to = tableOf(relationship?.to);
|
|
195
|
+
if (!from || !to) continue;
|
|
196
|
+
const children = map.get(from) ?? [];
|
|
197
|
+
if (!children.includes(to)) children.push(to);
|
|
198
|
+
map.set(from, children);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return map;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/* A table's owned children with the FK each is found by - the SAME foreignKeyFor the graph load and
|
|
205
|
+
save use, so a delete can never correlate through a column the save does not. */
|
|
206
|
+
export const ownedChildrenOf = (parentTable, dbIndex) =>
|
|
207
|
+
(byDbIndex.get(Number(dbIndex))?.ownedChildren?.get(String(parentTable ?? "").trim().toLowerCase()) ?? []).map(
|
|
208
|
+
(table) => ({ table, foreignKey: foreignKeyFor(parentTable, table, dbIndex) }),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
export const orderByFor = (parentTable, childTable, dbIndex) =>
|
|
212
|
+
byDbIndex
|
|
213
|
+
.get(Number(dbIndex))
|
|
214
|
+
?.orderBy?.get(
|
|
215
|
+
`${String(parentTable ?? "").trim().toLowerCase()}|${String(childTable ?? "").trim().toLowerCase()}`,
|
|
216
|
+
) ?? null;
|
|
217
|
+
|
|
218
|
+
/*
|
|
219
|
+
* ⚠️⚠️ THE FK A v2 GRAPH USES - load (graphRead.js) AND save (changeInference.js), through this one
|
|
220
|
+
* function so the two can never disagree. Declared first; the orm.pas `<parent>_ID` convention only
|
|
221
|
+
* when nothing is declared. It must be declared-first because the convention cannot always hold:
|
|
222
|
+
* Supertail's rel_item_add_info_options uses st_add_info_tmpl_det_id, since the conventional name is
|
|
223
|
+
* one character over Firebird 2.5's 31-character limit. v1 (Delphi parity) keeps the convention.
|
|
224
|
+
* The convention is spelled exactly as masterDetail.foreignKeyColumn spells it (fenced in the test).
|
|
225
|
+
*/
|
|
226
|
+
export const foreignKeyFor = (parentTable, childTable, dbIndex) =>
|
|
227
|
+
byDbIndex
|
|
228
|
+
.get(Number(dbIndex))
|
|
229
|
+
?.foreignKeys?.get(
|
|
230
|
+
`${String(parentTable ?? "").trim().toLowerCase()}|${String(childTable ?? "").trim().toLowerCase()}`,
|
|
231
|
+
) ?? `${parentTable}_ID`;
|
|
232
|
+
|
|
128
233
|
/*
|
|
129
234
|
* Doc 3 §11 — which tables are scoped by a tenant, and by which column.
|
|
130
235
|
*
|
|
@@ -238,9 +343,12 @@ export const primeEntityConfig = async ({ dbIndex, loadDomainConfigs }) => {
|
|
|
238
343
|
const configs = await loadDomainConfigs();
|
|
239
344
|
const map = buildEntityMap(configs);
|
|
240
345
|
const relationships = buildRelationshipMap(configs);
|
|
346
|
+
const foreignKeys = buildForeignKeyMap(configs);
|
|
347
|
+
const orderBy = buildOrderByMap(configs);
|
|
348
|
+
const ownedChildren = buildOwnedChildrenMap(configs);
|
|
241
349
|
const tenancy = buildTenancyMap(configs);
|
|
242
350
|
const booleanColumns = buildBooleanColumnMap(configs);
|
|
243
|
-
byDbIndex.set(Number(dbIndex), { entities: map, relationships, tenancy, booleanColumns });
|
|
351
|
+
byDbIndex.set(Number(dbIndex), { entities: map, relationships, foreignKeys, orderBy, ownedChildren, tenancy, booleanColumns });
|
|
244
352
|
|
|
245
353
|
/* ⚠️ Report the COUNT, not just success. "Everything is hard" is the correct answer when no
|
|
246
354
|
entity declares `soft`, and it is also exactly what a silently empty map returns — the two
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Doc 2 section 5.2 item 1 - load a master WITH its details and subdetails, in one request.
|
|
3
|
+
*
|
|
4
|
+
* The request is the shape the save already takes, collections left empty to mean "include":
|
|
5
|
+
*
|
|
6
|
+
* get, version 2, { st_customer: { id: "'{C1}'", name: "", st_customer_identity: [] } }
|
|
7
|
+
*
|
|
8
|
+
* and a subdetail is named inside the collection's first row. The master is read by the ordinary
|
|
9
|
+
* `get` (so its projection, soft-delete and tenant scoping and its section 6.7 concurrency token are
|
|
10
|
+
* exactly what they always were); only the collections are new.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️⚠️ EVERY COLLECTION IS READ THROUGH THE SAVE'S OWN COMPARISON READER (changeInference
|
|
13
|
+
* createReaders.readRows) - passed in, never re-implemented. Deletion on save is inferred from
|
|
14
|
+
* absence (Doc 3 section 6.2), so if the load and the comparison ever see different rows, the next
|
|
15
|
+
* save deletes the difference silently. Sharing the reader makes them the same set by construction:
|
|
16
|
+
* tenant scope, soft delete, and anything added to that reader later.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The FK is found the way the save finds it - entityConfig.foreignKeyFor, the declared foreignKey
|
|
19
|
+
* first and the orm.pas `<parent>_ID` convention otherwise. A load that followed a different rule
|
|
20
|
+
* from the save would be the same drift by another route.
|
|
21
|
+
*/
|
|
22
|
+
import { detailEntries } from "./masterDetail.js";
|
|
23
|
+
import { PRIMARY_KEY_COLUMN } from "./crudQuery.js";
|
|
24
|
+
import { assertOwnedCollection, foreignKeyFor, orderByFor } from "./entityConfig.js";
|
|
25
|
+
import { isConfigV2 } from "./configVersion.js";
|
|
26
|
+
|
|
27
|
+
export const GRAPH_READ_KEY_REQUIRED_CODE = "GRAPH_READ_KEY_REQUIRED";
|
|
28
|
+
|
|
29
|
+
/* A get at version 2 that names at least one collection, and is not the Delphi `getd` (`detail` key). */
|
|
30
|
+
export const isGraphRead = ({ method, version, object }) =>
|
|
31
|
+
String(method ?? "").toLowerCase() === "get" &&
|
|
32
|
+
isConfigV2(version) &&
|
|
33
|
+
object?.detail === undefined &&
|
|
34
|
+
Object.entries(object ?? {}).some(([, payload]) =>
|
|
35
|
+
(Array.isArray(payload) ? payload : [payload]).some((row) => detailEntries(row).length > 0),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const keyOf = (row) => {
|
|
39
|
+
const column = Object.keys(row ?? {}).find((k) => k.toLowerCase() === PRIMARY_KEY_COLUMN);
|
|
40
|
+
return column === undefined ? undefined : row[column];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const templateOf = (rows) =>
|
|
44
|
+
Array.isArray(rows) && rows[0] && typeof rows[0] === "object" ? rows[0] : {};
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* Refuse the WHOLE shape before anything is read. The save will not manage a shared collection
|
|
48
|
+
* (Doc 3 section 6.3); a load that handed one out would give a screen a graph it can never save.
|
|
49
|
+
*/
|
|
50
|
+
const assertShape = (parentTable, templateRow, dbIndex) => {
|
|
51
|
+
for (const [table, rows] of detailEntries(templateRow)) {
|
|
52
|
+
assertOwnedCollection(parentTable, table, dbIndex);
|
|
53
|
+
assertShape(table, templateOf(rows), dbIndex);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* A graph read is the load of ONE record. Without a key the master read is every row, and each
|
|
59
|
+
* collection would multiply that - so it is refused by name instead of issuing N+1 reads.
|
|
60
|
+
*/
|
|
61
|
+
export const assertGraphRequest = (object, dbIndex) => {
|
|
62
|
+
for (const [table, payload] of Object.entries(object ?? {})) {
|
|
63
|
+
if (table === "detail") continue;
|
|
64
|
+
for (const row of Array.isArray(payload) ? payload : [payload]) {
|
|
65
|
+
const key = keyOf(row);
|
|
66
|
+
if (key === undefined || key === null || String(key).trim() === "") {
|
|
67
|
+
throw Object.assign(
|
|
68
|
+
new Error(`a graph read of ${table} needs its key - it loads one record, with its collections`),
|
|
69
|
+
{ code: GRAPH_READ_KEY_REQUIRED_CODE },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
assertShape(table, row, dbIndex);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const attach = async (parentTable, dbRow, templateRow, readRows, dbIndex) => {
|
|
78
|
+
for (const [table, templateRows] of detailEntries(templateRow)) {
|
|
79
|
+
const key = keyOf(dbRow);
|
|
80
|
+
const rows =
|
|
81
|
+
key === undefined || key === null ? [] : (await readRows(table, foreignKeyFor(parentTable, table, dbIndex), key, orderByFor(parentTable, table, dbIndex))) ?? [];
|
|
82
|
+
for (const row of rows) await attach(table, row, templateOf(templateRows), readRows, dbIndex);
|
|
83
|
+
dbRow[table] = rows;
|
|
84
|
+
}
|
|
85
|
+
return dbRow;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/* `masterRows` are what the ordinary get returned for the master table. Mutated and returned. */
|
|
89
|
+
export const attachCollections = async ({ object, masterRows, readRows, dbIndex }) => {
|
|
90
|
+
const [masterTable, payload] = Object.entries(object ?? {}).find(([key]) => key !== "detail") ?? [];
|
|
91
|
+
const template = Array.isArray(payload) ? templateOf(payload) : payload ?? {};
|
|
92
|
+
for (const row of masterRows ?? []) await attach(masterTable, row, template, readRows, dbIndex);
|
|
93
|
+
return masterRows;
|
|
94
|
+
};
|
package/engine/orm/index.js
CHANGED
|
@@ -20,9 +20,11 @@ import {
|
|
|
20
20
|
buildGenIdSql,
|
|
21
21
|
PRIMARY_KEY_COLUMN,
|
|
22
22
|
} from "./crudQuery.js";
|
|
23
|
-
import { buildMasterDetailChangeSql, hasDetailCollection } from "./masterDetail.js";
|
|
23
|
+
import { buildMasterDetailChangeSql, buildGraphDeleteSql, hasDetailCollection } from "./masterDetail.js";
|
|
24
24
|
import { inferChanges } from "./changeInference.js";
|
|
25
|
+
import { isConfigV2 } from "./configVersion.js";
|
|
25
26
|
import { checkRowVersion } from "./concurrency.js";
|
|
27
|
+
import { assertCompleteCollections } from "./completeness.js";
|
|
26
28
|
import { tenantColumnFor, resolveWriteTenant, CROSS_TENANT_CODE } from "./entityConfig.js";
|
|
27
29
|
|
|
28
30
|
const parseRequest = (param) => (typeof param === "string" ? JSON.parse(param) : (param ?? {}));
|
|
@@ -208,15 +210,30 @@ export const buildWriteSql = async ({
|
|
|
208
210
|
"conflict" would also confirm to the caller that a row it may not see exists and has changed.
|
|
209
211
|
This is also the only place a cross-tenant write gets a NAMED refusal: the SQL constraints
|
|
210
212
|
downstream make such a write do nothing, which is safe but silent. */
|
|
211
|
-
|
|
213
|
+
/* Doc 2 section 5.5 ruling 5 - FIRST, before anything is read: a collection marked incomplete would
|
|
214
|
+
otherwise have its missing rows inferred as Deleted. Returns the payload with every mark stripped. */
|
|
215
|
+
const complete = assertCompleteCollections(object);
|
|
216
|
+
|
|
217
|
+
const tenantContext = await assertTenantAuthority({ object: complete, dbIndex, tenantIds, readTenant });
|
|
212
218
|
|
|
213
219
|
/* Doc 3 §6.7 — reject a save whose data has moved since it was loaded, BEFORE any SQL is built,
|
|
214
220
|
so a conflict writes nothing. This sits above the version split on purpose: the token is an
|
|
215
221
|
opt-in property of the payload, not of the persistence semantics, so a v1 save made from a
|
|
216
222
|
freshly-loaded form is protected too. A payload with no token reads nothing and is unchanged. */
|
|
217
|
-
const checked = await checkRowVersion({ object, readMaster });
|
|
223
|
+
const checked = await checkRowVersion({ object: complete, readMaster });
|
|
224
|
+
|
|
225
|
+
/* ⚠️⚠️ `put` ONLY - change inference is the SAVE semantic. This used to admit every method except
|
|
226
|
+
`delete`, so a `get` at version 2 carrying an empty collection was planned as a save: blank master
|
|
227
|
+
fields became an UPDATE and the empty array meant every detail row was Deleted. A read that
|
|
228
|
+
deletes. Unreachable only until a client sent `version` on a get, which Doc 2 section 5 does.
|
|
229
|
+
tests/orm-graph-read.test.js. */
|
|
230
|
+
/* Doc 2 section 5.2 item 5 - a v2 delete cascades by CONFIG (ownership: parent, declared FKs), not by
|
|
231
|
+
what the client names. v1 delete stays one row. tests/orm-v2-delete.test.js. */
|
|
232
|
+
if (isConfigV2(version) && String(method ?? "").toLowerCase() === "delete") {
|
|
233
|
+
return buildGraphDeleteSql(checked, dbIndex, deletedBy, tenantIds);
|
|
234
|
+
}
|
|
218
235
|
|
|
219
|
-
const wantsInference =
|
|
236
|
+
const wantsInference = isConfigV2(version) && String(method ?? "").toLowerCase() === "put";
|
|
220
237
|
if (wantsInference && hasDetailCollection(checked)) {
|
|
221
238
|
return buildMasterDetailChangeSql(
|
|
222
239
|
await inferChanges({ object: checked, readRows, readMaster, dbIndex }),
|
|
@@ -229,7 +246,7 @@ export const buildWriteSql = async ({
|
|
|
229
246
|
/* ⚠️ `tenantIds` reaches buildCrudSql here as well as through jsonToSql. This is the FOURTH place
|
|
230
247
|
today that quietly dropped a parameter it never declared; the `get` path runs through here, so
|
|
231
248
|
omitting it left reads unfiltered while every unit test that called buildCrudSql directly passed. */
|
|
232
|
-
return buildCrudSql(method, checked, dbIndex, deletedBy, tenantIds, tenantContext);
|
|
249
|
+
return buildCrudSql(method, checked, dbIndex, deletedBy, tenantIds, tenantContext, { returningKey: isConfigV2(version) });
|
|
233
250
|
};
|
|
234
251
|
|
|
235
252
|
/* Delphi: TOrm.lookupToSql / lookupTotalToSql (Doc 3 §5 item 1). */
|
package/engine/orm/listQuery.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* orm.pas jsonToListSQL. Produces both the page query and the record-count query, matching the
|
|
4
4
|
* Delphi TListRecord (QuerySQL / recordsTotalSQL).
|
|
5
5
|
*/
|
|
6
|
+
import { assertSqlFragment, assertSearchLiteral, normaliseFilterOperator } from "./sqlGuard.js";
|
|
6
7
|
import { buildPagingClause } from "./dialect.js";
|
|
7
8
|
import { isPostgresIndex } from "../domain/dialect.js";
|
|
8
9
|
import { softDeleteFilter, tenantFilter, isBooleanColumn } from "./entityConfig.js";
|
|
@@ -176,9 +177,9 @@ const buildCondition = (filter, registry, dbIndex) => {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
const junction = String(filter?.junction ?? "");
|
|
179
|
-
const rawColumn = String(filter?.column ?? "");
|
|
180
|
+
const rawColumn = String(assertSqlFragment(filter?.column ?? "", "filter column"));
|
|
180
181
|
const column = rawColumn === "" || isRawExpression(rawColumn) ? rawColumn : registry.resolve(rawColumn) ?? rawColumn;
|
|
181
|
-
const operator = String(filter?.operator ?? "");
|
|
182
|
+
const operator = String(normaliseFilterOperator(filter?.operator ?? ""));
|
|
182
183
|
|
|
183
184
|
const rendered = `${junction ? ` ${junction}` : ""} ${column} ${operator}`;
|
|
184
185
|
|
|
@@ -192,13 +193,13 @@ const buildCondition = (filter, registry, dbIndex) => {
|
|
|
192
193
|
const lowered = operator.toLowerCase().replace(/\s+/g, " ").trim();
|
|
193
194
|
if ((lowered === CONTAINING || lowered === NOT_CONTAINING) && isPostgresIndex(dbIndex)) {
|
|
194
195
|
const rendered2 = `${junction ? ` ${junction}` : ""} ${column} ${lowered === NOT_CONTAINING ? "NOT ILIKE" : "ILIKE"}`;
|
|
195
|
-
return `${rendered2} ${toContainingPattern(filter.value1)}`;
|
|
196
|
+
return `${rendered2} ${toContainingPattern(assertSearchLiteral(filter.value1, "containing value"))}`;
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
if (operator.toLowerCase() === "between") {
|
|
199
|
-
return `${rendered} ${filter.value1} AND ${filter.value2}`;
|
|
200
|
+
return `${rendered} ${assertSqlFragment(filter.value1, "value1")} AND ${assertSqlFragment(filter.value2, "value2")}`;
|
|
200
201
|
}
|
|
201
|
-
return `${rendered} ${coerceBooleanFilterValue(rawColumn, filter.value1, dbIndex)}`;
|
|
202
|
+
return `${rendered} ${coerceBooleanFilterValue(rawColumn, assertSqlFragment(filter.value1, "value1"), dbIndex)}`;
|
|
202
203
|
};
|
|
203
204
|
|
|
204
205
|
const buildWhereClause = (filters, registry, dbIndex) => {
|
|
@@ -215,7 +216,7 @@ export const buildOrderClause = (orders) => {
|
|
|
215
216
|
const parts = (Array.isArray(orders) ? orders : []).map((order) => {
|
|
216
217
|
const dir = String(order?.dir ?? "").toLowerCase();
|
|
217
218
|
const direction = dir === "asc" || dir === "desc" ? ` ${dir.toUpperCase()}` : "";
|
|
218
|
-
return `${order?.column ?? ""}${direction}`;
|
|
219
|
+
return `${assertSqlFragment(order?.column ?? "", "order column")}${direction}`;
|
|
219
220
|
});
|
|
220
221
|
return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
|
|
221
222
|
};
|
|
@@ -265,7 +266,10 @@ export const buildListSql = (object = {}, dbIndex, tenantIds) => {
|
|
|
265
266
|
extra.length === 0
|
|
266
267
|
? baseWhere
|
|
267
268
|
: baseWhere
|
|
268
|
-
|
|
269
|
+
/* ⚠️⚠️ The caller's conditions are PARENTHESISED before the platform's predicates. Appended
|
|
270
|
+
bare, an ordinary OR filter (`a = 1 or a = 2`) bound tighter than nothing and returned
|
|
271
|
+
soft-deleted rows - no attacker needed. Lookup already did this. tests/orm-sql-guard. */
|
|
272
|
+
? ` WHERE (${baseWhere.replace(/^ WHERE */, "")}) AND ${extra.join(" AND ")}`
|
|
269
273
|
: ` WHERE ${extra.join(" AND ")}`;
|
|
270
274
|
const from = ` FROM ${registry.baseTable}${registry.renderJoins()}`;
|
|
271
275
|
/* orm.pas:1504 compares case-INsensitively (CompareText), and 1521-1523 sits outside the
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* discarded. The FK is always the parent's real id, threaded from the parent CTE — never a value
|
|
21
21
|
* the payload asserts.
|
|
22
22
|
*/
|
|
23
|
+
import { assertKeyLiteral } from "./sqlGuard.js";
|
|
23
24
|
import { PRIMARY_KEY_COLUMN, STATEMENT_SEPARATOR } from "./crudQuery.js";
|
|
24
25
|
import {
|
|
25
26
|
softDeleteFilter,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
stampTenant,
|
|
31
32
|
withTenant,
|
|
32
33
|
isBooleanColumn,
|
|
34
|
+
ownedChildrenOf,
|
|
33
35
|
} from "./entityConfig.js";
|
|
34
36
|
import { renderTimestamp } from "./dialect.js";
|
|
35
37
|
|
|
@@ -342,7 +344,10 @@ const collectAddedSubtree = (rowPlan, tableName, parentRef, ctx) => {
|
|
|
342
344
|
const plainInsert = (rowPlan, tableName, parentRef, ctx) => {
|
|
343
345
|
const { columnList, values } = insertColumns(rowPlan, parentRef, tableName, ctx);
|
|
344
346
|
const valueList = [...values, ...(parentRef.fkColumn ? [parentRef.keyValue] : [])].join(", ");
|
|
345
|
-
|
|
347
|
+
/* A MASTER returns its key: the caller has to be able to find the record it just created (Doc 2
|
|
348
|
+
section 5 reloads it). A detail stays plain - nothing downstream needs its key back. */
|
|
349
|
+
const returning = parentRef.fkColumn ? "" : ` RETURNING ${PRIMARY_KEY_COLUMN}`;
|
|
350
|
+
return `INSERT INTO ${tableName} (${columnList}) VALUES (${valueList})${returning}`;
|
|
346
351
|
};
|
|
347
352
|
|
|
348
353
|
const hasAddedDescendants = (rowPlan) =>
|
|
@@ -416,8 +421,11 @@ const walkPlan = (rowPlan, tableName, parentRef, out) => {
|
|
|
416
421
|
const softDeleteOrDelete = (table, where, dbIndex, deletedBy, tenantIds) => {
|
|
417
422
|
const scopeTenant = tenantFilter(table, dbIndex, tenantIds);
|
|
418
423
|
const scoped = scopeTenant ? `${where} AND ${scopeTenant}` : where;
|
|
424
|
+
/* ⚠️ A soft delete touches only LIVE rows. Without this, deleting an already-deleted row stamped it
|
|
425
|
+
again - `deleted_at` and `deleted_by` rewritten to now and to whoever asked, on every v2 save and
|
|
426
|
+
every repeated deletemd. Soft delete exists to keep that record; this is what keeps it. */
|
|
419
427
|
return isSoftDelete(table, dbIndex)
|
|
420
|
-
? `UPDATE ${table} SET ${buildSoftDeleteSet(deletedBy)} WHERE ${scoped}`
|
|
428
|
+
? `UPDATE ${table} SET ${buildSoftDeleteSet(deletedBy)} WHERE ${scoped} AND ${softDeleteFilter(table, dbIndex)}`
|
|
421
429
|
: `DELETE FROM ${table} WHERE ${scoped}`;
|
|
422
430
|
};
|
|
423
431
|
|
|
@@ -440,6 +448,37 @@ export const buildMasterDetailChangeSql = (plan, dbIndex, deletedBy, tenantIds,
|
|
|
440
448
|
return [...out.deletes, ...out.updates, ...out.inserts].join(STATEMENT_SEPARATOR);
|
|
441
449
|
};
|
|
442
450
|
|
|
451
|
+
/*
|
|
452
|
+
* Doc 2 section 5.2 item 5 - a v2 DELETE of a master, cascading by CONFIG.
|
|
453
|
+
*
|
|
454
|
+
* Unlike deletemd, the client names nothing: the cascade is every ownership:parent relationship at any
|
|
455
|
+
* depth (entityConfig.ownedChildrenOf), through the DECLARED foreign keys. Deepest first, each table by
|
|
456
|
+
* its OWN strategy (softDeleteOrDelete - soft/hard and tenant scope, Doc 3 sections 6.6 and 11), the
|
|
457
|
+
* master last. A table already on the path is not re-entered, so an owned self-reference terminates.
|
|
458
|
+
* tests/orm-v2-delete.test.js.
|
|
459
|
+
*/
|
|
460
|
+
export const buildGraphDeleteSql = (object, dbIndex, deletedBy, tenantIds) => {
|
|
461
|
+
const [masterTable, masterRow] = Object.entries(object ?? {})[0] ?? [];
|
|
462
|
+
const keyEntry = Object.entries(masterRow ?? {}).find(([column]) => isPrimaryKey(column));
|
|
463
|
+
if (!masterTable || !keyEntry || !hasRealKey(masterRow)) {
|
|
464
|
+
throw new Error(`a v2 delete of ${masterTable ?? "a table"} needs the key of the record to delete`);
|
|
465
|
+
}
|
|
466
|
+
const statements = [];
|
|
467
|
+
const walk = (parentTable, correlation, path) => {
|
|
468
|
+
for (const { table, foreignKey } of ownedChildrenOf(parentTable, dbIndex)) {
|
|
469
|
+
if (path.includes(table.toLowerCase())) continue;
|
|
470
|
+
const here = `${foreignKey} ${correlation}`;
|
|
471
|
+
walk(table, `IN (SELECT ${PRIMARY_KEY_COLUMN} FROM ${table} WHERE ${here})`, [...path, table.toLowerCase()]);
|
|
472
|
+
statements.push(softDeleteOrDelete(table, here, dbIndex, deletedBy, tenantIds));
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
walk(masterTable, `= ${renderValue(keyEntry[1])}`, [String(masterTable).toLowerCase()]);
|
|
476
|
+
statements.push(
|
|
477
|
+
softDeleteOrDelete(masterTable, `${PRIMARY_KEY_COLUMN} = ${renderValue(keyEntry[1])}`, dbIndex, deletedBy, tenantIds),
|
|
478
|
+
);
|
|
479
|
+
return statements.join(STATEMENT_SEPARATOR);
|
|
480
|
+
};
|
|
481
|
+
|
|
443
482
|
/* deletemd — deepest detail first, each correlated to the master through its parent chain. */
|
|
444
483
|
export const buildDeleteMasterDetailSql = (object, dbIndex, deletedBy, tenantIds) => {
|
|
445
484
|
const [masterTable, masterRow] = Object.entries(object ?? {})[0] ?? [];
|
|
@@ -508,7 +547,8 @@ export const buildDetailSelectSql = (object, dbIndex, tenantIds) => {
|
|
|
508
547
|
const columns = Object.keys(detailRow)
|
|
509
548
|
.filter((column) => column.toLowerCase() !== masterKey.toLowerCase())
|
|
510
549
|
.map((column) => `${detailTable}.${column}`);
|
|
511
|
-
|
|
550
|
+
/* Verbatim by contract, like every get key - so exactly one literal (sqlGuard.js). */
|
|
551
|
+
const masterValue = assertKeyLiteral((masterRow ?? {})[masterValueKey], `${masterTable}.${masterValueKey}`);
|
|
512
552
|
/* Doc 3 §6.6 rule 2 — a soft-deleted detail must not reappear when its master is read. */
|
|
513
553
|
const hideDeleted = softDeleteFilter(detailTable, dbIndex);
|
|
514
554
|
/* Doc 3 §11 — and a detail belonging to another tenant must not reappear either. A detail is
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What request input may reach the ORM's SQL text, and in what shape.
|
|
3
|
+
*
|
|
4
|
+
* The ported ORM keeps FINA's contract: table names, column names, key values and filter fragments
|
|
5
|
+
* arrive from the caller and are interpolated AS WRITTEN (crudQuery.renderKeyValue, listQuery
|
|
6
|
+
* buildCondition). A GUID key arrives pre-quoted; a filter value may be a subquery. That contract is
|
|
7
|
+
* kept - every function here passes a legitimate value through unchanged, byte for byte - and what it
|
|
8
|
+
* refuses is the STRUCTURE no legitimate value needs.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️⚠️ WHAT THIS DEFENDS, HONESTLY. Any authenticated user can already run arbitrary SQL through the
|
|
11
|
+
* `block` endpoint - by design, relied on by the applications. So this is NOT a defence against a
|
|
12
|
+
* malicious logged-in user. It defends against DATA-BORNE injection: a value somebody else controlled
|
|
13
|
+
* (an external member, an import) flowing into a key or filter position, where whoever planted it
|
|
14
|
+
* cannot call `block`.
|
|
15
|
+
*
|
|
16
|
+
* Every refusal throws with `code: UNSAFE_SQL_CODE`, which apiRoute answers with a 400 naming the
|
|
17
|
+
* field - a caller can fix a request it can see is wrong; it cannot fix a bare 500.
|
|
18
|
+
* tests/orm-sql-guard.test.js pins every shape found in real application source.
|
|
19
|
+
*/
|
|
20
|
+
export const UNSAFE_SQL_CODE = "UNSAFE_SQL";
|
|
21
|
+
|
|
22
|
+
const refuse = (what, value, why) =>
|
|
23
|
+
Object.assign(
|
|
24
|
+
new Error(`the ORM refuses ${what} ${JSON.stringify(String(value)).slice(0, 80)}: ${why}`),
|
|
25
|
+
{ code: UNSAFE_SQL_CODE },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/* A plain SQL identifier. `$` stays legal - Firebird-era system tables are SYS$USERS, SYS$CONFIG. */
|
|
29
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
30
|
+
|
|
31
|
+
export const assertIdentifier = (name, what = "identifier") => {
|
|
32
|
+
const text = String(name ?? "");
|
|
33
|
+
if (!IDENTIFIER.test(text)) throw refuse(what, text, "not a plain SQL identifier");
|
|
34
|
+
return name;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/* `schema.table` - each part a plain identifier. The platform itself rewrites a Firebird catalog
|
|
38
|
+
lookup (RDB$RELATIONS) to information_schema.tables, so a lookup table may be qualified. */
|
|
39
|
+
export const assertQualifiedIdentifier = (name, what = "identifier") => {
|
|
40
|
+
const parts = String(name ?? "").split(".");
|
|
41
|
+
if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part))) {
|
|
42
|
+
throw refuse(what, name, "not a plain or schema-qualified SQL identifier");
|
|
43
|
+
}
|
|
44
|
+
return name;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/* Exactly one single-quoted SQL string, inner quotes doubled - the pre-quoted form the contract uses. */
|
|
48
|
+
const ONE_QUOTED_LITERAL = /^'(?:[^']|'')*'$/;
|
|
49
|
+
const NUMERIC = /^-?\d+(\.\d+)?$/;
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* A `get` / `delete` / `getd` key: interpolated verbatim by contract, so it must be ONE literal.
|
|
53
|
+
* Returns the value itself - the caller renders it exactly as before.
|
|
54
|
+
*/
|
|
55
|
+
export const assertKeyLiteral = (value, what = "key") => {
|
|
56
|
+
if (typeof value === "number") {
|
|
57
|
+
if (!Number.isFinite(value)) throw refuse(what, value, "not a finite number");
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === "boolean" || value instanceof Date) return value;
|
|
61
|
+
const text = String(value ?? "").trim();
|
|
62
|
+
if (NUMERIC.test(text) || ONE_QUOTED_LITERAL.test(text)) return value;
|
|
63
|
+
throw refuse(what, value, "a key value must be one literal - a number, or one quoted string");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* A filter or order fragment. The filter language IS an expression language in production use
|
|
68
|
+
* (Trimandiri appends `1 AND NOT EXISTS (SELECT ...)` through value1; Supertail sends DATEADD(...)),
|
|
69
|
+
* so expressions pass. Refused, outside string literals only: a statement terminator, a comment, an
|
|
70
|
+
* unbalanced parenthesis, an unterminated string. None of those appears in any surveyed filter, and
|
|
71
|
+
* each is what turns a filter into something that escapes it - a second statement, a truncated tail
|
|
72
|
+
* that drops the tenant predicate, or a `)` closing the group the platform wraps conditions in.
|
|
73
|
+
*/
|
|
74
|
+
export const assertSqlFragment = (value, what = "fragment") => {
|
|
75
|
+
if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
const text = String(value);
|
|
79
|
+
let depth = 0;
|
|
80
|
+
let quoted = false;
|
|
81
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
82
|
+
const ch = text[i];
|
|
83
|
+
if (quoted) {
|
|
84
|
+
if (ch === "'") {
|
|
85
|
+
if (text[i + 1] === "'") {
|
|
86
|
+
i += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
quoted = false;
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "'") {
|
|
94
|
+
quoted = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === ";") throw refuse(what, text, "a statement terminator");
|
|
98
|
+
if (ch === "-" && text[i + 1] === "-") throw refuse(what, text, "a comment");
|
|
99
|
+
if (ch === "/" && text[i + 1] === "*") throw refuse(what, text, "a comment");
|
|
100
|
+
if (ch === "(") depth += 1;
|
|
101
|
+
if (ch === ")") {
|
|
102
|
+
depth -= 1;
|
|
103
|
+
if (depth < 0) throw refuse(what, text, "a parenthesis closed that was never opened");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (quoted) throw refuse(what, text, "an unterminated string");
|
|
107
|
+
if (depth !== 0) throw refuse(what, text, "an unbalanced parenthesis");
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/*
|
|
112
|
+
* A `containing` search value is always a SEARCH STRING, never an expression: listQuery unwraps its
|
|
113
|
+
* quotes and escapes wildcards but not quotes, so an embedded quote closed the pattern early. One
|
|
114
|
+
* quoted literal, or plain text with no quote at all.
|
|
115
|
+
*/
|
|
116
|
+
export const assertSearchLiteral = (value, what = "search value") => {
|
|
117
|
+
if (value === null || value === undefined) return value;
|
|
118
|
+
const text = String(value).trim();
|
|
119
|
+
if (ONE_QUOTED_LITERAL.test(text) || !text.includes("'")) return value;
|
|
120
|
+
throw refuse(what, value, "a search value must be one quoted string");
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
* Every operator found across biz-a-template (5,787 filters) and the client filter-list component,
|
|
125
|
+
* plus their natural complements. Compared lower-cased with whitespace collapsed, so "Not In" and
|
|
126
|
+
* "CONTAINING" are the operators they read as.
|
|
127
|
+
*/
|
|
128
|
+
const OPERATORS = new Set([
|
|
129
|
+
"", "=", "<>", "!=", "<", "<=", ">", ">=",
|
|
130
|
+
"in", "not in", "like", "not like", "similar to", "not similar to",
|
|
131
|
+
"containing", "not containing", "starting", "starting with", "not starting", "not starting with",
|
|
132
|
+
"is", "is not", "is null", "is not null", "between", "not between", "exists", "not exists",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
export const normaliseFilterOperator = (operator) => {
|
|
136
|
+
const text = String(operator ?? "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
137
|
+
if (!OPERATORS.has(text)) throw refuse("filter operator", operator, "not a recognised operator");
|
|
138
|
+
return operator;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
* Every table and column NAME in a crud payload: the table keys, every row's keys, nested detail
|
|
143
|
+
* collections, and the `detail` spec of a getd. Values are not this function's business.
|
|
144
|
+
*/
|
|
145
|
+
export const assertPayloadIdentifiers = (object) => {
|
|
146
|
+
for (const [table, payload] of Object.entries(object ?? {})) {
|
|
147
|
+
if (table === "detail") {
|
|
148
|
+
const detail = payload;
|
|
149
|
+
if (typeof detail === "string") assertIdentifier(detail, "detail table");
|
|
150
|
+
else if (detail && typeof detail === "object") {
|
|
151
|
+
/* Only when PRESENT: a missing key is buildDetailSelectSql's to name (the Delphi contract's
|
|
152
|
+
own error, TestORMMasterDetail.pas GetDetail_DetailObject_invalid*). */
|
|
153
|
+
if ("tableName" in detail) assertIdentifier(detail.tableName, "detail table");
|
|
154
|
+
if ("masterKey" in detail) assertIdentifier(detail.masterKey, "detail master key");
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
assertIdentifier(table, "table");
|
|
159
|
+
assertRowIdentifiers(table, payload);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const assertRowIdentifiers = (table, payload) => {
|
|
164
|
+
for (const row of Array.isArray(payload) ? payload : [payload]) {
|
|
165
|
+
if (!row || typeof row !== "object") continue;
|
|
166
|
+
for (const [column, value] of Object.entries(row)) {
|
|
167
|
+
/* `*` is the contract's "every column" - ormGet sends it, and the ESB kiosk path relies on it. */
|
|
168
|
+
if (column !== "*") assertIdentifier(column, `column of ${table}`);
|
|
169
|
+
if (Array.isArray(value)) assertRowIdentifiers(column, value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|