tina4-nodejs 3.13.137 → 3.13.138
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/CLAUDE.md +33 -3
- package/COMMERCIAL-LICENSE.md +8 -0
- package/LICENSE +373 -0
- package/NOTICE +11 -0
- package/README.md +24 -2
- package/package.json +44 -6
- package/packages/cli/dist/bin.js +19466 -18048
- package/packages/cli/src/bin.ts +8 -0
- package/packages/cli/src/commands/build.ts +8 -0
- package/packages/cli/src/commands/generate.ts +8 -0
- package/packages/cli/src/commands/init.ts +8 -0
- package/packages/cli/src/commands/lint.ts +8 -0
- package/packages/cli/src/commands/migrate.ts +8 -0
- package/packages/cli/src/commands/migrateCreate.ts +8 -0
- package/packages/cli/src/commands/migrateRollback.ts +8 -0
- package/packages/cli/src/commands/migrateStatus.ts +8 -0
- package/packages/cli/src/commands/queue.ts +8 -0
- package/packages/cli/src/commands/routes.ts +8 -0
- package/packages/cli/src/commands/seed.ts +8 -0
- package/packages/cli/src/commands/serve.ts +11 -0
- package/packages/cli/src/commands/test.ts +8 -0
- package/packages/core/dist/index.js +19458 -18043
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
- package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
- package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
- package/packages/core/public/bundled-component-provenance.json +48 -0
- package/packages/core/public/js/tina4js.min.js +13 -3
- package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
- package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
- package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
- package/packages/core/src/__feedback/widget.js +8 -0
- package/packages/core/src/_missing.ts +8 -0
- package/packages/core/src/ai.ts +8 -0
- package/packages/core/src/aiClient.ts +8 -0
- package/packages/core/src/api.ts +15 -3
- package/packages/core/src/auth.ts +21 -8
- package/packages/core/src/authGate.ts +8 -0
- package/packages/core/src/background.ts +8 -0
- package/packages/core/src/cache.ts +43 -4
- package/packages/core/src/constants.ts +8 -0
- package/packages/core/src/container.ts +8 -0
- package/packages/core/src/context/chunker.ts +8 -0
- package/packages/core/src/context/index.ts +8 -0
- package/packages/core/src/devAdmin.ts +185 -59
- package/packages/core/src/devMailbox.ts +8 -0
- package/packages/core/src/dispatchPipeline.ts +8 -0
- package/packages/core/src/docs.ts +8 -0
- package/packages/core/src/docsAutoDiscovery.ts +8 -0
- package/packages/core/src/docsParser.ts +8 -0
- package/packages/core/src/docsScanner.ts +44 -0
- package/packages/core/src/docsSignatures.ts +8 -0
- package/packages/core/src/dotenv.ts +8 -0
- package/packages/core/src/env.ts +8 -0
- package/packages/core/src/errorOverlay.ts +8 -0
- package/packages/core/src/events.ts +8 -0
- package/packages/core/src/fakeData.ts +8 -0
- package/packages/core/src/feedback.ts +8 -0
- package/packages/core/src/graphql.ts +16 -5
- package/packages/core/src/health.ts +8 -0
- package/packages/core/src/htmlElement.ts +8 -0
- package/packages/core/src/i18n.ts +8 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/job.ts +8 -0
- package/packages/core/src/logger.ts +8 -0
- package/packages/core/src/mcp.test.ts +8 -0
- package/packages/core/src/mcp.ts +8 -0
- package/packages/core/src/messenger.ts +143 -86
- package/packages/core/src/metrics.ts +13 -0
- package/packages/core/src/middleware.ts +1178 -1096
- package/packages/core/src/mqtt.ts +8 -0
- package/packages/core/src/mqttMessage.ts +8 -0
- package/packages/core/src/optionalPackage.ts +48 -0
- package/packages/core/src/plan.ts +8 -0
- package/packages/core/src/portTakeover.ts +8 -0
- package/packages/core/src/projectIndex.ts +8 -0
- package/packages/core/src/projectIndexExtractors.ts +8 -0
- package/packages/core/src/projectIndexStorage.ts +8 -0
- package/packages/core/src/push.ts +8 -0
- package/packages/core/src/queue.ts +8 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
- package/packages/core/src/queueBackends/liteBackend.ts +8 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +21 -8
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
- package/packages/core/src/rateLimiter.ts +8 -0
- package/packages/core/src/request.ts +59 -10
- package/packages/core/src/response.ts +79 -5
- package/packages/core/src/routeDiscovery.ts +8 -0
- package/packages/core/src/router.ts +8 -0
- package/packages/core/src/server.ts +73 -24
- package/packages/core/src/service.ts +8 -0
- package/packages/core/src/session.ts +8 -0
- package/packages/core/src/sessionHandlers/childError.ts +8 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +8 -0
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
- package/packages/core/src/sessionHandlers/respClient.ts +8 -0
- package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
- package/packages/core/src/sso.ts +8 -0
- package/packages/core/src/static.ts +8 -0
- package/packages/core/src/test.ts +8 -0
- package/packages/core/src/testClient.ts +8 -0
- package/packages/core/src/testing.ts +8 -0
- package/packages/core/src/transport.ts +369 -0
- package/packages/core/src/trustedProxy.ts +8 -0
- package/packages/core/src/types.ts +13 -0
- package/packages/core/src/validator.ts +8 -0
- package/packages/core/src/version.ts +8 -0
- package/packages/core/src/websocket.ts +8 -0
- package/packages/core/src/websocketBackplane.ts +49 -32
- package/packages/core/src/websocketConnection.ts +8 -0
- package/packages/core/src/wsdl.ts +197 -115
- package/packages/core/templates/errors/302.twig +8 -0
- package/packages/core/templates/errors/401.twig +8 -0
- package/packages/core/templates/errors/403.twig +8 -0
- package/packages/core/templates/errors/404.twig +8 -0
- package/packages/core/templates/errors/500.twig +8 -0
- package/packages/core/templates/errors/502.twig +8 -0
- package/packages/core/templates/errors/503.twig +8 -0
- package/packages/core/templates/errors/base.twig +8 -0
- package/packages/frond/dist/index.js +151 -17
- package/packages/frond/src/engine.ts +177 -17
- package/packages/frond/src/index.ts +8 -0
- package/packages/orm/dist/index.js +8125 -6708
- package/packages/orm/src/adapters/firebird.ts +50 -3
- package/packages/orm/src/adapters/mongodb.ts +8 -0
- package/packages/orm/src/adapters/mssql.ts +29 -7
- package/packages/orm/src/adapters/mysql.ts +17 -1
- package/packages/orm/src/adapters/odbc.ts +11 -0
- package/packages/orm/src/adapters/pg-types.d.ts +8 -0
- package/packages/orm/src/adapters/postgres.ts +34 -8
- package/packages/orm/src/adapters/sqlDialect.ts +57 -0
- package/packages/orm/src/adapters/sqlite.ts +26 -0
- package/packages/orm/src/autoCrud.ts +78 -36
- package/packages/orm/src/baseModel.ts +61 -29
- package/packages/orm/src/cachedDatabase.ts +67 -1
- package/packages/orm/src/connectTimeout.ts +8 -0
- package/packages/orm/src/database.ts +649 -401
- package/packages/orm/src/databaseResult.ts +8 -0
- package/packages/orm/src/databaseUrl.ts +8 -0
- package/packages/orm/src/docstore.ts +23 -1
- package/packages/orm/src/fakeData.ts +8 -0
- package/packages/orm/src/graph/adapters/arango.ts +8 -0
- package/packages/orm/src/graph/adapters/bolt.ts +8 -0
- package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
- package/packages/orm/src/graph/connectTimeout.ts +8 -0
- package/packages/orm/src/graph/errors.ts +8 -0
- package/packages/orm/src/graph/graphAdapter.ts +8 -0
- package/packages/orm/src/graph/graphDatabase.ts +8 -0
- package/packages/orm/src/graph/graphUrl.ts +10 -1
- package/packages/orm/src/graph/shapes.ts +8 -0
- package/packages/orm/src/index.ts +147 -139
- package/packages/orm/src/migration.ts +8 -0
- package/packages/orm/src/model.ts +8 -0
- package/packages/orm/src/modelCollection.ts +8 -0
- package/packages/orm/src/point.ts +8 -0
- package/packages/orm/src/query.ts +149 -33
- package/packages/orm/src/queryBuilder.ts +8 -0
- package/packages/orm/src/realtime/index.ts +8 -0
- package/packages/orm/src/realtime/models/attachment.ts +8 -0
- package/packages/orm/src/realtime/models/channel.ts +8 -0
- package/packages/orm/src/realtime/models/channelMember.ts +8 -0
- package/packages/orm/src/realtime/models/message.ts +8 -0
- package/packages/orm/src/realtime/models/workspace.ts +8 -0
- package/packages/orm/src/realtime/realtime.ts +8 -0
- package/packages/orm/src/realtime/storage.ts +43 -12
- package/packages/orm/src/seeder.ts +8 -0
- package/packages/orm/src/seederTable.ts +8 -0
- package/packages/orm/src/seederTypes.ts +8 -0
- package/packages/orm/src/sqlTranslator.ts +185 -8
- package/packages/orm/src/types.ts +15 -0
- package/packages/orm/src/validation.ts +8 -0
- package/packages/swagger/src/generator.ts +8 -0
- package/packages/swagger/src/index.ts +8 -0
- package/packages/swagger/src/ui.ts +8 -0
- package/types/core/src/background.d.ts +0 -16
- package/types/core/src/cache.d.ts +6 -0
- package/types/core/src/devAdmin.d.ts +18 -12
- package/types/core/src/docs.d.ts +0 -17
- package/types/core/src/errorOverlay.d.ts +0 -24
- package/types/core/src/events.d.ts +0 -12
- package/types/core/src/feedback.d.ts +0 -23
- package/types/core/src/graphql.d.ts +0 -21
- package/types/core/src/htmlElement.d.ts +0 -15
- package/types/core/src/messenger.d.ts +12 -1
- package/types/core/src/metrics.d.ts +2 -0
- package/types/core/src/middleware.d.ts +45 -1
- package/types/core/src/mqtt.d.ts +0 -33
- package/types/core/src/optionalPackage.d.ts +25 -0
- package/types/core/src/plan.d.ts +0 -10
- package/types/core/src/projectIndex.d.ts +0 -10
- package/types/core/src/queueBackends/mongoBackend.d.ts +2 -0
- package/types/core/src/request.d.ts +12 -0
- package/types/core/src/response.d.ts +13 -0
- package/types/core/src/server.d.ts +19 -0
- package/types/core/src/sessionHandlers/childError.d.ts +0 -15
- package/types/core/src/transport.d.ts +28 -0
- package/types/core/src/trustedProxy.d.ts +0 -16
- package/types/core/src/types.d.ts +5 -0
- package/types/core/src/websocketBackplane.d.ts +1 -0
- package/types/core/src/wsdl.d.ts +8 -22
- package/types/frond/src/engine.d.ts +7 -1
- package/types/orm/src/adapters/firebird.d.ts +12 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +2 -0
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
- package/types/orm/src/adapters/sqlite.d.ts +7 -0
- package/types/orm/src/baseModel.d.ts +5 -0
- package/types/orm/src/cachedDatabase.d.ts +27 -0
- package/types/orm/src/database.d.ts +46 -7
- package/types/orm/src/graph/graphUrl.d.ts +0 -8
- package/types/orm/src/index.d.ts +1 -1
- package/types/orm/src/query.d.ts +48 -2
- package/types/orm/src/realtime/storage.d.ts +2 -1
- package/types/orm/src/sqlTranslator.d.ts +70 -18
- package/types/orm/src/types.d.ts +6 -0
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import type { RouteDefinition, Tina4Request, Tina4Response } from "../../core/src/index.js";
|
|
2
10
|
import type { DiscoveredModel } from "./model.js";
|
|
3
11
|
import type { FieldDefinition } from "./types.js";
|
|
4
|
-
import { getAdapter, adapterQuery, adapterExecute } from "./database.js";
|
|
12
|
+
import { getAdapter, getNamedAdapter, adapterQuery, adapterExecute, adapterFetch, quoteIdentifier } from "./database.js";
|
|
5
13
|
import { DatabaseResult } from "./databaseResult.js";
|
|
6
|
-
import { buildQuery, parseQueryString } from "./query.js";
|
|
14
|
+
import { buildQuery, parseQueryString, resolveField, resolveFieldColumn, UnknownFieldError, InvalidQueryParameterError } from "./query.js";
|
|
7
15
|
import { validate } from "./validation.js";
|
|
8
16
|
|
|
9
17
|
/**
|
|
@@ -23,6 +31,7 @@ import { validate } from "./validation.js";
|
|
|
23
31
|
*/
|
|
24
32
|
function allowListedBody(
|
|
25
33
|
fields: Record<string, FieldDefinition>,
|
|
34
|
+
columnOf: (field: string) => string,
|
|
26
35
|
pkField: string,
|
|
27
36
|
body: Record<string, unknown> | null | undefined,
|
|
28
37
|
isCreate: boolean,
|
|
@@ -31,10 +40,14 @@ function allowListedBody(
|
|
|
31
40
|
const stripPk = isCreate ? pkDef?.autoIncrement === true : true;
|
|
32
41
|
const allowed: Record<string, unknown> = {};
|
|
33
42
|
for (const [key, value] of Object.entries(body ?? {})) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
// ADR-0069 (G1): the SAME resolver the list route uses - a declared field
|
|
44
|
+
// by its property or its column; anything else (including an inherited
|
|
45
|
+
// Object property such as "constructor") is dropped.
|
|
46
|
+
const field = resolveField(fields, columnOf, key);
|
|
47
|
+
if (field === null) continue;
|
|
48
|
+
if (field === "is_deleted") continue;
|
|
49
|
+
if (stripPk && field === pkField) continue;
|
|
50
|
+
allowed[field] = value;
|
|
38
51
|
}
|
|
39
52
|
return allowed;
|
|
40
53
|
}
|
|
@@ -154,7 +167,11 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
154
167
|
const writeSecurity = options.public === true ? { secure: false as const } : {};
|
|
155
168
|
|
|
156
169
|
for (const { definition } of models) {
|
|
157
|
-
const { tableName, fields, softDelete, tableFilter, fieldMapping } = definition;
|
|
170
|
+
const { tableName, fields, softDelete, tableFilter, fieldMapping, dbName } = definition;
|
|
171
|
+
|
|
172
|
+
// The connection the model is bound to (`static _db = "name"`), never the
|
|
173
|
+
// global default behind its back - the same resolution BaseModel.getDb() uses.
|
|
174
|
+
const modelAdapter = () => (dbName ? getNamedAdapter(dbName) : getAdapter());
|
|
158
175
|
const basePath = `/api/${tableName}`;
|
|
159
176
|
const mapping = fieldMapping ?? {};
|
|
160
177
|
|
|
@@ -168,7 +185,9 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
168
185
|
// Build extra WHERE conditions for soft delete and table filter
|
|
169
186
|
const extraConditions: string[] = [];
|
|
170
187
|
if (softDelete) {
|
|
171
|
-
|
|
188
|
+
// Bare, like BaseModel's soft-delete filter: resolves on every engine
|
|
189
|
+
// (Firebird folds it to IS_DELETED, the name its DDL created).
|
|
190
|
+
extraConditions.push(`is_deleted = 0`);
|
|
172
191
|
}
|
|
173
192
|
if (tableFilter) {
|
|
174
193
|
extraConditions.push(tableFilter);
|
|
@@ -183,23 +202,42 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
183
202
|
tags: [tableName],
|
|
184
203
|
},
|
|
185
204
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
186
|
-
const adapter =
|
|
205
|
+
const adapter = modelAdapter();
|
|
206
|
+
const q = (name: string): string => quoteIdentifier(adapter, name);
|
|
187
207
|
|
|
188
|
-
// Parse query params for filtering / sorting / pagination
|
|
189
|
-
const qp = parseQueryString(req.query ?? {});
|
|
208
|
+
// Parse query params for filtering / sorting / pagination.
|
|
190
209
|
// limit/offset/page are the CLAMPED/CAPPED values buildQuery actually used
|
|
191
210
|
// for the SQL (PAGE-DEC-01: page >= 1, per-page <= DEFAULT_ROW_CAP) — read
|
|
192
211
|
// them back here instead of recomputing from the raw qp, so the envelope
|
|
193
212
|
// can never drift from the query that ran.
|
|
194
|
-
|
|
213
|
+
//
|
|
214
|
+
// Filter keys and sort fields resolve against the model's DECLARED fields
|
|
215
|
+
// (ADR-0069); an unknown one is a 400 before any SQL runs.
|
|
216
|
+
// A wrong-shaped value (a list/map where one value belongs) is a 400 too.
|
|
217
|
+
let built: ReturnType<typeof buildQuery>;
|
|
218
|
+
try {
|
|
219
|
+
const qp = parseQueryString(req.query ?? {});
|
|
220
|
+
built = buildQuery(tableName, qp, extraConditions, q, (key) => resolveFieldColumn(fields, getDbCol, key));
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof UnknownFieldError) {
|
|
223
|
+
res.error("UNKNOWN_FIELD", err.message, 400);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (err instanceof InvalidQueryParameterError) {
|
|
227
|
+
res.error("INVALID_QUERY_PARAMETER", err.message, 400);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
const { pageSql, countSql, filterParams, limit, offset } = built;
|
|
195
233
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
const rows = await
|
|
234
|
+
// adapterFetch pages in the engine's own syntax (LIMIT/OFFSET, OFFSET
|
|
235
|
+
// FETCH on MSSQL, ROWS on Firebird) - the same path BaseModel reads use.
|
|
236
|
+
const rows = await adapterFetch(adapter, pageSql, filterParams, limit, offset);
|
|
199
237
|
|
|
200
238
|
// total is the TRUE total for the filter (a COUNT probe), NEVER the number
|
|
201
239
|
// of rows this page returned (ADR-0043).
|
|
202
|
-
const countRow = await adapterQuery(adapter, countSql,
|
|
240
|
+
const countRow = await adapterQuery(adapter, countSql, filterParams);
|
|
203
241
|
const total = Number(countRow[0]?.total ?? 0);
|
|
204
242
|
|
|
205
243
|
// The REST list envelope IS the canonical paginate envelope: exactly the
|
|
@@ -220,11 +258,12 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
220
258
|
tags: [tableName],
|
|
221
259
|
},
|
|
222
260
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
223
|
-
const adapter =
|
|
261
|
+
const adapter = modelAdapter();
|
|
262
|
+
const q = (name: string): string => quoteIdentifier(adapter, name);
|
|
224
263
|
|
|
225
|
-
const conditions = [
|
|
264
|
+
const conditions = [`${q(pkColumn)} = ?`, ...extraConditions];
|
|
226
265
|
const rows = await adapterQuery(adapter,
|
|
227
|
-
`SELECT * FROM
|
|
266
|
+
`SELECT * FROM ${q(tableName)} WHERE ${conditions.join(" AND ")}`,
|
|
228
267
|
[req.params.id],
|
|
229
268
|
);
|
|
230
269
|
|
|
@@ -247,12 +286,13 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
247
286
|
tags: [tableName],
|
|
248
287
|
},
|
|
249
288
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
250
|
-
const adapter =
|
|
289
|
+
const adapter = modelAdapter();
|
|
290
|
+
const q = (name: string): string => quoteIdentifier(adapter, name);
|
|
251
291
|
const rawBody = req.body as Record<string, unknown>;
|
|
252
292
|
|
|
253
293
|
// CRUD-MASS-ASSIGNMENT: allow-list before anything downstream (both
|
|
254
294
|
// validation and persistence) ever sees the body.
|
|
255
|
-
const body = allowListedBody(fields, pkField, rawBody, true);
|
|
295
|
+
const body = allowListedBody(fields, getDbCol, pkField, rawBody, true);
|
|
256
296
|
|
|
257
297
|
// Validate against field definitions
|
|
258
298
|
const errors = validate(body, fields);
|
|
@@ -276,8 +316,8 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
276
316
|
// tolerates RETURNING but we still prefer its lastId below.
|
|
277
317
|
const isSqlite = adapter.constructor.name === "SQLiteAdapter";
|
|
278
318
|
const insertSql =
|
|
279
|
-
`INSERT INTO
|
|
280
|
-
(isSqlite ? "" : ` RETURNING
|
|
319
|
+
`INSERT INTO ${q(tableName)} (${columns.map((c) => q(c)).join(", ")}) VALUES (${placeholders})` +
|
|
320
|
+
(isSqlite ? "" : ` RETURNING ${q(pkColumn)}`);
|
|
281
321
|
|
|
282
322
|
const insertResult = await adapterExecute(adapter, insertSql, values);
|
|
283
323
|
|
|
@@ -295,7 +335,7 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
295
335
|
|
|
296
336
|
// Fetch the created record to include auto-generated fields (e.g. id)
|
|
297
337
|
const created = await adapterQuery(adapter,
|
|
298
|
-
`SELECT * FROM
|
|
338
|
+
`SELECT * FROM ${q(tableName)} WHERE ${q(pkColumn)} = ?`,
|
|
299
339
|
[lastId],
|
|
300
340
|
);
|
|
301
341
|
|
|
@@ -313,14 +353,15 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
313
353
|
tags: [tableName],
|
|
314
354
|
},
|
|
315
355
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
316
|
-
const adapter =
|
|
356
|
+
const adapter = modelAdapter();
|
|
357
|
+
const q = (name: string): string => quoteIdentifier(adapter, name);
|
|
317
358
|
const rawBody = req.body as Record<string, unknown>;
|
|
318
359
|
|
|
319
360
|
// CRUD-MASS-ASSIGNMENT: allow-list -- the row is addressed by the URL
|
|
320
361
|
// {id} alone, so a body PK is stripped (never lets the write rename
|
|
321
362
|
// the row's own identity column or, on a mis-wired WHERE, redirect
|
|
322
363
|
// to a different row); is_deleted is guarded the same as create.
|
|
323
|
-
const body = allowListedBody(fields, pkField, rawBody, false);
|
|
364
|
+
const body = allowListedBody(fields, getDbCol, pkField, rawBody, false);
|
|
324
365
|
|
|
325
366
|
// Feature 19 (VALID-NODE-PUT-NOVALIDATE): the update path validates the
|
|
326
367
|
// request body too — previously only POST did, so a PUT could write
|
|
@@ -334,9 +375,9 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
334
375
|
return;
|
|
335
376
|
}
|
|
336
377
|
|
|
337
|
-
const conditions = [
|
|
378
|
+
const conditions = [`${q(pkColumn)} = ?`, ...extraConditions];
|
|
338
379
|
const existing = await adapterQuery(adapter,
|
|
339
|
-
`SELECT * FROM
|
|
380
|
+
`SELECT * FROM ${q(tableName)} WHERE ${conditions.join(" AND ")}`,
|
|
340
381
|
[req.params.id],
|
|
341
382
|
);
|
|
342
383
|
if (existing.length === 0) {
|
|
@@ -359,17 +400,17 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
359
400
|
}
|
|
360
401
|
|
|
361
402
|
const setClauses = Object.keys(dbBody)
|
|
362
|
-
.map((col) =>
|
|
403
|
+
.map((col) => `${q(col)} = ?`)
|
|
363
404
|
.join(", ");
|
|
364
405
|
const values = [...Object.values(dbBody), req.params.id];
|
|
365
406
|
|
|
366
407
|
await adapterExecute(adapter,
|
|
367
|
-
`UPDATE
|
|
408
|
+
`UPDATE ${q(tableName)} SET ${setClauses} WHERE ${q(pkColumn)} = ?`,
|
|
368
409
|
values,
|
|
369
410
|
);
|
|
370
411
|
|
|
371
412
|
const updated = await adapterQuery(adapter,
|
|
372
|
-
`SELECT * FROM
|
|
413
|
+
`SELECT * FROM ${q(tableName)} WHERE ${q(pkColumn)} = ?`,
|
|
373
414
|
[req.params.id],
|
|
374
415
|
);
|
|
375
416
|
|
|
@@ -387,11 +428,12 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
387
428
|
tags: [tableName],
|
|
388
429
|
},
|
|
389
430
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
390
|
-
const adapter =
|
|
431
|
+
const adapter = modelAdapter();
|
|
432
|
+
const q = (name: string): string => quoteIdentifier(adapter, name);
|
|
391
433
|
|
|
392
|
-
const conditions = [
|
|
434
|
+
const conditions = [`${q(pkColumn)} = ?`, ...extraConditions];
|
|
393
435
|
const existing = await adapterQuery(adapter,
|
|
394
|
-
`SELECT * FROM
|
|
436
|
+
`SELECT * FROM ${q(tableName)} WHERE ${conditions.join(" AND ")}`,
|
|
395
437
|
[req.params.id],
|
|
396
438
|
);
|
|
397
439
|
if (existing.length === 0) {
|
|
@@ -401,13 +443,13 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
401
443
|
|
|
402
444
|
if (softDelete) {
|
|
403
445
|
await adapterExecute(adapter,
|
|
404
|
-
`UPDATE
|
|
446
|
+
`UPDATE ${q(tableName)} SET is_deleted = 1 WHERE ${q(pkColumn)} = ?`,
|
|
405
447
|
[req.params.id],
|
|
406
448
|
);
|
|
407
449
|
res.json({ message: "Deleted (soft)", data: existing[0] });
|
|
408
450
|
} else {
|
|
409
451
|
await adapterExecute(adapter,
|
|
410
|
-
`DELETE FROM
|
|
452
|
+
`DELETE FROM ${q(tableName)} WHERE ${q(pkColumn)} = ?`,
|
|
411
453
|
[req.params.id],
|
|
412
454
|
);
|
|
413
455
|
res.json({ message: "Deleted", data: existing[0] });
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import {
|
|
2
10
|
getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl,
|
|
3
11
|
adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
|
|
4
12
|
adapterStartTransaction, adapterCommit, adapterRollback,
|
|
5
13
|
adapterTableExists, adapterCreateTable, extractLastInsertId,
|
|
6
|
-
probeTotal, DEFAULT_ROW_CAP,
|
|
14
|
+
probeTotal, DEFAULT_ROW_CAP, quoteIdentifier,
|
|
7
15
|
} from "./database.js";
|
|
8
16
|
import { ModelCollection } from "./modelCollection.js";
|
|
9
17
|
import { validate as validateFields } from "./validation.js";
|
|
10
18
|
import { QueryBuilder } from "./queryBuilder.js";
|
|
19
|
+
import { resolveFieldColumn } from "./query.js";
|
|
11
20
|
import { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
12
21
|
import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
|
|
13
22
|
import { Log } from "../../core/src/index.js";
|
|
@@ -379,6 +388,14 @@ export class BaseModel {
|
|
|
379
388
|
* SQLite URLs are initialised synchronously. Other engines require initDatabase()
|
|
380
389
|
* to be called before first use.
|
|
381
390
|
*/
|
|
391
|
+
/**
|
|
392
|
+
* Quote a table/column name in the dialect of the adapter this model runs on
|
|
393
|
+
* (MySQL backticks, Firebird upper-cased, `"name"` elsewhere).
|
|
394
|
+
*/
|
|
395
|
+
protected static quoteName(name: string): string {
|
|
396
|
+
return quoteIdentifier(this.getDb(), name);
|
|
397
|
+
}
|
|
398
|
+
|
|
382
399
|
protected static getDb(): DatabaseAdapter {
|
|
383
400
|
if (this._db) {
|
|
384
401
|
return getNamedAdapter(this._db);
|
|
@@ -497,7 +514,7 @@ export class BaseModel {
|
|
|
497
514
|
static async findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): Promise<T | null> {
|
|
498
515
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
499
516
|
const pkCol = ModelClass.getPkColumn();
|
|
500
|
-
let sql = `SELECT * FROM
|
|
517
|
+
let sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${ModelClass.quoteName(pkCol)} = ?`;
|
|
501
518
|
|
|
502
519
|
if (ModelClass.softDelete) {
|
|
503
520
|
sql += ` AND is_deleted = 0`;
|
|
@@ -598,8 +615,12 @@ export class BaseModel {
|
|
|
598
615
|
|
|
599
616
|
if (filter) {
|
|
600
617
|
for (const [key, value] of Object.entries(filter)) {
|
|
601
|
-
|
|
602
|
-
|
|
618
|
+
// ADR-0069: a filter key must be a declared field (or its column).
|
|
619
|
+
const col = resolveFieldColumn(ModelClass.fields, (field) => ModelClass.getDbColumn(field), key);
|
|
620
|
+
if (col === null) {
|
|
621
|
+
throw new Error(`Unknown filter field '${key}' for model ${ModelClass.name}`);
|
|
622
|
+
}
|
|
623
|
+
conditions.push(`${ModelClass.quoteName(col)} = ?`);
|
|
603
624
|
params.push(value);
|
|
604
625
|
}
|
|
605
626
|
}
|
|
@@ -608,7 +629,7 @@ export class BaseModel {
|
|
|
608
629
|
conditions.push("is_deleted = 0");
|
|
609
630
|
}
|
|
610
631
|
|
|
611
|
-
let sql = `SELECT * FROM
|
|
632
|
+
let sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)}`;
|
|
612
633
|
if (conditions.length > 0) {
|
|
613
634
|
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
614
635
|
}
|
|
@@ -648,7 +669,7 @@ export class BaseModel {
|
|
|
648
669
|
const pkCol = ModelClass.getPkColumn();
|
|
649
670
|
const pkValue = (this as any)[pkProp];
|
|
650
671
|
if (pkValue === undefined || pkValue === null) return false;
|
|
651
|
-
sql = `SELECT * FROM
|
|
672
|
+
sql = `SELECT * FROM ${ModelClass.quoteName(table)} WHERE ${ModelClass.quoteName(pkCol)} = ?`;
|
|
652
673
|
params = [pkValue];
|
|
653
674
|
} else {
|
|
654
675
|
sql = `SELECT * FROM ${table} WHERE ${filter}`;
|
|
@@ -712,7 +733,7 @@ export class BaseModel {
|
|
|
712
733
|
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
713
734
|
// No LIMIT/OFFSET embedded: _collect's adapterFetch applies them to the page
|
|
714
735
|
// and the COUNT probe wraps the un-limited SQL so the total is the whole set.
|
|
715
|
-
const sql = `SELECT * FROM
|
|
736
|
+
const sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)}${whereClause}${orderClause}`;
|
|
716
737
|
|
|
717
738
|
// No bind parameters: the only conditions left are the framework's own
|
|
718
739
|
// softDelete / tableFilter literals. A caller-supplied filter belongs on
|
|
@@ -754,7 +775,7 @@ export class BaseModel {
|
|
|
754
775
|
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
755
776
|
// No LIMIT/OFFSET embedded — _collect applies them to the page and probes
|
|
756
777
|
// the total (ADR-0064). orderBy affects the page only; COUNT is order-free.
|
|
757
|
-
const sql = `SELECT * FROM
|
|
778
|
+
const sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${parts.join(" AND ")}${orderClause}`;
|
|
758
779
|
|
|
759
780
|
return ModelClass._collect<T>(sql, params, limit, offset, include);
|
|
760
781
|
}
|
|
@@ -840,13 +861,13 @@ export class BaseModel {
|
|
|
840
861
|
try {
|
|
841
862
|
if (ModelClass.softDelete) {
|
|
842
863
|
await adapterExecute(db,
|
|
843
|
-
`UPDATE
|
|
864
|
+
`UPDATE ${ModelClass.quoteName(ModelClass.tableName)} SET is_deleted = 1 WHERE ${this.pkWhere().sql}`,
|
|
844
865
|
this.pkWhere().params,
|
|
845
866
|
);
|
|
846
867
|
this.is_deleted = 1;
|
|
847
868
|
} else {
|
|
848
869
|
await adapterExecute(db,
|
|
849
|
-
`DELETE FROM
|
|
870
|
+
`DELETE FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${this.pkWhere().sql}`,
|
|
850
871
|
this.pkWhere().params,
|
|
851
872
|
);
|
|
852
873
|
}
|
|
@@ -1136,7 +1157,7 @@ export class BaseModel {
|
|
|
1136
1157
|
await adapterStartTransaction(db);
|
|
1137
1158
|
try {
|
|
1138
1159
|
await adapterExecute(db,
|
|
1139
|
-
`DELETE FROM
|
|
1160
|
+
`DELETE FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${this.pkWhere().sql}`,
|
|
1140
1161
|
this.pkWhere().params,
|
|
1141
1162
|
);
|
|
1142
1163
|
await adapterCommit(db);
|
|
@@ -1169,7 +1190,7 @@ export class BaseModel {
|
|
|
1169
1190
|
await adapterStartTransaction(db);
|
|
1170
1191
|
try {
|
|
1171
1192
|
await adapterExecute(db,
|
|
1172
|
-
`UPDATE
|
|
1193
|
+
`UPDATE ${ModelClass.quoteName(ModelClass.tableName)} SET is_deleted = 0 WHERE ${this.pkWhere().sql}`,
|
|
1173
1194
|
this.pkWhere().params,
|
|
1174
1195
|
);
|
|
1175
1196
|
await adapterCommit(db);
|
|
@@ -1203,7 +1224,7 @@ export class BaseModel {
|
|
|
1203
1224
|
parts.push(conditions);
|
|
1204
1225
|
}
|
|
1205
1226
|
|
|
1206
|
-
let sql = `SELECT * FROM
|
|
1227
|
+
let sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)}`;
|
|
1207
1228
|
if (parts.length > 0) {
|
|
1208
1229
|
sql += ` WHERE ${parts.join(" AND ")}`;
|
|
1209
1230
|
}
|
|
@@ -1230,7 +1251,7 @@ export class BaseModel {
|
|
|
1230
1251
|
parts.push(conditions);
|
|
1231
1252
|
}
|
|
1232
1253
|
const whereClause = parts.length > 0 ? ` WHERE ${parts.join(" AND ")}` : "";
|
|
1233
|
-
const sql = `SELECT COUNT(*) as cnt FROM
|
|
1254
|
+
const sql = `SELECT COUNT(*) as cnt FROM ${this.quoteName(this.tableName)}${whereClause}`;
|
|
1234
1255
|
const rows = await adapterQuery(db, sql, params);
|
|
1235
1256
|
if (rows.length === 0) return 0;
|
|
1236
1257
|
// PostgreSQL returns COUNT(*) as a bigint, which the `pg` driver hands
|
|
@@ -1277,7 +1298,7 @@ export class BaseModel {
|
|
|
1277
1298
|
}
|
|
1278
1299
|
|
|
1279
1300
|
const db = relatedClass.getDb();
|
|
1280
|
-
let sql = `SELECT * FROM
|
|
1301
|
+
let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, foreignKey)} = ?`;
|
|
1281
1302
|
if (relatedClass.softDelete) {
|
|
1282
1303
|
sql += ` AND is_deleted = 0`;
|
|
1283
1304
|
}
|
|
@@ -1325,12 +1346,12 @@ export class BaseModel {
|
|
|
1325
1346
|
|
|
1326
1347
|
const db = relatedClass.getDb();
|
|
1327
1348
|
const orderCol = relatedClass.getPkColumn();
|
|
1328
|
-
let sql = `SELECT * FROM
|
|
1349
|
+
let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, foreignKey)} = ?`;
|
|
1329
1350
|
if (relatedClass.softDelete) {
|
|
1330
1351
|
sql += ` AND is_deleted = 0`;
|
|
1331
1352
|
}
|
|
1332
1353
|
// Order by the child PK for a stable read (parity with the lazy accessor).
|
|
1333
|
-
sql += ` ORDER BY
|
|
1354
|
+
sql += ` ORDER BY ${quoteIdentifier(db, orderCol)}`;
|
|
1334
1355
|
// IMPREL-PY-CAP parity: with no explicit limit, return the WHOLE set (like
|
|
1335
1356
|
// the lazy accessor, which is uncapped) instead of a silent 100-row cap. An
|
|
1336
1357
|
// explicit limit still pages (explicit, never silent).
|
|
@@ -1369,7 +1390,7 @@ export class BaseModel {
|
|
|
1369
1390
|
|
|
1370
1391
|
const db = relatedClass.getDb();
|
|
1371
1392
|
const relatedPkCol = relatedClass.getPkColumn();
|
|
1372
|
-
let sql = `SELECT * FROM
|
|
1393
|
+
let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, relatedPkCol)} = ?`;
|
|
1373
1394
|
if (relatedClass.softDelete) {
|
|
1374
1395
|
sql += ` AND is_deleted = 0`;
|
|
1375
1396
|
}
|
|
@@ -1504,9 +1525,9 @@ export class BaseModel {
|
|
|
1504
1525
|
if (pkValue === undefined || pkValue === null) return [];
|
|
1505
1526
|
const db = relatedClass.getDb();
|
|
1506
1527
|
const orderCol = relatedClass.getPkColumn();
|
|
1507
|
-
let sql = `SELECT * FROM
|
|
1528
|
+
let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, foreignKey)} = ?`;
|
|
1508
1529
|
if (relatedClass.softDelete) sql += ` AND is_deleted = 0`;
|
|
1509
|
-
sql += ` ORDER BY
|
|
1530
|
+
sql += ` ORDER BY ${quoteIdentifier(db, orderCol)}`;
|
|
1510
1531
|
const rows = await adapterQuery(db, sql, [pkValue]);
|
|
1511
1532
|
return rows.map((row) => new (relatedClass as unknown as new (d: Record<string, unknown>) => BaseModel)(row as Record<string, unknown>));
|
|
1512
1533
|
}
|
|
@@ -1606,11 +1627,11 @@ function validateBeforeSave(instance: BaseModel, model: typeof BaseModel, isUpda
|
|
|
1606
1627
|
async function executeModelUpdate(instance: BaseModel, model: typeof BaseModel, db: DatabaseAdapter): Promise<void> {
|
|
1607
1628
|
const fields = Object.entries(model.fields).filter(([name, def]) => !def.primaryKey && instance[name] !== undefined);
|
|
1608
1629
|
if (fields.length === 0) return;
|
|
1609
|
-
const setClause = fields.map(([name]) =>
|
|
1630
|
+
const setClause = fields.map(([name]) => `${quoteIdentifier(db, model.getDbColumn(name))} = ?`).join(", ");
|
|
1610
1631
|
const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
|
|
1611
1632
|
const where = savePrimaryKeyWhere(instance, model);
|
|
1612
1633
|
values.push(...where.params);
|
|
1613
|
-
await adapterExecute(db, `UPDATE
|
|
1634
|
+
await adapterExecute(db, `UPDATE ${quoteIdentifier(db, model.tableName)} SET ${setClause} WHERE ${where.sql}`, values);
|
|
1614
1635
|
}
|
|
1615
1636
|
|
|
1616
1637
|
function buildInsertStatement(
|
|
@@ -1623,17 +1644,28 @@ function buildInsertStatement(
|
|
|
1623
1644
|
const fields = Object.entries(model.fields).filter(
|
|
1624
1645
|
([name, def]) => !(def.primaryKey && def.autoIncrement) && instance[name] !== undefined,
|
|
1625
1646
|
);
|
|
1626
|
-
|
|
1647
|
+
// The generated key comes back three ways: PostgreSQL and Firebird take a
|
|
1648
|
+
// trailing RETURNING, MSSQL an OUTPUT INSERTED clause before VALUES, and
|
|
1649
|
+
// SQLite / MySQL report it through lastInsertId(). MySQL and MSSQL reject a
|
|
1650
|
+
// RETURNING outright, so an auto-increment save() used to fail on both. The
|
|
1651
|
+
// engine comes from getDatabaseType(): the ORM holds the CachedDatabaseAdapter
|
|
1652
|
+
// wrapper, whose constructor.name never names the engine.
|
|
1653
|
+
const engine = db.getDatabaseType?.() ?? "";
|
|
1654
|
+
const wantsKey = Boolean(pkField?.autoIncrement);
|
|
1655
|
+
const returning = wantsKey && (engine === "postgres" || engine === "firebird")
|
|
1656
|
+
? ` RETURNING ${quoteIdentifier(db, pkCol)}` : "";
|
|
1657
|
+
const output = wantsKey && engine === "mssql" ? ` OUTPUT INSERTED.${quoteIdentifier(db, pkCol)}` : "";
|
|
1658
|
+
const table = quoteIdentifier(db, model.tableName);
|
|
1627
1659
|
if (fields.length === 0) {
|
|
1628
|
-
const emptyInsert =
|
|
1629
|
-
? `INSERT INTO
|
|
1630
|
-
: `INSERT INTO
|
|
1660
|
+
const emptyInsert = engine === "mysql"
|
|
1661
|
+
? `INSERT INTO ${table} () VALUES ()`
|
|
1662
|
+
: `INSERT INTO ${table}${output} DEFAULT VALUES`;
|
|
1631
1663
|
return { sql: emptyInsert + returning, values: [] };
|
|
1632
1664
|
}
|
|
1633
|
-
const columns = fields.map(([name]) =>
|
|
1665
|
+
const columns = fields.map(([name]) => quoteIdentifier(db, model.getDbColumn(name))).join(", ");
|
|
1634
1666
|
const placeholders = fields.map(() => "?").join(", ");
|
|
1635
1667
|
const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
|
|
1636
|
-
return { sql: `INSERT INTO
|
|
1668
|
+
return { sql: `INSERT INTO ${table} (${columns})${output} VALUES (${placeholders})${returning}`, values };
|
|
1637
1669
|
}
|
|
1638
1670
|
|
|
1639
1671
|
function applyInsertedId(
|
|
@@ -1804,7 +1836,7 @@ async function eagerQueryRelated(
|
|
|
1804
1836
|
const related: BaseModel[] = [];
|
|
1805
1837
|
for (const chunk of _chunk(values, EAGER_IN_CHUNK)) {
|
|
1806
1838
|
const placeholders = chunk.map(() => "?").join(",");
|
|
1807
|
-
let sql = `SELECT * FROM
|
|
1839
|
+
let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, column)} IN (${placeholders})`;
|
|
1808
1840
|
if (relatedClass.softDelete) sql += " AND is_deleted = 0";
|
|
1809
1841
|
const rows = await adapterQuery(db, sql, chunk);
|
|
1810
1842
|
for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Tina4 Cached Database — Transparent query cache decorator for DatabaseAdapter.
|
|
3
11
|
*
|
|
@@ -32,7 +40,8 @@
|
|
|
32
40
|
* db.cacheStats(); // { enabled, mode, hits, misses, size, ttl }
|
|
33
41
|
*/
|
|
34
42
|
|
|
35
|
-
import { QueryCache } from "./sqlTranslator.js";
|
|
43
|
+
import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
|
|
44
|
+
import { quoteIdentifierAnsi } from "./adapters/sqlDialect.js";
|
|
36
45
|
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "./types.js";
|
|
37
46
|
import type { CacheBackend } from "../../core/src/index.js";
|
|
38
47
|
|
|
@@ -309,6 +318,25 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
309
318
|
if (b) { try { await b.set(key, { row }, this.ttl); } catch { /* best effort */ } }
|
|
310
319
|
}
|
|
311
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A write that arrives through a READ method (fetch/fetchOne/query of an
|
|
323
|
+
* `INSERT ... RETURNING`) is still a write: its result must never be cached
|
|
324
|
+
* (a repeat would answer from the cache and never insert) and it flushes the
|
|
325
|
+
* cache like every other write (tina4-python#133 contract).
|
|
326
|
+
*/
|
|
327
|
+
private isWriteRead(sql: string): boolean {
|
|
328
|
+
return this.enabled && SQLTranslator.isWriteStatement(sql);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Run a write that came through a read method, then flush (never store). */
|
|
332
|
+
private runWrite<R>(run: () => R): R {
|
|
333
|
+
try { return run(); } finally { this.invalidate(); }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async runWriteAsync<R>(run: () => Promise<R>): Promise<R> {
|
|
337
|
+
try { return await run(); } finally { await this.invalidateAsync(); }
|
|
338
|
+
}
|
|
339
|
+
|
|
312
340
|
// ── DatabaseAdapter interface — writes flush, reads cache ──
|
|
313
341
|
|
|
314
342
|
/** ADR-0044 required capability — delegates to the wrapped adapter. */
|
|
@@ -316,11 +344,28 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
316
344
|
return this.adapter.connect?.();
|
|
317
345
|
}
|
|
318
346
|
|
|
347
|
+
/**
|
|
348
|
+
* The derived-table alias the COUNT probe needs, forwarded from the wrapped
|
|
349
|
+
* adapter. SQL Server and MySQL REQUIRE `FROM (<sql>) AS _count_query`; the
|
|
350
|
+
* probe reads this property off whatever adapter it is handed, and
|
|
351
|
+
* Database.create() hands it THIS wrapper. Without the forward the probe ran
|
|
352
|
+
* unaliased, the engine rejected it, the error was swallowed, and
|
|
353
|
+
* `result.count` silently became the page length on those two engines.
|
|
354
|
+
*/
|
|
355
|
+
get countSubqueryAlias(): string | undefined {
|
|
356
|
+
return (this.adapter as { countSubqueryAlias?: string } | null)?.countSubqueryAlias;
|
|
357
|
+
}
|
|
358
|
+
|
|
319
359
|
/** ADR-0044 required capability — delegates to the wrapped adapter. */
|
|
320
360
|
getDatabaseType(): string {
|
|
321
361
|
return this.adapter.getDatabaseType();
|
|
322
362
|
}
|
|
323
363
|
|
|
364
|
+
/** Identifier quoting is the wrapped engine's, never the wrapper's. */
|
|
365
|
+
quoteIdentifier(name: string): string {
|
|
366
|
+
return this.adapter.quoteIdentifier ? this.adapter.quoteIdentifier(name) : quoteIdentifierAnsi(name);
|
|
367
|
+
}
|
|
368
|
+
|
|
324
369
|
/**
|
|
325
370
|
* ADR-0044 required capability — a native boolean, readable and writable.
|
|
326
371
|
* A getter/setter pair (not a plain field) so it genuinely delegates to the
|
|
@@ -347,6 +392,21 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
347
392
|
return this.adapter.execute(sql, params);
|
|
348
393
|
}
|
|
349
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Forward an adapter's row-returning execute (SQLite, Firebird), for
|
|
397
|
+
* Database.execute(). Resolves `undefined` when the wrapped adapter has none
|
|
398
|
+
* - WITHOUT running anything - so the caller falls back to execute().
|
|
399
|
+
*/
|
|
400
|
+
async executeRowsAsync(sql: string, params?: unknown[]): Promise<unknown[] | undefined> {
|
|
401
|
+
const inner = this.adapter as unknown as {
|
|
402
|
+
executeRowsAsync?: (s: string, p?: unknown[]) => Promise<unknown[]>;
|
|
403
|
+
executeRows?: (s: string, p?: unknown[]) => unknown[];
|
|
404
|
+
};
|
|
405
|
+
if (!inner.executeRowsAsync && !inner.executeRows) return undefined;
|
|
406
|
+
if (this.enabled) await this.invalidateAsync();
|
|
407
|
+
return inner.executeRowsAsync ? inner.executeRowsAsync(sql, params) : inner.executeRows!(sql, params);
|
|
408
|
+
}
|
|
409
|
+
|
|
350
410
|
executeMany(sql: string, paramsList: unknown[][]): import("./types.js").DatabaseResult | { totalAffected: number; lastId?: number | bigint } {
|
|
351
411
|
if (this.enabled) this.invalidate();
|
|
352
412
|
return this.adapter.executeMany(sql, paramsList);
|
|
@@ -357,6 +417,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
357
417
|
// so caching here is what makes ORM reads dedupe — matching the Python master
|
|
358
418
|
// where every ORM read flows through the cached db.fetch(). Same store, same
|
|
359
419
|
// counters, flushed on writes.
|
|
420
|
+
if (this.isWriteRead(sql)) return this.runWrite(() => this.adapter.query<T>(sql, params));
|
|
360
421
|
if (this.enabled) {
|
|
361
422
|
const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined, this.identity);
|
|
362
423
|
const cached = this.cache.get<T[]>(key);
|
|
@@ -377,6 +438,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
377
438
|
// store, run straight against the underlying adapter (mirrors the Python
|
|
378
439
|
// master's `no_cache`). Counters are left untouched so a bypass read isn't
|
|
379
440
|
// misreported as a hit or a miss.
|
|
441
|
+
if (this.isWriteRead(sql)) return this.runWrite(() => this.adapter.fetch<T>(sql, params, limit, skip));
|
|
380
442
|
if (this.enabled && !noCache) {
|
|
381
443
|
const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined, this.identity);
|
|
382
444
|
const cached = this.cache.get<T[]>(key);
|
|
@@ -393,6 +455,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
393
455
|
}
|
|
394
456
|
|
|
395
457
|
fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[], noCache?: boolean): T | null {
|
|
458
|
+
if (this.isWriteRead(sql)) return this.runWrite(() => this.adapter.fetchOne<T>(sql, params));
|
|
396
459
|
if (this.enabled && !noCache) {
|
|
397
460
|
const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined, this.identity);
|
|
398
461
|
const cached = this.cache.get<T | null>(key);
|
|
@@ -488,6 +551,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
488
551
|
: this.adapter.fetch<T>(sql, params, limit, skip);
|
|
489
552
|
// `noCache` bypasses both cache layers for this one call — no lookup, no
|
|
490
553
|
// store, run directly (mirrors the Python master's `no_cache`).
|
|
554
|
+
if (this.isWriteRead(sql)) return this.runWriteAsync(run);
|
|
491
555
|
if (this.enabled && !noCache) {
|
|
492
556
|
const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined, this.identity);
|
|
493
557
|
// Persistent distributed backend is AUTHORITATIVE (mirrors Python, where a
|
|
@@ -520,6 +584,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
520
584
|
? await (this.adapter as any).fetchOneAsync(sql, params)
|
|
521
585
|
: this.adapter.fetchOne<T>(sql, params);
|
|
522
586
|
// `noCache` bypasses both cache layers for this one call (see fetchAsync).
|
|
587
|
+
if (this.isWriteRead(sql)) return this.runWriteAsync(run);
|
|
523
588
|
if (this.enabled && !noCache) {
|
|
524
589
|
const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined, this.identity);
|
|
525
590
|
if (this.usesPersistentBackend()) {
|
|
@@ -547,6 +612,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
547
612
|
const run = async (): Promise<T[]> => (this.adapter as any).queryAsync
|
|
548
613
|
? await (this.adapter as any).queryAsync(sql, params)
|
|
549
614
|
: this.adapter.query<T>(sql, params);
|
|
615
|
+
if (this.isWriteRead(sql)) return this.runWriteAsync(run);
|
|
550
616
|
if (this.enabled) {
|
|
551
617
|
const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined, this.identity);
|
|
552
618
|
if (this.usesPersistentBackend()) {
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* TINA4_DATABASE_CONNECT_TIMEOUT - bound a database connect attempt.
|
|
3
11
|
*
|