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 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[]; // bound values (see limitations below)
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** are supported only on `filters`/`conditions` (main and `joinMustExist`/`joinMultiple`/`joinGroup`). They are rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `orderBy`, and `joinLeft.filters`/`conditions` — those paths require placeholder coordination not done in the first round. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no placeholders.
509
- - **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). 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.
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;AA+nC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CA4IvB"}
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, values: [], computed: false };
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
- const out = fn(ctx);
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. When `allowBoundValues=false`, throws 400 if the computed binds
102
- * any parameters — used in positions where parameter binding cannot be coordinated (orderBy,
103
- * compute*, joinLeft.filters, etc).
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
- outParts.push(`${ref.expr} ${(dir || 'ASC').toUpperCase()}`);
247
- outValues.push(...ref.values);
248
- currentIdx += ref.values.length;
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: build expr with alias-qualified columns.
283
- const ev = evaluateComputedField(computed[field], joinSchema, db, alias, `Computed field '${field}' with bound values is not supported in joinLeft.filters`);
284
- cb.isEqual(ev.expr, value);
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 ev = evaluateComputedField(computed[c.field], joinSchema, db, alias, `Computed field '${c.field}' with bound values is not supported in joinLeft.conditions`);
300
- dispatchConditionMethod(cb, c.method, ev.expr, c.params ?? []);
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.expr}${suffix}`;
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.expr} AS ${db.qi(name)}`);
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
- if (ref.values.length > 0) {
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
- let where = cb.build(1, db.ph);
433
- let values = cb.getValues();
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`).expr;
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
- let where = cb.build(1, db.ph);
552
- let values = cb.getValues();
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
- if (ref.conditions?.length) {
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 (!computed)
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 ev = evaluateComputedField(computed[name], joinTableConf.Schema, db, undefined, '', true);
655
- out.push({ method: 'isEqual', expr: ev.expr, values: ev.values ?? [], params: [value] });
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 (!computed[c.field])
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
- const ev = evaluateComputedField(computed[c.field], joinTableConf.Schema, db, undefined, '', true);
666
- out.push({
667
- method: c.method,
668
- expr: ev.expr,
669
- values: ev.values ?? [],
670
- params: c.params ?? [],
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 out;
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
- if (!ref.computed) {
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: when `filters.<computedName>` is
708
- * set on the request body and the computed exists on `tableConf.computedFields`,
709
- * generate a side-channel clause `<expr> = ?` with the user value as parameter.
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 collectComputedFilterClauses(filters, tableConf, db) {
714
- const out = [];
700
+ function applyComputedFilters(condition, filters, tableConf, db) {
715
701
  const computed = tableConf.computedFields;
716
702
  if (!computed)
717
- return out;
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 ref = evaluateComputedField(computed[name], tableConf.Schema, db, undefined, '', true);
724
- out.push({ method: 'isEqual', expr: ref.expr, values: ref.values ?? [], params: [value] });
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
- let filterWhere = filterCondition.build(startIdx, db.ph);
788
- let filterVals = [...filterCondition.getValues()];
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 that target computed fields (declared on tableConf.computedFields)
831
- // are applied via the side-channel pattern further down.
832
- let computedFilterClauses = [];
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
- computedFilterClauses = collectComputedFilterClauses(filters, tableConf, db);
789
+ applyComputedFilters(condition, filters, tableConf, db);
835
790
  }
836
- let computedConditionClauses = [];
837
791
  if (conditions?.length) {
838
- computedConditionClauses = applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
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));
@@ -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
- * - `values`: bound parameter values (always parameterized — never interpolate
20
- * user-derived data into `expr`).
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;;;;;;GAMG;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"}
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.5",
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.6"
67
+ "node-condition-builder": "^1.2.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@fastify/postgres": "^6.0.2",