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.
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -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
|
|
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`
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -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.
|
|
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;
|
|
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
|
|
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`. */
|