fastify-auto-sqlapi 0.1.5 → 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 +15 -4
- package/README.md +10 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +97 -151
- package/dist/types/computed.d.ts +13 -3
- package/dist/types/computed.d.ts.map +1 -1
- package/package.json +2 -2
package/AGENTS_BACKEND.md
CHANGED
|
@@ -485,12 +485,22 @@ type ComputedFieldFn = (ctx: {
|
|
|
485
485
|
db: QueryClient; // dialect-aware (qi, ph, dialectName, dateTrunc, ...)
|
|
486
486
|
qiCol(field: string, opts?: { qualifier?: string }): string;
|
|
487
487
|
}) => {
|
|
488
|
-
expr: string; // SQL fragment
|
|
489
|
-
values: unknown[]; //
|
|
488
|
+
expr: string; // SQL fragment; mark each bound value with `?`
|
|
489
|
+
values: unknown[]; // one entry per `?` marker in expr
|
|
490
490
|
type: TSchema; // REQUIRED — used by Swagger and body validation
|
|
491
491
|
};
|
|
492
492
|
```
|
|
493
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
|
+
|
|
494
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.
|
|
495
505
|
|
|
496
506
|
### Validation & startup checks
|
|
@@ -505,8 +515,9 @@ For `joinLeft` specifically, the computed expr is automatically alias-prefixed b
|
|
|
505
515
|
|
|
506
516
|
### Limitations (first round, by design)
|
|
507
517
|
|
|
508
|
-
- **Bound `values` in computed expressions**
|
|
509
|
-
-
|
|
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.
|
|
510
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.
|
|
511
522
|
- **No chained computed** (a computed referencing another computed). Flat-only.
|
|
512
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.
|
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
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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,4 +1,4 @@
|
|
|
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
4
|
import { assertReadable, assertFiltersReadable, readableSelectColumns } from '../../read-access.js';
|
|
@@ -42,13 +42,12 @@ function resolveFieldRef(field, schema, tableConf, db, alias) {
|
|
|
42
42
|
assertReadable(tableConf, field);
|
|
43
43
|
const col = db.qi(schema.col(field));
|
|
44
44
|
const expr = alias ? `${db.qi(alias)}.${col}` : col;
|
|
45
|
-
return { expr,
|
|
45
|
+
return { expr, computed: false };
|
|
46
46
|
}
|
|
47
47
|
const fn = tableConf?.computedFields?.[field];
|
|
48
48
|
if (fn) {
|
|
49
49
|
const ctx = buildComputedContext(db, schema, alias);
|
|
50
|
-
|
|
51
|
-
return { expr: out.expr, values: out.values ?? [], computed: true };
|
|
50
|
+
return { expr: computedExpression(field, fn(ctx)), computed: true };
|
|
52
51
|
}
|
|
53
52
|
err400(`Unknown field: ${field}`);
|
|
54
53
|
}
|
|
@@ -98,16 +97,48 @@ function extractJoinRefs(db, tableConf, joinDef) {
|
|
|
98
97
|
};
|
|
99
98
|
}
|
|
100
99
|
/**
|
|
101
|
-
* Evaluate a computed field
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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.
|
|
104
106
|
*/
|
|
105
|
-
function evaluateComputedField(fn, schema, db, alias, message, allowBoundValues = false) {
|
|
107
|
+
function evaluateComputedField(name, fn, schema, db, alias, message, allowBoundValues = false) {
|
|
106
108
|
const ev = fn(buildComputedContext(db, schema, alias));
|
|
107
109
|
if (!allowBoundValues && (ev.values?.length ?? 0) > 0) {
|
|
108
110
|
err400(message);
|
|
109
111
|
}
|
|
110
|
-
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;
|
|
111
142
|
}
|
|
112
143
|
/** Map rows from snake_case columns to camelCase fields, preserving any computed-field columns
|
|
113
144
|
* that are projected by their declared name (skipped by camelcaseObject). */
|
|
@@ -243,9 +274,12 @@ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx)
|
|
|
243
274
|
}
|
|
244
275
|
const [, field, dir] = plain;
|
|
245
276
|
const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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;
|
|
249
283
|
}
|
|
250
284
|
return { sql: outParts.join(', '), values: outValues, leftJoinAliases };
|
|
251
285
|
}
|
|
@@ -279,9 +313,10 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
279
313
|
cb.isEqual(`${aliasIdent}.${db.qi(joinSchema.col(field))}`, value);
|
|
280
314
|
}
|
|
281
315
|
else if (computed?.[field]) {
|
|
282
|
-
// Computed field on the parent table:
|
|
283
|
-
|
|
284
|
-
|
|
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);
|
|
285
320
|
}
|
|
286
321
|
}
|
|
287
322
|
}
|
|
@@ -296,8 +331,8 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
296
331
|
dispatchConditionMethod(cb, c.method, col, c.params ?? []);
|
|
297
332
|
}
|
|
298
333
|
else if (computed?.[c.field]) {
|
|
299
|
-
const
|
|
300
|
-
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 ?? []);
|
|
301
336
|
}
|
|
302
337
|
else {
|
|
303
338
|
err400(`Unknown field: ${c.field}`);
|
|
@@ -337,8 +372,8 @@ function convertConfiguredOrder(order, tableConf, db) {
|
|
|
337
372
|
}
|
|
338
373
|
const computed = tableConf.computedFields?.[field];
|
|
339
374
|
if (computed) {
|
|
340
|
-
const ev = evaluateComputedField(computed, tableConf.Schema, db, undefined, `Computed field '${field}' with bound values cannot be used in defaultOrder`);
|
|
341
|
-
return `${ev.
|
|
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}`;
|
|
342
377
|
}
|
|
343
378
|
}
|
|
344
379
|
return trimmed;
|
|
@@ -366,8 +401,8 @@ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator
|
|
|
366
401
|
const fn = tableConf.computedFields?.[name];
|
|
367
402
|
if (!fn)
|
|
368
403
|
err400(`Unknown computed field in selectComputed: '${name}'`);
|
|
369
|
-
const out = evaluateComputedField(fn, tableConf.Schema, db, undefined, `Computed field '${name}' with bound values cannot be used in selectComputed`);
|
|
370
|
-
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)}`);
|
|
371
406
|
}
|
|
372
407
|
columns = projections.join(', ');
|
|
373
408
|
}
|
|
@@ -398,10 +433,12 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
|
|
|
398
433
|
for (const { key, field, fn } of computations) {
|
|
399
434
|
if (field) {
|
|
400
435
|
const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
|
|
401
|
-
|
|
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) {
|
|
402
439
|
err400(`Computed field '${field}' with bound values cannot be used in compute${key.charAt(0).toUpperCase() + key.slice(1)}`);
|
|
403
440
|
}
|
|
404
|
-
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);
|
|
405
442
|
computed[key] = { [field]: result.rows[0].value };
|
|
406
443
|
}
|
|
407
444
|
}
|
|
@@ -429,13 +466,8 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
|
|
|
429
466
|
cb.isIn(db.qi(fkCol), ids);
|
|
430
467
|
const tenantJoins = [];
|
|
431
468
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
// Side-channel: computed-field clauses on the join table
|
|
435
|
-
const computedClauses = collectJoinRefComputedClauses(joinTableConf, ref || {}, db);
|
|
436
|
-
if (computedClauses.length > 0) {
|
|
437
|
-
({ where, values } = appendComputedConditions(where, values, computedClauses, db));
|
|
438
|
-
}
|
|
469
|
+
const where = cb.build(1, db.ph);
|
|
470
|
+
const values = cb.getValues();
|
|
439
471
|
const selection = ref?.selection ?? defaultSelection;
|
|
440
472
|
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
|
|
441
473
|
const rows = await db.select({
|
|
@@ -499,7 +531,7 @@ function buildByExpression(by, joinSchema, joinTableConf, db) {
|
|
|
499
531
|
}
|
|
500
532
|
const fn = joinTableConf?.computedFields?.[by];
|
|
501
533
|
if (fn) {
|
|
502
|
-
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;
|
|
503
535
|
}
|
|
504
536
|
err400(`Unknown field: ${by}`);
|
|
505
537
|
}
|
|
@@ -548,13 +580,8 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
|
|
|
548
580
|
cb.isIn(db.qi(fkCol), ids);
|
|
549
581
|
const tenantJoins = [];
|
|
550
582
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
// Side-channel: computed-field clauses on the join table
|
|
554
|
-
const computedClauses = collectJoinRefComputedClauses(joinTableConf, groupRef, db);
|
|
555
|
-
if (computedClauses.length > 0) {
|
|
556
|
-
({ where, values } = appendComputedConditions(where, values, computedClauses, db));
|
|
557
|
-
}
|
|
583
|
+
const where = cb.build(1, db.ph);
|
|
584
|
+
const values = cb.getValues();
|
|
558
585
|
const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
|
|
559
586
|
const fromJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
|
|
560
587
|
const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)}${fromJoins} WHERE ${where} ${groupBy}`;
|
|
@@ -618,60 +645,32 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
|
|
|
618
645
|
const cb = (ref.filters && joinTableConf)
|
|
619
646
|
? joinTableConf.filters(ref.filters, db.cbDialect)
|
|
620
647
|
: new ConditionBuilder('AND', db.cbDialect);
|
|
621
|
-
|
|
622
|
-
for (const c of ref.conditions) {
|
|
623
|
-
if (!ALLOWED_SET.has(c.method)) {
|
|
624
|
-
err400(`Invalid condition method: ${c.method}`);
|
|
625
|
-
}
|
|
626
|
-
// Skip computed-field conditions here — they are processed via side-channel
|
|
627
|
-
// by `collectJoinRefComputedClauses` since ConditionBuilder cannot bind values
|
|
628
|
-
// for an LHS expression.
|
|
629
|
-
if (joinTableConf?.computedFields?.[c.field])
|
|
630
|
-
continue;
|
|
631
|
-
const col = db.qi(validateSchemaField(c.field, joinSchema, joinTableConf));
|
|
632
|
-
dispatchConditionMethod(cb, c.method, col, c.params ?? []);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
return cb;
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* Side-channel: gather equality + operator clauses targeting computed fields
|
|
639
|
-
* declared on the join table's `computedFields`. To be appended to a WHERE
|
|
640
|
-
* after the main `cb.build()` with correct placeholder offsets.
|
|
641
|
-
*/
|
|
642
|
-
function collectJoinRefComputedClauses(joinTableConf, ref, db) {
|
|
643
|
-
const out = [];
|
|
648
|
+
// Equality filters targeting the join table's computed fields
|
|
644
649
|
const computed = joinTableConf?.computedFields;
|
|
645
|
-
if (
|
|
646
|
-
return out;
|
|
647
|
-
// Equality filters on computed names
|
|
648
|
-
if (ref.filters) {
|
|
650
|
+
if (computed && ref.filters) {
|
|
649
651
|
for (const [name, value] of Object.entries(ref.filters)) {
|
|
650
652
|
if (value === null || value === undefined)
|
|
651
653
|
continue;
|
|
652
654
|
if (!computed[name])
|
|
653
655
|
continue;
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
+
const expr = evaluateComputedField(name, computed[name], joinTableConf.Schema, db, undefined, '', true);
|
|
657
|
+
cb.isEqual(expr, value);
|
|
656
658
|
}
|
|
657
659
|
}
|
|
658
|
-
// Operator conditions on computed names
|
|
659
660
|
if (ref.conditions?.length) {
|
|
660
661
|
for (const c of ref.conditions) {
|
|
661
|
-
if (!
|
|
662
|
-
continue;
|
|
663
|
-
if (!ALLOWED_SET.has(c.method))
|
|
662
|
+
if (!ALLOWED_SET.has(c.method)) {
|
|
664
663
|
err400(`Invalid condition method: ${c.method}`);
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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 ?? []);
|
|
672
671
|
}
|
|
673
672
|
}
|
|
674
|
-
return
|
|
673
|
+
return cb;
|
|
675
674
|
}
|
|
676
675
|
/**
|
|
677
676
|
* Apply non-dotted conditions to the main `condition` ConditionBuilder when
|
|
@@ -680,7 +679,6 @@ function collectJoinRefComputedClauses(joinTableConf, ref, db) {
|
|
|
680
679
|
* offsets), since ConditionBuilder cannot bind values for an LHS expression.
|
|
681
680
|
*/
|
|
682
681
|
function applyConditions(condition, conditions, schema, tableConf, db) {
|
|
683
|
-
const computedClauses = [];
|
|
684
682
|
for (const c of conditions) {
|
|
685
683
|
// Skip dot-notation fields — those become aggregation conditions processed later
|
|
686
684
|
if (c.field.includes('.'))
|
|
@@ -688,63 +686,29 @@ function applyConditions(condition, conditions, schema, tableConf, db) {
|
|
|
688
686
|
if (!ALLOWED_SET.has(c.method)) {
|
|
689
687
|
err400(`Invalid condition method: ${c.method}`);
|
|
690
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.
|
|
691
691
|
const ref = resolveFieldRef(c.field, schema, tableConf, db);
|
|
692
|
-
|
|
693
|
-
dispatchConditionMethod(condition, c.method, ref.expr, c.params);
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
computedClauses.push({
|
|
697
|
-
method: c.method,
|
|
698
|
-
expr: ref.expr,
|
|
699
|
-
values: ref.values,
|
|
700
|
-
params: c.params ?? [],
|
|
701
|
-
});
|
|
702
|
-
}
|
|
692
|
+
dispatchConditionMethod(condition, c.method, ref.expr, c.params);
|
|
703
693
|
}
|
|
704
|
-
return computedClauses;
|
|
705
694
|
}
|
|
706
695
|
/**
|
|
707
|
-
* Equality filters targeting computed fields:
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
* This complements the auto-generated equality on schema fields done by
|
|
711
|
-
* `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.
|
|
712
699
|
*/
|
|
713
|
-
function
|
|
714
|
-
const out = [];
|
|
700
|
+
function applyComputedFilters(condition, filters, tableConf, db) {
|
|
715
701
|
const computed = tableConf.computedFields;
|
|
716
702
|
if (!computed)
|
|
717
|
-
return
|
|
703
|
+
return;
|
|
718
704
|
for (const [name, value] of Object.entries(filters)) {
|
|
719
705
|
if (value === null || value === undefined)
|
|
720
706
|
continue;
|
|
721
707
|
if (!computed[name])
|
|
722
708
|
continue;
|
|
723
|
-
const
|
|
724
|
-
|
|
709
|
+
const expr = evaluateComputedField(name, computed[name], tableConf.Schema, db, undefined, '', true);
|
|
710
|
+
condition.isEqual(expr, value);
|
|
725
711
|
}
|
|
726
|
-
return out;
|
|
727
|
-
}
|
|
728
|
-
/**
|
|
729
|
-
* Append computed-field condition clauses (side-channel from applyConditions)
|
|
730
|
-
* to the current WHERE, coordinating placeholder indices with the running
|
|
731
|
-
* `values` array.
|
|
732
|
-
*/
|
|
733
|
-
function appendComputedConditions(currentWhere, currentValues, clauses, db) {
|
|
734
|
-
let where = currentWhere;
|
|
735
|
-
let values = [...currentValues];
|
|
736
|
-
for (const c of clauses) {
|
|
737
|
-
// Append the computed's own bound values first; the expr placeholders
|
|
738
|
-
// (if any) reference them via stable indices set by the consumer.
|
|
739
|
-
values = [...values, ...c.values];
|
|
740
|
-
const tmpCb = new ConditionBuilder('AND', db.cbDialect);
|
|
741
|
-
dispatchConditionMethod(tmpCb, c.method, c.expr, c.params);
|
|
742
|
-
const startIdx = values.length + 1;
|
|
743
|
-
const clauseSql = tmpCb.build(startIdx, db.ph);
|
|
744
|
-
where += ` AND ${clauseSql}`;
|
|
745
|
-
values.push(...tmpCb.getValues());
|
|
746
|
-
}
|
|
747
|
-
return { where, values };
|
|
748
712
|
}
|
|
749
713
|
function appendAggConditions(currentWhere, currentValues, conditions, db, dbTables, tableConf, joinGroup) {
|
|
750
714
|
let where = currentWhere;
|
|
@@ -783,20 +747,11 @@ function buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, curre
|
|
|
783
747
|
const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db);
|
|
784
748
|
const tenantJoins = [];
|
|
785
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.
|
|
786
752
|
const startIdx = values.length + 1;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
// Side-channel: computed-field clauses
|
|
790
|
-
const computedClauses = collectJoinRefComputedClauses(joinTableConf, ref, db);
|
|
791
|
-
if (computedClauses.length > 0) {
|
|
792
|
-
let cwhere = filterWhere;
|
|
793
|
-
let cvalues = filterVals;
|
|
794
|
-
({ where: cwhere, values: cvalues } = appendComputedConditions(cwhere, [...values, ...cvalues], computedClauses, db));
|
|
795
|
-
// appendComputedConditions starts with currentValues; we need just the
|
|
796
|
-
// delta belonging to this EXISTS subquery to update filterVals.
|
|
797
|
-
filterVals = cvalues.slice(values.length);
|
|
798
|
-
filterWhere = cwhere;
|
|
799
|
-
}
|
|
753
|
+
const filterWhere = filterCondition.build(startIdx, db.ph);
|
|
754
|
+
const filterVals = [...filterCondition.getValues()];
|
|
800
755
|
// Qualify the FK with the join table: the EXISTS FROM may include the tenant
|
|
801
756
|
// through-join, so a bare column could become ambiguous.
|
|
802
757
|
const innerWhere = filterWhere
|
|
@@ -827,15 +782,14 @@ export async function searchEngine(dbTables, params) {
|
|
|
827
782
|
// Build main condition
|
|
828
783
|
assertFiltersReadable(filters, tableConf);
|
|
829
784
|
const condition = tableConf.filters(filters || {}, db.cbDialect);
|
|
830
|
-
// Filters
|
|
831
|
-
//
|
|
832
|
-
|
|
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.
|
|
833
788
|
if (filters && tableConf.computedFields) {
|
|
834
|
-
|
|
789
|
+
applyComputedFilters(condition, filters, tableConf, db);
|
|
835
790
|
}
|
|
836
|
-
let computedConditionClauses = [];
|
|
837
791
|
if (conditions?.length) {
|
|
838
|
-
|
|
792
|
+
applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
|
|
839
793
|
}
|
|
840
794
|
// Tenant
|
|
841
795
|
const tenantJoins = [];
|
|
@@ -847,14 +801,6 @@ export async function searchEngine(dbTables, params) {
|
|
|
847
801
|
}
|
|
848
802
|
let where = condition.build(1, db.ph);
|
|
849
803
|
let values = [...condition.getValues()];
|
|
850
|
-
// Append computed filter (equality) and condition (operator) clauses with
|
|
851
|
-
// correct placeholder offsets.
|
|
852
|
-
if (computedFilterClauses.length > 0) {
|
|
853
|
-
({ where, values } = appendComputedConditions(where, values, computedFilterClauses, db));
|
|
854
|
-
}
|
|
855
|
-
if (computedConditionClauses.length > 0) {
|
|
856
|
-
({ where, values } = appendComputedConditions(where, values, computedConditionClauses, db));
|
|
857
|
-
}
|
|
858
804
|
// joinMustExist (EXISTS)
|
|
859
805
|
if (joinMustExist && Object.keys(joinMustExist).length > 0) {
|
|
860
806
|
({ where, values } = buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, where, values, tenant));
|
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/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",
|