fastify-auto-sqlapi 0.4.0 → 0.4.1

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.
@@ -81,10 +81,12 @@ Pagination:
81
81
  `orderBy` is a comma-separated list, each part one of:
82
82
  - `field [ASC|DESC]`, including computed fields.
83
83
  - `alias.parentField`: joinLeft alias; adds the LEFT JOIN.
84
- - `alias.fn.field`, with fn one of `sum|min|max|avg|count|distinctCount`. The joinGroup with that fn and field must be in the body; mains without children sort as 0.
84
+ - `alias.fn.field`, with fn one of `sum|min|max|avg|count|distinctCount`. The joinGroup with that fn and field must be in the body.
85
85
 
86
86
  For `alias.fn.field`, in both `orderBy` and conditions, the joinGroup `by` must be absent or equal to the FK (else 400). In `orderBy` it is also a 400 on tables with distinct results.
87
87
 
88
+ A main without children (or none left after the joinGroup filters) has `count`, `distinctCount` and `sum` = 0, but `min`, `max` and `avg` = null. Null sorts where the database puts it (PostgreSQL: last in ASC, first in DESC; MySQL/MariaDB: first in ASC, last in DESC) and fails every comparison: `max < 5` excludes such mains; `isNull` selects them.
89
+
88
90
  Condition methods and `params` arity:
89
91
  - `[v]`: `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual`
90
92
  - `["%p%"]`: `isLike|isNotLike|isILike|isNotILike`
@@ -823,3 +823,53 @@ dialect.upsertSql(quotedKeys, dialect.upsertUpdateSet(fields, conflictKeys));
823
823
  const { onConflict } = getDialect('postgres');
824
824
  onConflict!.sql(quotedKeys, onConflict!.updateSet(fields, conflictKeys));
825
825
  ```
826
+
827
+ ---
828
+
829
+ # Breaking Change — `min`, `max` and `avg` of a main row without children are NULL
830
+
831
+ *Landed in **0.4.1**.*
832
+
833
+ ## Why this changed
834
+
835
+ `orderBy` and HAVING-style conditions on `<alias>.<fn>.<field>` run a correlated subquery per
836
+ main row, and every aggregate was wrapped in `COALESCE(…, 0)`. Over no child rows, that 0 is
837
+ the right answer for `count`, `distinctCount` and `sum`, but `min`, `max` and `avg` have no
838
+ value at all:
839
+
840
+ - **PostgreSQL**: on a text, char, date or timestamp column the request failed with a 500,
841
+ `COALESCE types … and integer cannot be matched`.
842
+ - **MySQL/MariaDB**: no error, but those mains were compared as `0` — `max(orderDate) <
843
+ '2026-03-01'` matched every main without children.
844
+ - **Both, numeric columns**: a 0 that no child row holds decided the ordering and conditions.
845
+
846
+ ## What changed
847
+
848
+ - `count`, `distinctCount` and `sum` over no rows are still 0.
849
+ - `min`, `max` and `avg` over no rows are NULL. In `orderBy` they sort where the database puts
850
+ NULL (PostgreSQL: last in ASC, first in DESC; MySQL/MariaDB: first in ASC, last in DESC). In
851
+ conditions they fail every comparison; `isNull` / `isNotNull` select them.
852
+ - "Without children" includes children all excluded by the joinGroup's own `filters` /
853
+ `conditions`.
854
+ - `result.joinGroup` is unchanged: it was never coalesced.
855
+
856
+ ## Who is affected
857
+
858
+ Clients that order or filter by `min`, `max` or `avg` of a **numeric** column on tables where
859
+ some main rows have no children:
860
+
861
+ - `orderBy=orders.min.total ASC` on PostgreSQL: those mains move from first to last.
862
+ `… DESC`: from last to first. On MySQL/MariaDB the order is unchanged (NULL sorts as the
863
+ smallest value, where the 0 was for non-negative columns).
864
+ - `conditions: [{ field: 'orders.max.total', method: 'isLess', params: [5] }]`: those mains
865
+ no longer match. `isGreater` with a negative value, `isEqual` with 0 and similar change the
866
+ same way.
867
+
868
+ ## Migration
869
+
870
+ - To drop mains without children from a result, add `{ field: 'orders.count.id', method:
871
+ 'isGreater', params: [0] }` (or `isNotNull` on the aggregate).
872
+ - To list only them, use `isNull` on the aggregate, or `isEqual 0` on `count`.
873
+ - Conditions are ANDed, so "below 5 **or** no children" is no longer one request: run the
874
+ `isNull` search separately, or compare against a `count` instead where that expresses the
875
+ same thing.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,28 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.4.1]
13
+
14
+ ### Changed
15
+
16
+ - **BREAKING: `min`, `max` and `avg` of a main row without children are NULL, not 0, in
17
+ `orderBy` and HAVING-style conditions.** The correlated subquery behind `alias.fn.field`
18
+ wrapped every aggregate in `COALESCE(…, 0)`. That 0 is right for `count`, `distinctCount` and
19
+ `sum`, which keep it; for `min`/`max`/`avg` it invented a value the children do not have. On
20
+ numeric columns, such mains now sort where the database puts NULL (PostgreSQL: last in ASC,
21
+ first in DESC; MySQL/MariaDB: first in ASC, last in DESC) instead of as 0, and a condition
22
+ like `max < 5` no longer matches them (`isNull` does). See
23
+ [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--min-max-and-avg-of-a-main-row-without-children-are-null).
24
+
25
+ ### Fixed
26
+
27
+ - **PostgreSQL: `orderBy` or a condition on `min`/`max`/`avg` of a non-numeric column no longer
28
+ fails.** `orderBy=charge.max.monthYear` over a `character(7)` column (or any text, date or
29
+ timestamp one) was a 500, `COALESCE types character and integer cannot be matched`, whenever
30
+ the request reached the database. MySQL/MariaDB did not fail but compared those mains as `0`:
31
+ `max(orderDate) < '2026-03-01'` matched every main without children. Both now compare the
32
+ aggregate as NULL, as above.
33
+
12
34
  ## [0.4.0]
13
35
 
14
36
  ### Changed
@@ -746,7 +768,8 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
746
768
  primary key support in updates, transactional insert/update with secondaries, the hook matrix,
747
769
  and bulk operations.
748
770
 
749
- [Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.4.0...HEAD
771
+ [Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.4.1...HEAD
772
+ [0.4.1]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.4.1
750
773
  [0.4.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.4.0
751
774
  [0.3.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.3.0
752
775
  [0.2.4]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.4
@@ -11,9 +11,16 @@ import { Expression } from 'node-condition-builder';
11
11
  import type { QueryClient } from '../../db.js';
12
12
  import type { QueryParams } from '../query-params.js';
13
13
  import type { DbTables, ITable, JoinGroupRequest, SearchCondition, TenantContext } from '../../../types.js';
14
+ /**
15
+ * One aggregation function, as SQL. `zeroWhenEmpty`: over no rows it means 0 (nothing counted,
16
+ * nothing summed), so the correlated subquery coalesces its NULL to 0. min/max/avg over no rows
17
+ * have no value and stay NULL — a 0 would be invented data on numeric columns and a type error
18
+ * on text/date ones.
19
+ */
14
20
  export interface AggFn {
15
21
  sql: string;
16
22
  distinct: boolean;
23
+ zeroWhenEmpty: boolean;
17
24
  }
18
25
  export declare const AGG_FN: Record<string, AggFn>;
19
26
  export declare function aggExpr(fn: AggFn, qualifiedCol: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"aggregations.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/aggregations.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAoB,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EACV,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,KAAK;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE;AAEzD,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAOxC,CAAC;AAEF,wBAAgB,OAAO,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,MAAM,CAAC,EAAE,aAAa,GACrB,UAAU,CAgEZ;AAED,0FAA0F;AAC1F,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,eAAe,EAAE,EAC7B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,MAAM,CAAC,EAAE,aAAa,GACrB,MAAM,CAyBR"}
1
+ {"version":3,"file":"aggregations.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/aggregations.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAoB,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EACV,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B;;;;;GAKG;AACH,MAAM,WAAW,KAAK;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,OAAO,CAAA;CAAE;AAEjF,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAOxC,CAAC;AAEF,wBAAgB,OAAO,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,MAAM,CAAC,EAAE,aAAa,GACrB,UAAU,CAiEZ;AAED,0FAA0F;AAC1F,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,eAAe,EAAE,EAC7B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,MAAM,CAAC,EAAE,aAAa,GACrB,MAAM,CAyBR"}
@@ -14,12 +14,12 @@ import { err400, validateSchemaField } from './fields.js';
14
14
  import { requireJoin, extractJoinRefs, subqueryAlias } from './joins.js';
15
15
  import { buildJoinRefCondition, dispatchConditionMethod } from './conditions.js';
16
16
  export const AGG_FN = {
17
- sum: { sql: 'SUM', distinct: false },
18
- min: { sql: 'MIN', distinct: false },
19
- max: { sql: 'MAX', distinct: false },
20
- avg: { sql: 'AVG', distinct: false },
21
- count: { sql: 'COUNT', distinct: false },
22
- distinctCount: { sql: 'COUNT', distinct: true },
17
+ sum: { sql: 'SUM', distinct: false, zeroWhenEmpty: true },
18
+ min: { sql: 'MIN', distinct: false, zeroWhenEmpty: false },
19
+ max: { sql: 'MAX', distinct: false, zeroWhenEmpty: false },
20
+ avg: { sql: 'AVG', distinct: false, zeroWhenEmpty: false },
21
+ count: { sql: 'COUNT', distinct: false, zeroWhenEmpty: true },
22
+ distinctCount: { sql: 'COUNT', distinct: true, zeroWhenEmpty: true },
23
23
  };
24
24
  export function aggExpr(fn, qualifiedCol) {
25
25
  return fn.distinct ? `COUNT(DISTINCT ${qualifiedCol})` : `${fn.sql}(${qualifiedCol})`;
@@ -81,7 +81,8 @@ export function buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joi
81
81
  }
82
82
  }
83
83
  const qualifiedCol = `${subRef}.${db.qi(fieldCol)}`;
84
- const expr = `COALESCE((SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} AS ${subRef} WHERE ${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${tenantWhere}${filterWhere}), 0)`;
84
+ const subquery = `(SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} AS ${subRef} WHERE ${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${tenantWhere}${filterWhere})`;
85
+ const expr = aggFn.zeroWhenEmpty ? `COALESCE(${subquery}, 0)` : subquery;
85
86
  return new Expression(expr, [...tenantVals, ...filterVals]);
86
87
  }
87
88
  /** HAVING-style conditions: the dotted `<alias>.<fn>.<field>` entries of `conditions`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-auto-sqlapi",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",