fastify-auto-sqlapi 0.1.4 → 0.1.6
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/AGENTS_BACKEND.md +23 -5
- package/README.md +27 -1
- package/dist/bin/generate-tables.js +19 -5
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +6 -4
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +2 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +156 -162
- package/dist/lib/read-access.d.ts +31 -0
- package/dist/lib/read-access.d.ts.map +1 -0
- package/dist/lib/read-access.js +53 -0
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +17 -5
- package/dist/lib/sql-api-decorator.d.ts +18 -0
- package/dist/lib/sql-api-decorator.d.ts.map +1 -0
- package/dist/lib/sql-api-decorator.js +40 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +18 -0
- package/dist/routes/auto/plugin.d.ts +0 -6
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +2 -28
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +4 -0
- package/dist/types/computed.d.ts +13 -3
- package/dist/types/computed.d.ts.map +1 -1
- package/dist/types/table.d.ts +13 -0
- package/dist/types/table.d.ts.map +1 -1
- package/package.json +2 -2
package/AGENTS_BACKEND.md
CHANGED
|
@@ -184,6 +184,8 @@ await app.register(async (instance) => {
|
|
|
184
184
|
|
|
185
185
|
**Note**: After registering the plugin, `app.sqlApi` is available for custom routes — see [SqlApi](#sqlapi--programmatic-high-level-api).
|
|
186
186
|
|
|
187
|
+
**Note**: Granular route plugins create their own `sqlApi` decorator automatically, so they work standalone (no main plugin required). The decorator is scoped to each route plugin: if you also need `app.sqlApi` in your own routes, register the main plugin.
|
|
188
|
+
|
|
187
189
|
---
|
|
188
190
|
|
|
189
191
|
## defineTable() — Complete Reference
|
|
@@ -234,8 +236,12 @@ export const TableCustomer = defineTable({
|
|
|
234
236
|
...exportTableInfo(Schema), // Schema + auto-filter builder
|
|
235
237
|
|
|
236
238
|
// OPTIONAL
|
|
237
|
-
defaultOrder: 'name', // ORDER BY default
|
|
239
|
+
defaultOrder: 'name', // ORDER BY default. camelCase fields are mapped to DB
|
|
240
|
+
// columns (e.g. 'squadIndex' -> "squad_index"); supports
|
|
241
|
+
// multi ('name ASC, id DESC'), computed fields, and raw
|
|
242
|
+
// SQL fragments (unknown tokens pass through unchanged)
|
|
238
243
|
excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
|
|
244
|
+
readExclude: ['passwordHash'], // Fields hidden from ALL reads (writes unaffected)
|
|
239
245
|
distinctResults: true, // Use SELECT DISTINCT
|
|
240
246
|
|
|
241
247
|
// JOINS — relations to other tables. Alias defaults to joinSchema.tableName —
|
|
@@ -479,12 +485,22 @@ type ComputedFieldFn = (ctx: {
|
|
|
479
485
|
db: QueryClient; // dialect-aware (qi, ph, dialectName, dateTrunc, ...)
|
|
480
486
|
qiCol(field: string, opts?: { qualifier?: string }): string;
|
|
481
487
|
}) => {
|
|
482
|
-
expr: string; // SQL fragment
|
|
483
|
-
values: unknown[]; //
|
|
488
|
+
expr: string; // SQL fragment; mark each bound value with `?`
|
|
489
|
+
values: unknown[]; // one entry per `?` marker in expr
|
|
484
490
|
type: TSchema; // REQUIRED — used by Swagger and body validation
|
|
485
491
|
};
|
|
486
492
|
```
|
|
487
493
|
|
|
494
|
+
**Bound values use `?` markers.** The engine assigns the placeholder positions — never write `$1` or `db.ph(n)` yourself. Use `\?` for a literal question mark (PostgreSQL jsonb operator). A computed whose marker count does not match `values.length` is rejected with a descriptive error, because it would bind values the query never references:
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
bonus: ({ qiCol }) => ({
|
|
498
|
+
expr: `CASE WHEN ${qiCol('role')} = ? THEN ${qiCol('salary')} ELSE 0 END`,
|
|
499
|
+
values: ['admin'],
|
|
500
|
+
type: Type.Number(),
|
|
501
|
+
}),
|
|
502
|
+
```
|
|
503
|
+
|
|
488
504
|
`qiCol(field)` returns a properly-quoted column reference, optionally prefixed by an alias qualifier — the engine passes the alias automatically when the computed is invoked inside a `joinLeft` LEFT JOIN, so the same function works both inline on the main query and as a parent-side column.
|
|
489
505
|
|
|
490
506
|
### Validation & startup checks
|
|
@@ -499,8 +515,9 @@ For `joinLeft` specifically, the computed expr is automatically alias-prefixed b
|
|
|
499
515
|
|
|
500
516
|
### Limitations (first round, by design)
|
|
501
517
|
|
|
502
|
-
- **Bound `values` in computed expressions**
|
|
503
|
-
-
|
|
518
|
+
- **Bound `values` in computed expressions** work anywhere the expression lands in the `WHERE` clause or in `ORDER BY`: `filters` and `conditions` (main, `joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) and `orderBy`. The ConditionBuilder assigns the placeholder positions there, so no coordination is needed from the caller.
|
|
519
|
+
- They are still rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `joinGroup.aggregations.by` and `defaultOrder`. Those expressions are emitted in the `SELECT` / `GROUP BY` list, which precedes the `WHERE` values in the parameter order — there is no correct position for them there. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no bound values at all.
|
|
520
|
+
- **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400 — it is emitted in the `SELECT`/`GROUP BY` list). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-parti aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
|
|
504
521
|
- **Computed cannot be used as aggregation function** (`sum`/`min`/`max`/...). The values inside `aggregations.sum: ['<name>']` must be schema field names. To aggregate on a derived expression, declare the derivation as a computed and pass the computed name as the field, BUT only via `computeMin/Max/Sum/Avg` (top-level main aggregates), not the joinGroup ones.
|
|
505
522
|
- **No chained computed** (a computed referencing another computed). Flat-only.
|
|
506
523
|
- **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
|
|
@@ -757,6 +774,7 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
757
774
|
- **camelCase in API, any case in DB**: all request/response fields are camelCase. The plugin converts via `col()` and `colMap`. For snake_case DB columns (default), conversion is automatic. For camelCase DB columns (e.g. betterauth), the CLI generates a `colMap` that preserves the original column names — no conversion needed. Manual schemas without `colMap` fall back to `toUnderscore()`.
|
|
758
775
|
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
759
776
|
- **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks.
|
|
777
|
+
- **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes.
|
|
760
778
|
- **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
|
|
761
779
|
- **`schemaOverrides`**: override auto-generated schema fields with stricter TypeBox types (e.g. `{ email: Type.String({ format: 'email' }) }`). Overrides are merged into the body schema for insert, update, and bulk-upsert. The original Schema file is never modified. In updates, overridden fields are still wrapped in Optional (validates only when present). Overrides appear in Swagger.
|
|
762
780
|
- **Validation receives camelCase**: `validate` receives the original camelCase record (as sent by the client) and secondaries. Field names match the schema definition, with full TypeScript inference (`main.startDate`, not `main.start_date`). It returns `ValidationError[]` — tuples of `[field, code]` or `[field, code, message]`. If any errors are returned, the request is rejected with 400 before hooks or SQL execute.
|
package/README.md
CHANGED
|
@@ -4,6 +4,16 @@ Automatic CRUD API generation from PostgreSQL, MySQL, and MariaDB tables for [Fa
|
|
|
4
4
|
|
|
5
5
|
Point it at your database, and get a full REST API with search, advanced conditions, pagination, joins, aggregations, bulk operations, multi-tenant isolation, validation, and hooks.
|
|
6
6
|
|
|
7
|
+
## What LLMs Are Saying
|
|
8
|
+
|
|
9
|
+
> API: 5 new read-only tables — and this is where your plugin really shined.
|
|
10
|
+
|
|
11
|
+
> Yes, it genuinely saved me a lot of work, and the numbers make that obvious: I deleted about 350 lines of hand-written API repository and route code, replacing them with roughly 150 lines of declarative configuration. And it wasn't a one-for-one trade. In return, I gained features I simply didn't have before: complete Swagger documentation, the conditions DSL (previously I only had sorting and fixed filters), pagination with total counts, structured 400 errors, and consistent camelCase conversion.
|
|
12
|
+
|
|
13
|
+
> The moment I thought, "Okay, this is well designed," was when I used computedFields: the case of "attributes stored inside a jsonb column that still need to be sortable and filterable" is exactly the kind of problem that usually forces you to step outside the framework, yet it fit in cleanly. It worked end-to-end with the role-based CASE expression, remained sortable, could be projected with selectComputed, and required zero SQL in the routes.
|
|
14
|
+
|
|
15
|
+
> The AGENTS files deserve a mention: I configured everything without ever opening the source code—CLI schema, defineTable, buildRelation, computed, extraFilters, and the frontend request/response shapes. As "documentation for agents," they genuinely work, and that's more than I can say for most libraries.
|
|
16
|
+
|
|
7
17
|
## Features
|
|
8
18
|
|
|
9
19
|
- **Zero boilerplate** — define your tables, get 7 endpoints each
|
|
@@ -143,8 +153,9 @@ const TableCustomer = defineTable({
|
|
|
143
153
|
...exportTableInfo(SchemaCustomer),
|
|
144
154
|
|
|
145
155
|
// Ordering & filtering
|
|
146
|
-
defaultOrder: 'name', // default ORDER BY
|
|
156
|
+
defaultOrder: 'name', // default ORDER BY (camelCase fields map to DB columns)
|
|
147
157
|
excludeFromCreation: ['id'], // omit from INSERT (e.g. auto-increment)
|
|
158
|
+
readExclude: ['passwordHash'], // hide from all reads (writes unaffected)
|
|
148
159
|
distinctResults: true, // SELECT DISTINCT
|
|
149
160
|
|
|
150
161
|
// Relations — alias defaults to joinSchema.tableName. Override with `{ alias: '...' }`
|
|
@@ -320,6 +331,21 @@ only columns present in the generated Schema (as narrowed by `schemaOverrides` /
|
|
|
320
331
|
column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
|
|
321
332
|
to keep sensitive columns out of it.
|
|
322
333
|
|
|
334
|
+
### Read visibility
|
|
335
|
+
|
|
336
|
+
`readExclude` hides columns from every read while leaving writes untouched — the case for a
|
|
337
|
+
password hash or an access token: writable, never readable.
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
readExclude: ['accessToken'],
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Excluded fields are not selected by search/get, are omitted from read response schemas and from
|
|
344
|
+
the table's default join selection, and cannot be referenced from `filters`, `conditions`,
|
|
345
|
+
`orderBy`, aggregations or an explicit join `selection` (`400`). That last part is the point:
|
|
346
|
+
letting a hidden field be filtered would leak its value by bisection. Primary keys cannot be
|
|
347
|
+
excluded.
|
|
348
|
+
|
|
323
349
|
## API Reference
|
|
324
350
|
|
|
325
351
|
### POST /search/{table}
|
|
@@ -20,7 +20,7 @@ function printHelp() {
|
|
|
20
20
|
console.log(' sqlapi-generate-tables --all');
|
|
21
21
|
console.log('');
|
|
22
22
|
display('Table*.ts files are only created if they do not already exist.', CONSOLE_COLORS.magenta);
|
|
23
|
-
display('dbTables.ts is
|
|
23
|
+
display('dbTables.ts is created if missing, indexing the Table*.ts files present on disk.', CONSOLE_COLORS.magenta);
|
|
24
24
|
}
|
|
25
25
|
await runCli('fastify-auto-sqlapi: generating tables template', async () => {
|
|
26
26
|
const cliArgs = parseArgs({
|
|
@@ -84,9 +84,11 @@ await runCli('fastify-auto-sqlapi: generating tables template', async () => {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
const tableVarOf = (schema) => 'Table' + schema.schemaName.replace(/^Schema/, '');
|
|
87
88
|
// Generate individual Table*.ts files (skip if already exists)
|
|
89
|
+
const createdTableVars = [];
|
|
88
90
|
for (const schema of targetSchemas) {
|
|
89
|
-
const tableVarName =
|
|
91
|
+
const tableVarName = tableVarOf(schema);
|
|
90
92
|
const tableFile = path.join(tablesDir, `${tableVarName}.ts`);
|
|
91
93
|
if (fs.existsSync(tableFile)) {
|
|
92
94
|
displayAsTableRow(`${tableVarName}.ts`, 'skipped (already exists)', 60, CONSOLE_COLORS.gray);
|
|
@@ -94,17 +96,29 @@ await runCli('fastify-auto-sqlapi: generating tables template', async () => {
|
|
|
94
96
|
else {
|
|
95
97
|
const content = generateSingleTableFile(schema, allSchemas);
|
|
96
98
|
fs.writeFileSync(tableFile, content);
|
|
99
|
+
createdTableVars.push(tableVarName);
|
|
97
100
|
displayAsTableRow(`${tableVarName}.ts`, 'created', 60, CONSOLE_COLORS.green);
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
|
-
// Generate dbTables.ts only if it does not exist
|
|
103
|
+
// Generate dbTables.ts only if it does not exist. The index must reference only
|
|
104
|
+
// Table*.ts files actually present on disk, otherwise a subset run would emit
|
|
105
|
+
// imports for files that were never generated and the project would not compile.
|
|
101
106
|
const dbTablesFile = path.join(tablesDir, 'dbTables.ts');
|
|
102
107
|
if (fs.existsSync(dbTablesFile)) {
|
|
103
108
|
displayAsTableRow('dbTables.ts', 'skipped (already exists)', 60, CONSOLE_COLORS.gray);
|
|
109
|
+
const indexContent = fs.readFileSync(dbTablesFile, 'utf-8');
|
|
110
|
+
const missing = createdTableVars.filter((v) => !indexContent.includes(v));
|
|
111
|
+
if (missing.length > 0) {
|
|
112
|
+
display(`Remember to add the new tables to dbTables.ts: ${missing.join(', ')}`, CONSOLE_COLORS.yellow);
|
|
113
|
+
}
|
|
104
114
|
}
|
|
105
115
|
else {
|
|
106
|
-
const
|
|
107
|
-
|
|
116
|
+
const existingTableVars = new Set(fs
|
|
117
|
+
.readdirSync(tablesDir)
|
|
118
|
+
.filter((f) => f.startsWith('Table') && f.endsWith('.ts'))
|
|
119
|
+
.map((f) => f.slice(0, -'.ts'.length)));
|
|
120
|
+
const indexSchemas = allSchemas.filter((s) => existingTableVars.has(tableVarOf(s)));
|
|
121
|
+
fs.writeFileSync(dbTablesFile, generateDbTablesIndex(indexSchemas));
|
|
108
122
|
displayAsTableRow('dbTables.ts', 'created', 60, CONSOLE_COLORS.green);
|
|
109
123
|
}
|
|
110
124
|
console.log('');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAuCpE;AAuCD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAuCpE;AAuCD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,CA2HhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
|
|
@@ -130,10 +130,10 @@ export function generateSingleTableFile(schema, allSchemas) {
|
|
|
130
130
|
// Imports
|
|
131
131
|
lines.push(`import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';`);
|
|
132
132
|
lines.push(`import type {ValidationError} from 'fastify-auto-sqlapi';`);
|
|
133
|
-
lines.push(`import {${schema.schemaName} as Schema} from '../schemas/${schema.schemaName}';`);
|
|
133
|
+
lines.push(`import {${schema.schemaName} as Schema} from '../schemas/${schema.schemaName}.js';`);
|
|
134
134
|
// Commented imports for related schemas
|
|
135
135
|
for (const relSchema of relatedSchemas) {
|
|
136
|
-
lines.push(`// import {${relSchema}} from '../schemas/${relSchema}';`);
|
|
136
|
+
lines.push(`// import {${relSchema}} from '../schemas/${relSchema}.js';`);
|
|
137
137
|
}
|
|
138
138
|
lines.push(``);
|
|
139
139
|
// Extra filters placeholder
|
|
@@ -161,6 +161,8 @@ export function generateSingleTableFile(schema, allSchemas) {
|
|
|
161
161
|
else {
|
|
162
162
|
lines.push(` // excludeFromCreation: [],`);
|
|
163
163
|
}
|
|
164
|
+
// Hide columns from every read (writes still accept them, e.g. a password hash)
|
|
165
|
+
lines.push(` // readExclude: [],`);
|
|
164
166
|
if (parentRels.length > 0) {
|
|
165
167
|
lines.push(` // allowedReadJoins: [`);
|
|
166
168
|
for (const rel of parentRels) {
|
|
@@ -213,7 +215,7 @@ export function generateDbTablesIndex(schemas) {
|
|
|
213
215
|
lines.push(``);
|
|
214
216
|
for (const schema of schemas) {
|
|
215
217
|
const tableVarName = 'Table' + schema.schemaName.replace(/^Schema/, '');
|
|
216
|
-
lines.push(`import {${tableVarName}} from './${tableVarName}';`);
|
|
218
|
+
lines.push(`import {${tableVarName}} from './${tableVarName}.js';`);
|
|
217
219
|
}
|
|
218
220
|
lines.push(``);
|
|
219
221
|
lines.push(`export const dbTables: DbTables = {`);
|
|
@@ -245,7 +247,7 @@ export function generateTablesFile(schemas) {
|
|
|
245
247
|
lines.push(``);
|
|
246
248
|
// Schema imports
|
|
247
249
|
for (const schema of schemas) {
|
|
248
|
-
lines.push(`import {${schema.schemaName}} from './${schema.schemaName}';`);
|
|
250
|
+
lines.push(`import {${schema.schemaName}} from './${schema.schemaName}.js';`);
|
|
249
251
|
}
|
|
250
252
|
// Table definitions
|
|
251
253
|
for (const schema of schemas) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAuB,MAAM,mBAAmB,CAAC;AAEnF,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CA8BrE"}
|
|
@@ -2,6 +2,7 @@ import { ConditionBuilder } from 'node-condition-builder';
|
|
|
2
2
|
import { camelcaseObject } from '../../naming.js';
|
|
3
3
|
import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
|
|
4
4
|
import { httpError } from '../../errors.js';
|
|
5
|
+
import { readableSelectColumns } from '../../read-access.js';
|
|
5
6
|
import { primaryAsString } from '../../../types.js';
|
|
6
7
|
export async function getEngine(params) {
|
|
7
8
|
const { db, tableConf, id, tenant } = params;
|
|
@@ -19,6 +20,7 @@ export async function getEngine(params) {
|
|
|
19
20
|
const values = cb.getValues();
|
|
20
21
|
const rows = await db.select({
|
|
21
22
|
tableName: tableConf.Schema.tableName,
|
|
23
|
+
columns: readableSelectColumns(tableConf, tableConf.Schema, db),
|
|
22
24
|
where,
|
|
23
25
|
values,
|
|
24
26
|
limit: '1',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAcb,MAAM,mBAAmB,CAAC;AAmkC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAkIvB"}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { ConditionBuilder } from 'node-condition-builder';
|
|
1
|
+
import { ConditionBuilder, Expression } from 'node-condition-builder';
|
|
2
2
|
import { camelcaseObject } from '../../naming.js';
|
|
3
3
|
import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
|
|
4
|
+
import { assertReadable, assertFiltersReadable, readableSelectColumns } from '../../read-access.js';
|
|
4
5
|
import { primaryAsString } from '../../../types.js';
|
|
5
6
|
// ─── Schema field validation ────────────────────────────────
|
|
6
|
-
function validateSchemaField(field, schema) {
|
|
7
|
+
function validateSchemaField(field, schema, tableConf) {
|
|
7
8
|
if (!(field in schema.fields)) {
|
|
8
9
|
const err = new Error(`Unknown field: ${field}`);
|
|
9
10
|
err.statusCode = 400;
|
|
10
11
|
throw err;
|
|
11
12
|
}
|
|
13
|
+
assertReadable(tableConf, field);
|
|
12
14
|
return schema.col(field);
|
|
13
15
|
}
|
|
14
16
|
function err400(msg) {
|
|
@@ -37,15 +39,15 @@ function buildComputedContext(db, schema, alias) {
|
|
|
37
39
|
}
|
|
38
40
|
function resolveFieldRef(field, schema, tableConf, db, alias) {
|
|
39
41
|
if (field in schema.fields) {
|
|
42
|
+
assertReadable(tableConf, field);
|
|
40
43
|
const col = db.qi(schema.col(field));
|
|
41
44
|
const expr = alias ? `${db.qi(alias)}.${col}` : col;
|
|
42
|
-
return { expr,
|
|
45
|
+
return { expr, computed: false };
|
|
43
46
|
}
|
|
44
47
|
const fn = tableConf?.computedFields?.[field];
|
|
45
48
|
if (fn) {
|
|
46
49
|
const ctx = buildComputedContext(db, schema, alias);
|
|
47
|
-
|
|
48
|
-
return { expr: out.expr, values: out.values ?? [], computed: true };
|
|
50
|
+
return { expr: computedExpression(field, fn(ctx)), computed: true };
|
|
49
51
|
}
|
|
50
52
|
err400(`Unknown field: ${field}`);
|
|
51
53
|
}
|
|
@@ -95,16 +97,48 @@ function extractJoinRefs(db, tableConf, joinDef) {
|
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
/**
|
|
98
|
-
* Evaluate a computed field
|
|
99
|
-
*
|
|
100
|
-
*
|
|
100
|
+
* Evaluate a computed field into an Expression carrying its own bound values.
|
|
101
|
+
*
|
|
102
|
+
* Inside a WHERE clause the ConditionBuilder assigns the placeholder indexes, so bound
|
|
103
|
+
* values are always safe there. `allowBoundValues=false` is for the positions that sit
|
|
104
|
+
* *before* the WHERE values in the parameter order (SELECT projections, aggregations),
|
|
105
|
+
* where they cannot be placed correctly; those still reject with 400.
|
|
101
106
|
*/
|
|
102
|
-
function evaluateComputedField(fn, schema, db, alias, message, allowBoundValues = false) {
|
|
107
|
+
function evaluateComputedField(name, fn, schema, db, alias, message, allowBoundValues = false) {
|
|
103
108
|
const ev = fn(buildComputedContext(db, schema, alias));
|
|
104
109
|
if (!allowBoundValues && (ev.values?.length ?? 0) > 0) {
|
|
105
110
|
err400(message);
|
|
106
111
|
}
|
|
107
|
-
return ev;
|
|
112
|
+
return computedExpression(name, ev);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build the Expression for a computed field, checking that it marks each bound value
|
|
116
|
+
* with a `?`.
|
|
117
|
+
*
|
|
118
|
+
* A mismatch is always a table-configuration bug and would otherwise produce a query that
|
|
119
|
+
* binds values nobody references — silently returning wrong rows. Expressions without
|
|
120
|
+
* bound values are exempt: they are emitted verbatim, so a `?` there is a literal operator
|
|
121
|
+
* (PostgreSQL jsonb) rather than a marker.
|
|
122
|
+
*/
|
|
123
|
+
function computedExpression(name, ev) {
|
|
124
|
+
const values = ev.values ?? [];
|
|
125
|
+
if (values.length > 0) {
|
|
126
|
+
const markers = (ev.expr.match(/\\\?|\?/g) ?? []).filter((m) => m === '?').length;
|
|
127
|
+
if (markers !== values.length) {
|
|
128
|
+
throw new Error(`Computed field '${name}' declares ${values.length} bound value(s) but its expression ` +
|
|
129
|
+
`contains ${markers} '?' marker(s). Mark each bound value with '?' (use '\\?' for a ` +
|
|
130
|
+
`literal question mark) so the engine can assign placeholder positions.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return new Expression(ev.expr, values);
|
|
134
|
+
}
|
|
135
|
+
/** Bound values carried by a resolved field reference. */
|
|
136
|
+
function refValues(expr) {
|
|
137
|
+
return expr instanceof Expression ? [...expr.values] : [];
|
|
138
|
+
}
|
|
139
|
+
/** Render a resolved field reference to SQL, binding any values from `startIndex`. */
|
|
140
|
+
function renderRef(expr, startIndex, db) {
|
|
141
|
+
return expr instanceof Expression ? expr.render(startIndex, db.ph) : expr;
|
|
108
142
|
}
|
|
109
143
|
/** Map rows from snake_case columns to camelCase fields, preserving any computed-field columns
|
|
110
144
|
* that are projected by their declared name (skipped by camelcaseObject). */
|
|
@@ -121,11 +155,19 @@ function mapRowsToCamelCase(rows, schema, computedFieldNames) {
|
|
|
121
155
|
return camel;
|
|
122
156
|
});
|
|
123
157
|
}
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
|
|
160
|
+
* A default '*' selection narrows to the join table's readable columns when it declares
|
|
161
|
+
* `readExclude`; an explicit selection naming an excluded field is rejected with 400.
|
|
162
|
+
*/
|
|
163
|
+
function buildSelectionColumns(selection, joinSchema, db, joinTableConf) {
|
|
164
|
+
if (selection === '*') {
|
|
165
|
+
return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
|
|
166
|
+
}
|
|
167
|
+
return selection
|
|
168
|
+
.split(',')
|
|
169
|
+
.map((c) => db.qi(validateSchemaField(c.trim(), joinSchema, joinTableConf)))
|
|
170
|
+
.join(', ');
|
|
129
171
|
}
|
|
130
172
|
const AGG_FN = {
|
|
131
173
|
sum: { sql: 'SUM', distinct: false },
|
|
@@ -219,7 +261,7 @@ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx)
|
|
|
219
261
|
if (dotted2) {
|
|
220
262
|
const [, alias, field, dir] = dotted2;
|
|
221
263
|
const joinDef = requireJoin(tableConf, alias, true);
|
|
222
|
-
const col = validateSchemaField(field, joinDef.joinSchema);
|
|
264
|
+
const col = validateSchemaField(field, joinDef.joinSchema, dbTables[joinDef.joinSchema.tableName]);
|
|
223
265
|
// Reference the LEFT JOIN'd table via its alias (SQL identifier).
|
|
224
266
|
outParts.push(`${db.qi(alias)}.${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
|
|
225
267
|
leftJoinAliases.add(alias);
|
|
@@ -232,9 +274,12 @@ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx)
|
|
|
232
274
|
}
|
|
233
275
|
const [, field, dir] = plain;
|
|
234
276
|
const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
277
|
+
// A computed field binds its own values here: ORDER BY is emitted after the WHERE, so
|
|
278
|
+
// its placeholders continue from `currentIdx`.
|
|
279
|
+
const refVals = refValues(ref.expr);
|
|
280
|
+
outParts.push(`${renderRef(ref.expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
|
|
281
|
+
outValues.push(...refVals);
|
|
282
|
+
currentIdx += refVals.length;
|
|
238
283
|
}
|
|
239
284
|
return { sql: outParts.join(', '), values: outValues, leftJoinAliases };
|
|
240
285
|
}
|
|
@@ -259,6 +304,7 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
259
304
|
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
260
305
|
const joinTableConf = dbTables[joinSchema.tableName];
|
|
261
306
|
const computed = joinTableConf?.computedFields;
|
|
307
|
+
assertFiltersReadable(ref.filters, joinTableConf);
|
|
262
308
|
if (ref.filters) {
|
|
263
309
|
for (const [field, value] of Object.entries(ref.filters)) {
|
|
264
310
|
if (value === null || value === undefined)
|
|
@@ -267,9 +313,10 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
267
313
|
cb.isEqual(`${aliasIdent}.${db.qi(joinSchema.col(field))}`, value);
|
|
268
314
|
}
|
|
269
315
|
else if (computed?.[field]) {
|
|
270
|
-
// Computed field on the parent table:
|
|
271
|
-
|
|
272
|
-
|
|
316
|
+
// Computed field on the parent table: expr with alias-qualified columns. Bound
|
|
317
|
+
// values are placed by this ConditionBuilder, whose SQL lands in the WHERE.
|
|
318
|
+
const expr = evaluateComputedField(field, computed[field], joinSchema, db, alias, '', true);
|
|
319
|
+
cb.isEqual(expr, value);
|
|
273
320
|
}
|
|
274
321
|
}
|
|
275
322
|
}
|
|
@@ -279,12 +326,13 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
279
326
|
err400(`Invalid condition method: ${c.method}`);
|
|
280
327
|
}
|
|
281
328
|
if (c.field in joinSchema.fields) {
|
|
329
|
+
assertReadable(dbTables[joinSchema.tableName], c.field);
|
|
282
330
|
const col = `${aliasIdent}.${db.qi(joinSchema.col(c.field))}`;
|
|
283
331
|
dispatchConditionMethod(cb, c.method, col, c.params ?? []);
|
|
284
332
|
}
|
|
285
333
|
else if (computed?.[c.field]) {
|
|
286
|
-
const
|
|
287
|
-
dispatchConditionMethod(cb, c.method,
|
|
334
|
+
const expr = evaluateComputedField(c.field, computed[c.field], joinSchema, db, alias, '', true);
|
|
335
|
+
dispatchConditionMethod(cb, c.method, expr, c.params ?? []);
|
|
288
336
|
}
|
|
289
337
|
else {
|
|
290
338
|
err400(`Unknown field: ${c.field}`);
|
|
@@ -303,25 +351,58 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
303
351
|
return { joinClauses, whereExtras, values };
|
|
304
352
|
}
|
|
305
353
|
// ─── Main query execution ───────────────────────────────────
|
|
354
|
+
/**
|
|
355
|
+
* Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
|
|
356
|
+
* Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
|
|
357
|
+
* lenient for backward compatibility: tokens matching a camelCase schema field are
|
|
358
|
+
* mapped to their quoted DB column, computed fields (without bound values) expand
|
|
359
|
+
* to their expression, and anything else passes through unchanged as raw SQL.
|
|
360
|
+
*/
|
|
361
|
+
function convertConfiguredOrder(order, tableConf, db) {
|
|
362
|
+
return order
|
|
363
|
+
.split(',')
|
|
364
|
+
.map((part) => {
|
|
365
|
+
const trimmed = part.trim();
|
|
366
|
+
const match = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
|
|
367
|
+
if (match) {
|
|
368
|
+
const [, field, dir] = match;
|
|
369
|
+
const suffix = dir ? ` ${dir.toUpperCase()}` : '';
|
|
370
|
+
if (field in tableConf.Schema.fields) {
|
|
371
|
+
return `${db.qi(tableConf.Schema.col(field))}${suffix}`;
|
|
372
|
+
}
|
|
373
|
+
const computed = tableConf.computedFields?.[field];
|
|
374
|
+
if (computed) {
|
|
375
|
+
const ev = evaluateComputedField(field, computed, tableConf.Schema, db, undefined, `Computed field '${field}' with bound values cannot be used in defaultOrder`);
|
|
376
|
+
return `${ev.value}${suffix}`;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return trimmed;
|
|
380
|
+
})
|
|
381
|
+
.join(', ');
|
|
382
|
+
}
|
|
306
383
|
async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = [], selectComputed, maxRows) {
|
|
307
384
|
const tableName = tableConf.Schema.tableName;
|
|
308
|
-
|
|
385
|
+
// Request orderBy arrives already validated and mapped by validateOrderBy;
|
|
386
|
+
// configured fallbacks (defaultOrder / primary key) are converted leniently here.
|
|
387
|
+
const order = orderBy || convertConfiguredOrder(tableConf.defaultOrder || primaryAsString(tableConf.primary), tableConf, db);
|
|
309
388
|
// With a paginator, the page size governs the LIMIT. Without one, apply `maxRows` (if set) so an
|
|
310
389
|
// unbounded search cannot dump the whole table; a plain integer is a safe, non-injectable LIMIT.
|
|
311
390
|
const limit = paginator
|
|
312
391
|
? `${paginator.itemsPerPage} OFFSET ${(paginator.page - 1) * paginator.itemsPerPage}`
|
|
313
392
|
: (maxRows != null ? String(maxRows) : null);
|
|
393
|
+
// Base projection: '*' unless the table hides fields via readExclude.
|
|
394
|
+
const readableColumns = readableSelectColumns(tableConf, tableConf.Schema, db);
|
|
314
395
|
// Optional computed projections — bound values, if any, are NOT yet supported
|
|
315
396
|
// here (would require placeholder-aware composition with WHERE values).
|
|
316
|
-
let columns;
|
|
397
|
+
let columns = readableColumns;
|
|
317
398
|
if (selectComputed?.length) {
|
|
318
|
-
const projections = ['*'];
|
|
399
|
+
const projections = [readableColumns ?? '*'];
|
|
319
400
|
for (const name of selectComputed) {
|
|
320
401
|
const fn = tableConf.computedFields?.[name];
|
|
321
402
|
if (!fn)
|
|
322
403
|
err400(`Unknown computed field in selectComputed: '${name}'`);
|
|
323
|
-
const out = evaluateComputedField(fn, tableConf.Schema, db, undefined, `Computed field '${name}' with bound values cannot be used in selectComputed`);
|
|
324
|
-
projections.push(`${out.
|
|
404
|
+
const out = evaluateComputedField(name, fn, tableConf.Schema, db, undefined, `Computed field '${name}' with bound values cannot be used in selectComputed`);
|
|
405
|
+
projections.push(`${out.value} AS ${db.qi(name)}`);
|
|
325
406
|
}
|
|
326
407
|
columns = projections.join(', ');
|
|
327
408
|
}
|
|
@@ -352,10 +433,12 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
|
|
|
352
433
|
for (const { key, field, fn } of computations) {
|
|
353
434
|
if (field) {
|
|
354
435
|
const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
|
|
355
|
-
|
|
436
|
+
// The aggregate sits in the SELECT list, before the WHERE values: a computed that
|
|
437
|
+
// binds parameters cannot be placed here.
|
|
438
|
+
if (refValues(ref.expr).length > 0) {
|
|
356
439
|
err400(`Computed field '${field}' with bound values cannot be used in compute${key.charAt(0).toUpperCase() + key.slice(1)}`);
|
|
357
440
|
}
|
|
358
|
-
const result = await db.query(`SELECT ${fn}(${ref.expr}) as value FROM ${db.qi(tableName)}${joinClause} WHERE ${where}`, values);
|
|
441
|
+
const result = await db.query(`SELECT ${fn}(${renderRef(ref.expr, 1, db)}) as value FROM ${db.qi(tableName)}${joinClause} WHERE ${where}`, values);
|
|
359
442
|
computed[key] = { [field]: result.rows[0].value };
|
|
360
443
|
}
|
|
361
444
|
}
|
|
@@ -383,15 +466,10 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
|
|
|
383
466
|
cb.isIn(db.qi(fkCol), ids);
|
|
384
467
|
const tenantJoins = [];
|
|
385
468
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// Side-channel: computed-field clauses on the join table
|
|
389
|
-
const computedClauses = collectJoinRefComputedClauses(joinTableConf, ref || {}, db);
|
|
390
|
-
if (computedClauses.length > 0) {
|
|
391
|
-
({ where, values } = appendComputedConditions(where, values, computedClauses, db));
|
|
392
|
-
}
|
|
469
|
+
const where = cb.build(1, db.ph);
|
|
470
|
+
const values = cb.getValues();
|
|
393
471
|
const selection = ref?.selection ?? defaultSelection;
|
|
394
|
-
const columns = buildSelectionColumns(selection, joinSchema, db);
|
|
472
|
+
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
|
|
395
473
|
const rows = await db.select({
|
|
396
474
|
tableName: joinSchema.tableName,
|
|
397
475
|
columns,
|
|
@@ -425,7 +503,7 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
|
|
|
425
503
|
const where = cb.build(1, db.ph);
|
|
426
504
|
const values = cb.getValues();
|
|
427
505
|
const selection = ref?.selection ?? defaultSelection;
|
|
428
|
-
const columns = buildSelectionColumns(selection, joinSchema, db);
|
|
506
|
+
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
|
|
429
507
|
const rows = await db.select({
|
|
430
508
|
tableName: joinSchema.tableName,
|
|
431
509
|
columns,
|
|
@@ -453,7 +531,7 @@ function buildByExpression(by, joinSchema, joinTableConf, db) {
|
|
|
453
531
|
}
|
|
454
532
|
const fn = joinTableConf?.computedFields?.[by];
|
|
455
533
|
if (fn) {
|
|
456
|
-
return evaluateComputedField(fn, joinSchema, db, undefined, `Computed field '${by}' with bound values cannot be used in aggregations.by`).
|
|
534
|
+
return evaluateComputedField(by, fn, joinSchema, db, undefined, `Computed field '${by}' with bound values cannot be used in aggregations.by`).value;
|
|
457
535
|
}
|
|
458
536
|
err400(`Unknown field: ${by}`);
|
|
459
537
|
}
|
|
@@ -482,7 +560,7 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
|
|
|
482
560
|
return;
|
|
483
561
|
const fn = AGG_FN[kind];
|
|
484
562
|
for (const f of fields) {
|
|
485
|
-
const col = validateSchemaField(f, joinSchema);
|
|
563
|
+
const col = validateSchemaField(f, joinSchema, joinTableConf);
|
|
486
564
|
selectParts.push(`${aggExpr(fn, db.qi(col))} as "${kind}_${f}"`);
|
|
487
565
|
}
|
|
488
566
|
};
|
|
@@ -502,13 +580,8 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
|
|
|
502
580
|
cb.isIn(db.qi(fkCol), ids);
|
|
503
581
|
const tenantJoins = [];
|
|
504
582
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
// Side-channel: computed-field clauses on the join table
|
|
508
|
-
const computedClauses = collectJoinRefComputedClauses(joinTableConf, groupRef, db);
|
|
509
|
-
if (computedClauses.length > 0) {
|
|
510
|
-
({ where, values } = appendComputedConditions(where, values, computedClauses, db));
|
|
511
|
-
}
|
|
583
|
+
const where = cb.build(1, db.ph);
|
|
584
|
+
const values = cb.getValues();
|
|
512
585
|
const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
|
|
513
586
|
const fromJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
|
|
514
587
|
const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)}${fromJoins} WHERE ${where} ${groupBy}`;
|
|
@@ -568,63 +641,36 @@ function dispatchConditionMethod(cb, method, colOrExpr, params) {
|
|
|
568
641
|
}
|
|
569
642
|
}
|
|
570
643
|
function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
|
|
644
|
+
assertFiltersReadable(ref.filters, joinTableConf);
|
|
571
645
|
const cb = (ref.filters && joinTableConf)
|
|
572
646
|
? joinTableConf.filters(ref.filters, db.cbDialect)
|
|
573
647
|
: new ConditionBuilder('AND', db.cbDialect);
|
|
574
|
-
|
|
575
|
-
for (const c of ref.conditions) {
|
|
576
|
-
if (!ALLOWED_SET.has(c.method)) {
|
|
577
|
-
err400(`Invalid condition method: ${c.method}`);
|
|
578
|
-
}
|
|
579
|
-
// Skip computed-field conditions here — they are processed via side-channel
|
|
580
|
-
// by `collectJoinRefComputedClauses` since ConditionBuilder cannot bind values
|
|
581
|
-
// for an LHS expression.
|
|
582
|
-
if (joinTableConf?.computedFields?.[c.field])
|
|
583
|
-
continue;
|
|
584
|
-
const col = db.qi(validateSchemaField(c.field, joinSchema));
|
|
585
|
-
dispatchConditionMethod(cb, c.method, col, c.params ?? []);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
return cb;
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Side-channel: gather equality + operator clauses targeting computed fields
|
|
592
|
-
* declared on the join table's `computedFields`. To be appended to a WHERE
|
|
593
|
-
* after the main `cb.build()` with correct placeholder offsets.
|
|
594
|
-
*/
|
|
595
|
-
function collectJoinRefComputedClauses(joinTableConf, ref, db) {
|
|
596
|
-
const out = [];
|
|
648
|
+
// Equality filters targeting the join table's computed fields
|
|
597
649
|
const computed = joinTableConf?.computedFields;
|
|
598
|
-
if (
|
|
599
|
-
return out;
|
|
600
|
-
// Equality filters on computed names
|
|
601
|
-
if (ref.filters) {
|
|
650
|
+
if (computed && ref.filters) {
|
|
602
651
|
for (const [name, value] of Object.entries(ref.filters)) {
|
|
603
652
|
if (value === null || value === undefined)
|
|
604
653
|
continue;
|
|
605
654
|
if (!computed[name])
|
|
606
655
|
continue;
|
|
607
|
-
const
|
|
608
|
-
|
|
656
|
+
const expr = evaluateComputedField(name, computed[name], joinTableConf.Schema, db, undefined, '', true);
|
|
657
|
+
cb.isEqual(expr, value);
|
|
609
658
|
}
|
|
610
659
|
}
|
|
611
|
-
// Operator conditions on computed names
|
|
612
660
|
if (ref.conditions?.length) {
|
|
613
661
|
for (const c of ref.conditions) {
|
|
614
|
-
if (!
|
|
615
|
-
continue;
|
|
616
|
-
if (!ALLOWED_SET.has(c.method))
|
|
662
|
+
if (!ALLOWED_SET.has(c.method)) {
|
|
617
663
|
err400(`Invalid condition method: ${c.method}`);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
664
|
+
}
|
|
665
|
+
// Computed fields resolve to an Expression carrying their own bound values, which
|
|
666
|
+
// the ConditionBuilder places together with the compared value.
|
|
667
|
+
const operand = computed?.[c.field]
|
|
668
|
+
? evaluateComputedField(c.field, computed[c.field], joinTableConf.Schema, db, undefined, '', true)
|
|
669
|
+
: db.qi(validateSchemaField(c.field, joinSchema, joinTableConf));
|
|
670
|
+
dispatchConditionMethod(cb, c.method, operand, c.params ?? []);
|
|
625
671
|
}
|
|
626
672
|
}
|
|
627
|
-
return
|
|
673
|
+
return cb;
|
|
628
674
|
}
|
|
629
675
|
/**
|
|
630
676
|
* Apply non-dotted conditions to the main `condition` ConditionBuilder when
|
|
@@ -633,7 +679,6 @@ function collectJoinRefComputedClauses(joinTableConf, ref, db) {
|
|
|
633
679
|
* offsets), since ConditionBuilder cannot bind values for an LHS expression.
|
|
634
680
|
*/
|
|
635
681
|
function applyConditions(condition, conditions, schema, tableConf, db) {
|
|
636
|
-
const computedClauses = [];
|
|
637
682
|
for (const c of conditions) {
|
|
638
683
|
// Skip dot-notation fields — those become aggregation conditions processed later
|
|
639
684
|
if (c.field.includes('.'))
|
|
@@ -641,63 +686,29 @@ function applyConditions(condition, conditions, schema, tableConf, db) {
|
|
|
641
686
|
if (!ALLOWED_SET.has(c.method)) {
|
|
642
687
|
err400(`Invalid condition method: ${c.method}`);
|
|
643
688
|
}
|
|
689
|
+
// A computed field resolves to an Expression carrying its own bound values; the
|
|
690
|
+
// ConditionBuilder places them, so no placeholder offset is computed here.
|
|
644
691
|
const ref = resolveFieldRef(c.field, schema, tableConf, db);
|
|
645
|
-
|
|
646
|
-
dispatchConditionMethod(condition, c.method, ref.expr, c.params);
|
|
647
|
-
}
|
|
648
|
-
else {
|
|
649
|
-
computedClauses.push({
|
|
650
|
-
method: c.method,
|
|
651
|
-
expr: ref.expr,
|
|
652
|
-
values: ref.values,
|
|
653
|
-
params: c.params ?? [],
|
|
654
|
-
});
|
|
655
|
-
}
|
|
692
|
+
dispatchConditionMethod(condition, c.method, ref.expr, c.params);
|
|
656
693
|
}
|
|
657
|
-
return computedClauses;
|
|
658
694
|
}
|
|
659
695
|
/**
|
|
660
|
-
* Equality filters targeting computed fields:
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* This complements the auto-generated equality on schema fields done by
|
|
664
|
-
* `tableConf.filters()`.
|
|
696
|
+
* Equality filters targeting computed fields: `filters.<computedName>` becomes
|
|
697
|
+
* `<expr> = <value>` on the same ConditionBuilder used for schema fields, which
|
|
698
|
+
* `tableConf.filters()` has already populated.
|
|
665
699
|
*/
|
|
666
|
-
function
|
|
667
|
-
const out = [];
|
|
700
|
+
function applyComputedFilters(condition, filters, tableConf, db) {
|
|
668
701
|
const computed = tableConf.computedFields;
|
|
669
702
|
if (!computed)
|
|
670
|
-
return
|
|
703
|
+
return;
|
|
671
704
|
for (const [name, value] of Object.entries(filters)) {
|
|
672
705
|
if (value === null || value === undefined)
|
|
673
706
|
continue;
|
|
674
707
|
if (!computed[name])
|
|
675
708
|
continue;
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
return out;
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* Append computed-field condition clauses (side-channel from applyConditions)
|
|
683
|
-
* to the current WHERE, coordinating placeholder indices with the running
|
|
684
|
-
* `values` array.
|
|
685
|
-
*/
|
|
686
|
-
function appendComputedConditions(currentWhere, currentValues, clauses, db) {
|
|
687
|
-
let where = currentWhere;
|
|
688
|
-
let values = [...currentValues];
|
|
689
|
-
for (const c of clauses) {
|
|
690
|
-
// Append the computed's own bound values first; the expr placeholders
|
|
691
|
-
// (if any) reference them via stable indices set by the consumer.
|
|
692
|
-
values = [...values, ...c.values];
|
|
693
|
-
const tmpCb = new ConditionBuilder('AND', db.cbDialect);
|
|
694
|
-
dispatchConditionMethod(tmpCb, c.method, c.expr, c.params);
|
|
695
|
-
const startIdx = values.length + 1;
|
|
696
|
-
const clauseSql = tmpCb.build(startIdx, db.ph);
|
|
697
|
-
where += ` AND ${clauseSql}`;
|
|
698
|
-
values.push(...tmpCb.getValues());
|
|
709
|
+
const expr = evaluateComputedField(name, computed[name], tableConf.Schema, db, undefined, '', true);
|
|
710
|
+
condition.isEqual(expr, value);
|
|
699
711
|
}
|
|
700
|
-
return { where, values };
|
|
701
712
|
}
|
|
702
713
|
function appendAggConditions(currentWhere, currentValues, conditions, db, dbTables, tableConf, joinGroup) {
|
|
703
714
|
let where = currentWhere;
|
|
@@ -736,20 +747,11 @@ function buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, curre
|
|
|
736
747
|
const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db);
|
|
737
748
|
const tenantJoins = [];
|
|
738
749
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, filterCondition, tenantJoins);
|
|
750
|
+
// The sub-condition (computed fields included) is built by the ConditionBuilder, which
|
|
751
|
+
// numbers its placeholders from where the outer values end.
|
|
739
752
|
const startIdx = values.length + 1;
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
// Side-channel: computed-field clauses
|
|
743
|
-
const computedClauses = collectJoinRefComputedClauses(joinTableConf, ref, db);
|
|
744
|
-
if (computedClauses.length > 0) {
|
|
745
|
-
let cwhere = filterWhere;
|
|
746
|
-
let cvalues = filterVals;
|
|
747
|
-
({ where: cwhere, values: cvalues } = appendComputedConditions(cwhere, [...values, ...cvalues], computedClauses, db));
|
|
748
|
-
// appendComputedConditions starts with currentValues; we need just the
|
|
749
|
-
// delta belonging to this EXISTS subquery to update filterVals.
|
|
750
|
-
filterVals = cvalues.slice(values.length);
|
|
751
|
-
filterWhere = cwhere;
|
|
752
|
-
}
|
|
753
|
+
const filterWhere = filterCondition.build(startIdx, db.ph);
|
|
754
|
+
const filterVals = [...filterCondition.getValues()];
|
|
753
755
|
// Qualify the FK with the join table: the EXISTS FROM may include the tenant
|
|
754
756
|
// through-join, so a bare column could become ambiguous.
|
|
755
757
|
const innerWhere = filterWhere
|
|
@@ -778,16 +780,16 @@ export async function searchEngine(dbTables, params) {
|
|
|
778
780
|
for (const a of Object.keys(joinLeft))
|
|
779
781
|
requireJoin(tableConf, a, true);
|
|
780
782
|
// Build main condition
|
|
783
|
+
assertFiltersReadable(filters, tableConf);
|
|
781
784
|
const condition = tableConf.filters(filters || {}, db.cbDialect);
|
|
782
|
-
// Filters
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
+
// Filters and conditions targeting computed fields go on the same ConditionBuilder:
|
|
786
|
+
// a computed resolves to an Expression carrying its own values, and the builder is
|
|
787
|
+
// what assigns every placeholder index.
|
|
785
788
|
if (filters && tableConf.computedFields) {
|
|
786
|
-
|
|
789
|
+
applyComputedFilters(condition, filters, tableConf, db);
|
|
787
790
|
}
|
|
788
|
-
let computedConditionClauses = [];
|
|
789
791
|
if (conditions?.length) {
|
|
790
|
-
|
|
792
|
+
applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
|
|
791
793
|
}
|
|
792
794
|
// Tenant
|
|
793
795
|
const tenantJoins = [];
|
|
@@ -799,14 +801,6 @@ export async function searchEngine(dbTables, params) {
|
|
|
799
801
|
}
|
|
800
802
|
let where = condition.build(1, db.ph);
|
|
801
803
|
let values = [...condition.getValues()];
|
|
802
|
-
// Append computed filter (equality) and condition (operator) clauses with
|
|
803
|
-
// correct placeholder offsets.
|
|
804
|
-
if (computedFilterClauses.length > 0) {
|
|
805
|
-
({ where, values } = appendComputedConditions(where, values, computedFilterClauses, db));
|
|
806
|
-
}
|
|
807
|
-
if (computedConditionClauses.length > 0) {
|
|
808
|
-
({ where, values } = appendComputedConditions(where, values, computedConditionClauses, db));
|
|
809
|
-
}
|
|
810
804
|
// joinMustExist (EXISTS)
|
|
811
805
|
if (joinMustExist && Object.keys(joinMustExist).length > 0) {
|
|
812
806
|
({ where, values } = buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, where, values, tenant));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { QueryClient } from './db.js';
|
|
2
|
+
import type { ITable, SchemaDefinition } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Read visibility (`ITable.readExclude`).
|
|
5
|
+
*
|
|
6
|
+
* Excluded fields are never projected by any read query and cannot be referenced
|
|
7
|
+
* from filters, conditions, orderBy, aggregations or join selections: allowing a
|
|
8
|
+
* field to be filtered while hiding it from the output would still leak its value
|
|
9
|
+
* by bisection. Write paths (insert/update/upsert) are deliberately unaffected —
|
|
10
|
+
* a field can be writable but never readable (e.g. a password hash).
|
|
11
|
+
*/
|
|
12
|
+
export declare function isReadExcluded(tableConf: ITable | undefined, field: string): boolean;
|
|
13
|
+
/** Throws 400 when `field` is read-excluded on `tableConf`. */
|
|
14
|
+
export declare function assertReadable(tableConf: ITable | undefined, field: string): void;
|
|
15
|
+
/**
|
|
16
|
+
* Throws 400 when a filter map targets a read-excluded field. Keys that are not
|
|
17
|
+
* excluded pass through untouched (extraFilters and computed names included).
|
|
18
|
+
*/
|
|
19
|
+
export declare function assertFiltersReadable(filters: Record<string, unknown> | undefined, tableConf: ITable | undefined): void;
|
|
20
|
+
/** Schema field names that may be read, in schema declaration order. */
|
|
21
|
+
export declare function readableFieldNames(tableConf: ITable | undefined, schema: SchemaDefinition): string[];
|
|
22
|
+
/**
|
|
23
|
+
* SELECT column list for a table with read exclusions, or `undefined` when the
|
|
24
|
+
* table has none (callers keep their existing `*` projection).
|
|
25
|
+
*
|
|
26
|
+
* Columns are qualified with the table name: read queries may carry a tenant
|
|
27
|
+
* `through` INNER JOIN, where a bare column name shared with the through table
|
|
28
|
+
* would be ambiguous.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readableSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string | undefined;
|
|
31
|
+
//# sourceMappingURL=read-access.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-access.d.ts","sourceRoot":"","sources":["../../src/lib/read-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG5D;;;;;;;;GAQG;AAEH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,IAAI,CAKN;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,MAAM,EAAE,CAIV;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,GAAG,SAAS,CAMpB"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { httpError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read visibility (`ITable.readExclude`).
|
|
4
|
+
*
|
|
5
|
+
* Excluded fields are never projected by any read query and cannot be referenced
|
|
6
|
+
* from filters, conditions, orderBy, aggregations or join selections: allowing a
|
|
7
|
+
* field to be filtered while hiding it from the output would still leak its value
|
|
8
|
+
* by bisection. Write paths (insert/update/upsert) are deliberately unaffected —
|
|
9
|
+
* a field can be writable but never readable (e.g. a password hash).
|
|
10
|
+
*/
|
|
11
|
+
export function isReadExcluded(tableConf, field) {
|
|
12
|
+
return tableConf?.readExclude?.includes(field) ?? false;
|
|
13
|
+
}
|
|
14
|
+
/** Throws 400 when `field` is read-excluded on `tableConf`. */
|
|
15
|
+
export function assertReadable(tableConf, field) {
|
|
16
|
+
if (isReadExcluded(tableConf, field)) {
|
|
17
|
+
throw httpError(400, `Field is not readable: ${field}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Throws 400 when a filter map targets a read-excluded field. Keys that are not
|
|
22
|
+
* excluded pass through untouched (extraFilters and computed names included).
|
|
23
|
+
*/
|
|
24
|
+
export function assertFiltersReadable(filters, tableConf) {
|
|
25
|
+
if (!filters || !tableConf?.readExclude?.length)
|
|
26
|
+
return;
|
|
27
|
+
for (const key of Object.keys(filters)) {
|
|
28
|
+
assertReadable(tableConf, key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Schema field names that may be read, in schema declaration order. */
|
|
32
|
+
export function readableFieldNames(tableConf, schema) {
|
|
33
|
+
const fields = Object.keys(schema.fields);
|
|
34
|
+
if (!tableConf?.readExclude?.length)
|
|
35
|
+
return fields;
|
|
36
|
+
return fields.filter((f) => !isReadExcluded(tableConf, f));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* SELECT column list for a table with read exclusions, or `undefined` when the
|
|
40
|
+
* table has none (callers keep their existing `*` projection).
|
|
41
|
+
*
|
|
42
|
+
* Columns are qualified with the table name: read queries may carry a tenant
|
|
43
|
+
* `through` INNER JOIN, where a bare column name shared with the through table
|
|
44
|
+
* would be ambiguous.
|
|
45
|
+
*/
|
|
46
|
+
export function readableSelectColumns(tableConf, schema, db) {
|
|
47
|
+
if (!tableConf?.readExclude?.length)
|
|
48
|
+
return undefined;
|
|
49
|
+
const table = db.qi(schema.tableName);
|
|
50
|
+
return readableFieldNames(tableConf, schema)
|
|
51
|
+
.map((f) => `${table}.${db.qi(schema.col(f))}`)
|
|
52
|
+
.join(', ');
|
|
53
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAGrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAyBzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAuHlF;AAED,eAAO,MAAM,sBAAsB;;;;;;;;EASjC,CAAC;AAEH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { ALLOWED_METHODS } from '../condition-methods.js';
|
|
3
|
+
import { readableFieldNames } from '../read-access.js';
|
|
4
|
+
/** Schema fields minus the ones hidden by `readExclude`. */
|
|
5
|
+
function readableFields(schema, tableConf) {
|
|
6
|
+
if (!tableConf?.readExclude?.length)
|
|
7
|
+
return { ...schema.fields };
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const field of readableFieldNames(tableConf, schema)) {
|
|
10
|
+
out[field] = schema.fields[field];
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
3
14
|
const JoinGroupResultItem = Type.Object({
|
|
4
15
|
sum: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
5
16
|
min: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
@@ -38,7 +49,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
|
|
|
38
49
|
}
|
|
39
50
|
}
|
|
40
51
|
const filterFields = {
|
|
41
|
-
...schema
|
|
52
|
+
...readableFields(schema, tableConf),
|
|
42
53
|
...tableConf.extraFilters,
|
|
43
54
|
...computedTypes,
|
|
44
55
|
};
|
|
@@ -71,7 +82,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
|
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
const joinFilterFields = joinTableConf
|
|
74
|
-
? { ...joinSchema
|
|
85
|
+
? { ...readableFields(joinSchema, joinTableConf), ...joinTableConf.extraFilters, ...joinComputedTypes }
|
|
75
86
|
: { ...joinSchema.fields };
|
|
76
87
|
const joinRefShape = {
|
|
77
88
|
filters: Type.Optional(Type.Partial(Type.Object(joinFilterFields))),
|
|
@@ -150,16 +161,17 @@ export function SearchTableResponse(dbTables, tableName) {
|
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
}
|
|
164
|
+
const mainReadable = readableFields(tableConf.Schema, tableConf);
|
|
153
165
|
const mainItem = Object.keys(computedTypes).length > 0
|
|
154
|
-
? Type.Partial(Type.Object({ ...
|
|
155
|
-
: Type.Partial(Type.Object(
|
|
166
|
+
? Type.Partial(Type.Object({ ...mainReadable, ...computedTypes }))
|
|
167
|
+
: Type.Partial(Type.Object(mainReadable));
|
|
156
168
|
const joinMultipleProps = {};
|
|
157
169
|
const joinLeftProps = {};
|
|
158
170
|
const joinGroupProps = {};
|
|
159
171
|
if (tableConf.allowedReadJoins) {
|
|
160
172
|
for (const joinDef of tableConf.allowedReadJoins) {
|
|
161
173
|
const { joinSchema, alias, unique } = joinDef;
|
|
162
|
-
const itemArray = Type.Array(Type.Partial(Type.Object(joinSchema.
|
|
174
|
+
const itemArray = Type.Array(Type.Partial(Type.Object(readableFields(joinSchema, dbTables[joinSchema.tableName]))));
|
|
163
175
|
if (unique) {
|
|
164
176
|
joinLeftProps[alias] = itemArray;
|
|
165
177
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { type SqlApi } from './sql-api.js';
|
|
3
|
+
import type { SqlApiPluginOptions } from '../types.js';
|
|
4
|
+
declare module 'fastify' {
|
|
5
|
+
interface FastifyInstance {
|
|
6
|
+
sqlApi: SqlApi;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Decorates `fastify.sqlApi` (lazy getter) on the given instance unless the
|
|
11
|
+
* decorator is already present in scope. The main plugin calls this at the
|
|
12
|
+
* fp level (parent scope); granular route plugins call it through
|
|
13
|
+
* `registerForAllTables` so they also work standalone, each within its own
|
|
14
|
+
* encapsulation context. The underlying pool (`fastify.pg` / `fastify.mysql`)
|
|
15
|
+
* is shared either way.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ensureSqlApiDecorator(fastify: FastifyInstance, options: SqlApiPluginOptions): void;
|
|
18
|
+
//# sourceMappingURL=sql-api-decorator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql-api-decorator.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api-decorator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,MAAM,EAAE,MAAM,CAAC;KAChB;CACF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,IAAI,CA2BN"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createQueryClient } from './db.js';
|
|
2
|
+
import { pgQueryable } from './adapters/pg-adapter.js';
|
|
3
|
+
import { mysqlQueryable } from './adapters/mysql-adapter.js';
|
|
4
|
+
import { createSqlApi } from './sql-api.js';
|
|
5
|
+
/**
|
|
6
|
+
* Decorates `fastify.sqlApi` (lazy getter) on the given instance unless the
|
|
7
|
+
* decorator is already present in scope. The main plugin calls this at the
|
|
8
|
+
* fp level (parent scope); granular route plugins call it through
|
|
9
|
+
* `registerForAllTables` so they also work standalone, each within its own
|
|
10
|
+
* encapsulation context. The underlying pool (`fastify.pg` / `fastify.mysql`)
|
|
11
|
+
* is shared either way.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureSqlApiDecorator(fastify, options) {
|
|
14
|
+
if (fastify.hasDecorator('sqlApi'))
|
|
15
|
+
return;
|
|
16
|
+
// Lazy-init QueryClient (internal closure — not decorated on fastify)
|
|
17
|
+
let cachedDb;
|
|
18
|
+
function getDb() {
|
|
19
|
+
if (!cachedDb) {
|
|
20
|
+
const pool = (options.dialect === 'mysql' || options.dialect === 'mariadb')
|
|
21
|
+
? mysqlQueryable(fastify.mysql)
|
|
22
|
+
: pgQueryable(fastify.pg);
|
|
23
|
+
cachedDb = createQueryClient(pool, options.dialect);
|
|
24
|
+
if (options.debug)
|
|
25
|
+
cachedDb.setDebug(true);
|
|
26
|
+
}
|
|
27
|
+
return cachedDb;
|
|
28
|
+
}
|
|
29
|
+
let cachedSqlApi;
|
|
30
|
+
fastify.decorate('sqlApi', {
|
|
31
|
+
getter() {
|
|
32
|
+
if (!cachedSqlApi) {
|
|
33
|
+
cachedSqlApi = createSqlApi(getDb(), options.DbTables, {
|
|
34
|
+
getTenantId: options.getTenantId,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return cachedSqlApi;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CA0BhG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CA0BhG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAMX;AAkED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAShB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
|
|
@@ -33,8 +33,26 @@ export function defineTable(config) {
|
|
|
33
33
|
validateAliasUniqueness(config.allowedReadJoins, 'allowedReadJoins');
|
|
34
34
|
validateAliasUniqueness(config.allowedWriteJoins, 'allowedWriteJoins');
|
|
35
35
|
validateComputedFields(config);
|
|
36
|
+
validateReadExclude(config);
|
|
36
37
|
return config;
|
|
37
38
|
}
|
|
39
|
+
function validateReadExclude(config) {
|
|
40
|
+
const excluded = config.readExclude;
|
|
41
|
+
if (!excluded?.length)
|
|
42
|
+
return;
|
|
43
|
+
const schemaFields = Object.keys(config.Schema.fields);
|
|
44
|
+
const pkFields = Array.isArray(config.primary) ? config.primary : [config.primary];
|
|
45
|
+
for (const field of excluded) {
|
|
46
|
+
if (!schemaFields.includes(field)) {
|
|
47
|
+
throw new Error(`defineTable: readExclude field '${field}' is not a schema field on ` +
|
|
48
|
+
`table '${config.Schema.tableName}'.`);
|
|
49
|
+
}
|
|
50
|
+
if (pkFields.includes(field)) {
|
|
51
|
+
throw new Error(`defineTable: readExclude cannot hide the primary key field '${field}' on ` +
|
|
52
|
+
`table '${config.Schema.tableName}' — reads and joins rely on it.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
38
56
|
function validateComputedFields(config) {
|
|
39
57
|
const computed = config.computedFields;
|
|
40
58
|
if (!computed)
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
|
-
import { type SqlApi } from '../../lib/sql-api.js';
|
|
3
2
|
import type { SqlApiPluginOptions } from '../../types.js';
|
|
4
|
-
declare module 'fastify' {
|
|
5
|
-
interface FastifyInstance {
|
|
6
|
-
sqlApi: SqlApi;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
3
|
declare const _default: (fastify: FastifyInstance, options: SqlApiPluginOptions) => Promise<void>;
|
|
10
4
|
export default _default;
|
|
11
5
|
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAa7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBA0DoC"}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import fp from 'fastify-plugin';
|
|
2
2
|
import { ConditionBuilder } from 'node-condition-builder';
|
|
3
3
|
import { getDialect } from '../../lib/dialect.js';
|
|
4
|
-
import {
|
|
5
|
-
import { pgQueryable } from '../../lib/adapters/pg-adapter.js';
|
|
6
|
-
import { mysqlQueryable } from '../../lib/adapters/mysql-adapter.js';
|
|
7
|
-
import { createSqlApi } from '../../lib/sql-api.js';
|
|
4
|
+
import { ensureSqlApiDecorator } from '../../lib/sql-api-decorator.js';
|
|
8
5
|
import { setupSwagger } from '../../lib/setup-swagger.js';
|
|
9
6
|
import searchRoutes from './search.routes.js';
|
|
10
7
|
import getRoutes from './get.routes.js';
|
|
@@ -56,31 +53,8 @@ export default fp(async function fastifyAutoSqlApi(fastify, options) {
|
|
|
56
53
|
// Set ConditionBuilder dialect globally
|
|
57
54
|
const dialect = getDialect(options.dialect || 'postgres');
|
|
58
55
|
ConditionBuilder.DIALECT = dialect.cbDialect;
|
|
59
|
-
// Lazy-init QueryClient (internal closure — not decorated on fastify)
|
|
60
|
-
let cachedDb;
|
|
61
|
-
function getDb() {
|
|
62
|
-
if (!cachedDb) {
|
|
63
|
-
const pool = (options.dialect === 'mysql' || options.dialect === 'mariadb')
|
|
64
|
-
? mysqlQueryable(fastify.mysql)
|
|
65
|
-
: pgQueryable(fastify.pg);
|
|
66
|
-
cachedDb = createQueryClient(pool, options.dialect);
|
|
67
|
-
if (options.debug)
|
|
68
|
-
cachedDb.setDebug(true);
|
|
69
|
-
}
|
|
70
|
-
return cachedDb;
|
|
71
|
-
}
|
|
72
56
|
// SqlApi: exposed to parent scope via fp
|
|
73
|
-
|
|
74
|
-
fastify.decorate('sqlApi', {
|
|
75
|
-
getter() {
|
|
76
|
-
if (!cachedSqlApi) {
|
|
77
|
-
cachedSqlApi = createSqlApi(getDb(), options.DbTables, {
|
|
78
|
-
getTenantId: options.getTenantId,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
return cachedSqlApi;
|
|
82
|
-
},
|
|
83
|
-
});
|
|
57
|
+
ensureSqlApiDecorator(fastify, options);
|
|
84
58
|
// Routes in a child scope — prefix applies here, not at fp level
|
|
85
59
|
const { prefix, ...routeOptions } = options;
|
|
86
60
|
await fastify.register(async (instance) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE5F,KAAK,WAAW,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC;AAElG,wBAAgB,eAAe,CAAC,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,GAAG,WAAW,EAAE,CAE9F;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CAqCf"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureSqlApiDecorator } from '../../lib/sql-api-decorator.js';
|
|
1
2
|
export function mergeOnRequests(options, tableConf) {
|
|
2
3
|
return [...(options.onRequests || []), ...(tableConf.onRequests || [])];
|
|
3
4
|
}
|
|
@@ -16,6 +17,9 @@ export function buildWriteDescription(action, tableName, tableConf) {
|
|
|
16
17
|
* default reply when handler returns a value.
|
|
17
18
|
*/
|
|
18
19
|
export async function registerForAllTables(fastify, options, spec) {
|
|
20
|
+
// Granular composition support: when a route plugin is registered without the
|
|
21
|
+
// main plugin, no ancestor has decorated `sqlApi` — create it in this scope.
|
|
22
|
+
ensureSqlApiDecorator(fastify, options);
|
|
19
23
|
const { DbTables } = options;
|
|
20
24
|
for (const [tableName, tableConf] of Object.entries(DbTables)) {
|
|
21
25
|
// operations acts as a whitelist; when omitted every operation is exposed.
|
package/dist/types/computed.d.ts
CHANGED
|
@@ -15,10 +15,20 @@ export interface ComputedFieldContext {
|
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* Result returned by a ComputedFieldFn.
|
|
18
|
-
* - `expr`: SQL fragment usable as a scalar expression.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* - `expr`: SQL fragment usable as a scalar expression. Mark each bound value with `?`
|
|
19
|
+
* (use `\?` for a literal question mark, e.g. the PostgreSQL jsonb operator). The engine
|
|
20
|
+
* assigns the placeholder positions — never write `$1` / `db.ph(n)` yourself, and never
|
|
21
|
+
* interpolate user-derived data into the SQL.
|
|
22
|
+
* - `values`: bound parameter values, one per `?` marker in `expr`. A mismatch between
|
|
23
|
+
* markers and values is rejected: it would bind values the query never references.
|
|
21
24
|
* - `type`: TypeBox schema for Swagger filters and main response (REQUIRED).
|
|
25
|
+
*
|
|
26
|
+
* Example:
|
|
27
|
+
* ({ qiCol }) => ({
|
|
28
|
+
* expr: `CASE WHEN ${qiCol('role')} = ? THEN ${qiCol('salary')} ELSE 0 END`,
|
|
29
|
+
* values: ['admin'],
|
|
30
|
+
* type: Type.Number(),
|
|
31
|
+
* })
|
|
22
32
|
*/
|
|
23
33
|
export interface ComputedFieldExpr {
|
|
24
34
|
expr: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"computed.d.ts","sourceRoot":"","sources":["../../src/types/computed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,WAAW,CAAC;IAChB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;CAC7D;AAED
|
|
1
|
+
{"version":3,"file":"computed.d.ts","sourceRoot":"","sources":["../../src/types/computed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,WAAW,CAAC;IAChB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;CAC7D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,EAAE,CAAC;IAClB,IAAI,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,oBAAoB,KAAK,iBAAiB,CAAC"}
|
package/dist/types/table.d.ts
CHANGED
|
@@ -102,6 +102,19 @@ export interface ITable<F extends Record<string, TSchema> = Record<string, TSche
|
|
|
102
102
|
* affected.
|
|
103
103
|
*/
|
|
104
104
|
operations?: TableOperation[];
|
|
105
|
+
/**
|
|
106
|
+
* Fields hidden from every read: they are not projected by search/get, not included
|
|
107
|
+
* in the read response schemas, and omitted from this table's default join selection
|
|
108
|
+
* when it is the target of a join.
|
|
109
|
+
*
|
|
110
|
+
* Referencing an excluded field from filters, conditions, orderBy, aggregations or an
|
|
111
|
+
* explicit join selection is rejected with 400 — hiding a field from the output while
|
|
112
|
+
* letting it be filtered would still leak its value by bisection.
|
|
113
|
+
*
|
|
114
|
+
* Write paths are NOT affected: a field can be writable but never readable (e.g. a
|
|
115
|
+
* password hash). Primary-key fields cannot be excluded.
|
|
116
|
+
*/
|
|
117
|
+
readExclude?: (string & keyof F)[];
|
|
105
118
|
defaultOrder?: string;
|
|
106
119
|
excludeFromCreation?: (string & keyof F)[];
|
|
107
120
|
distinctResults?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table.d.ts","sourceRoot":"","sources":["../../src/types/table.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"table.d.ts","sourceRoot":"","sources":["../../src/types/table.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-auto-sqlapi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Fastify plugin for automatic CRUD API generation from PostgreSQL, MySQL and MariaDB tables",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"homepage": "https://github.com/LucaRainone/fastify-auto-sqlapi#readme",
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@sinclair/typebox": "^0.34.14",
|
|
67
|
-
"node-condition-builder": "^1.0
|
|
67
|
+
"node-condition-builder": "^1.2.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@fastify/postgres": "^6.0.2",
|