@happyvertical/sql 0.105.11 → 0.106.0

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/README.md CHANGED
@@ -636,6 +636,94 @@ semantics for `where` and `having`, and maps time buckets per adapter:
636
636
  PostgreSQL, DuckDB, and JSON use `date_trunc(...)`; SQLite uses portable
637
637
  `strftime(...)`/`date(...)` expressions.
638
638
 
639
+ #### Joins, row expressions and filtered aggregates
640
+
641
+ A spec can join tables, aggregate a computed value, and count or sum only the
642
+ rows a predicate holds for. All three are structured data (never SQL text):
643
+ identifiers are validated, strings and dates are bound, and an aggregate that
644
+ takes an `expr` or `filter` must be `count`, `sum`, `avg`, `min` or `max`.
645
+ Other parts of an `AggregateSpec` predate this and are not hardened for
646
+ untrusted input: `fn` is free-form for a plain column, and `raw()` keys in
647
+ `where`/`having` use a string prefix that JSON can forge. Validate specs built
648
+ from untrusted input before passing them in.
649
+
650
+ ```typescript
651
+ const aggregate = buildAggregate(
652
+ {
653
+ from: 'time_entries',
654
+ fromAlias: 'te',
655
+ joins: [
656
+ {
657
+ table: 'work_orders',
658
+ alias: 'wo',
659
+ type: 'left', // 'inner' (default) or 'left'
660
+ on: {
661
+ op: 'and',
662
+ operands: [
663
+ { op: 'eq', left: { col: 'te.work_order_id' }, right: { col: 'wo.id' } },
664
+ // Put the tenant equality in ON so ids that collide across tenants never match.
665
+ { op: 'eq', left: { col: 'te.tenant_id' }, right: { col: 'wo.tenant_id' } },
666
+ ],
667
+ },
668
+ },
669
+ ],
670
+ select: [
671
+ { column: 'wo.title', as: 'title' },
672
+ { column: 'wo.estimated_hours', as: 'estimated_hours' },
673
+ { fn: 'sum', column: 'te.hours', as: 'booked' },
674
+ // Filtered count: rows where the predicate is true (false and NULL are skipped).
675
+ {
676
+ fn: 'count',
677
+ filter: { op: 'gt', left: { col: 'te.hours' }, right: { value: 8 } },
678
+ as: 'long_days',
679
+ },
680
+ // A row expression, aggregated.
681
+ {
682
+ fn: 'avg',
683
+ expr: { op: 'secondsBetween', later: { col: 'te.ended_at' }, earlier: { col: 'te.started_at' } },
684
+ as: 'avg_seconds',
685
+ },
686
+ ],
687
+ where: { 'te.tenant_id': tenantId },
688
+ },
689
+ 1,
690
+ 'postgres',
691
+ );
692
+ ```
693
+
694
+ - **Joins.** `from` takes an optional `fromAlias`; each join has a `table`, a
695
+ unique `alias` (letters, digits, underscore) and a required `on` expression.
696
+ With joins, a table-qualified column must be `alias.column` where the alias
697
+ is `from`, `fromAlias` or a join alias; anything else is rejected. The
698
+ builder does not know what a tenant is: the caller puts the tenant condition
699
+ in `on` and in `where`.
700
+ - **Expressions.** Operands are `{ col }`, `{ value }` or a node:
701
+ `add | sub | mul | div` (`div` is fractional and returns NULL for a zero
702
+ divisor), `secondsBetween` (timestamp difference in seconds),
703
+ `eq | ne | lt | lte | gt | gte` (`eq`/`ne` against a NULL literal compare with
704
+ `IS [NOT] NULL`), `and | or | not`, `isNull | isNotNull` and `coalesce`.
705
+ Numbers, booleans and null are inlined after validation; strings and dates
706
+ are bound as parameters. Expressions are limited to depth 24 and 1024 nodes per spec.
707
+ - **Where an expression goes.** `{ expr, as }` in `select` is a grouping key
708
+ (grouped by its position, so a bound value is not repeated); `expr` on an
709
+ aggregate replaces `column`; `filter` on an aggregate restricts its rows
710
+ (rendered as `CASE WHEN`, the same on every dialect); `whereExpr` is a row
711
+ predicate ANDed with the whole of `where` (OR groups included). `having` and `orderBy` refer to any of them by
712
+ alias, as before.
713
+ - **Parameters.** Values are numbered in build order (select, joins, `where`,
714
+ `whereExpr`, `having`, paging) and returned in that order. A parameter used
715
+ by a select expression is referenced again, with the same number, when
716
+ `having` names its alias; adapters that rewrite `$N` must resolve each
717
+ occurrence by number (the bundled ones do).
718
+ - Date literals follow `buildWhere`: PostgreSQL binds them as `CAST($N AS
719
+ TIMESTAMPTZ)`; DuckDB and JSON bind them as `CAST($N AS
720
+ TIMESTAMP)`, which is read in the session time zone, so compare a
721
+ `TIMESTAMPTZ` column against a date there with the zone in mind.
722
+ - Specs without these fields produce the SQL they always did, except that a
723
+ dotted column with no `as` (`t.region`, which used to alias to the invalid
724
+ `t.region`) now aliases to its last segment. Two select items that resolve to
725
+ the same alias are rejected.
726
+
639
727
  ### Schema Synchronization
640
728
 
641
729
  ```typescript
@@ -0,0 +1,100 @@
1
+ import type { SqlAdapterType } from './shared/utils.js';
2
+ /**
3
+ * Closed, structured expression nodes for {@link buildAggregate}.
4
+ *
5
+ * An expression is data, never SQL text: every node is checked against a fixed
6
+ * set of operators, column references are validated identifiers, and literals
7
+ * are either inlined after validation (finite numbers, booleans, null) or
8
+ * bound as parameters (strings, dates). There is no raw-SQL escape hatch.
9
+ */
10
+ /** A scalar a node may hold. Strings and dates are bound; the rest are inlined. */
11
+ export type AggregateLiteral = string | number | boolean | Date | null;
12
+ export type AggregateArithmeticOp = 'add' | 'sub' | 'mul' | 'div';
13
+ export type AggregateComparisonOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte';
14
+ export type AggregateOperand =
15
+ /** A column, bare (`qty`) or qualified by a table alias (`wo.estimated_hours`). */
16
+ {
17
+ col: string;
18
+ }
19
+ /** A literal value. */
20
+ | {
21
+ value: AggregateLiteral;
22
+ } | AggregateExpression;
23
+ export type AggregateExpression =
24
+ /**
25
+ * `left op right`. `div` yields a fractional result on every dialect (integer
26
+ * operands are not truncated) and NULL, not an error, for a zero divisor.
27
+ */
28
+ {
29
+ op: AggregateArithmeticOp;
30
+ left: AggregateOperand;
31
+ right: AggregateOperand;
32
+ }
33
+ /**
34
+ * Seconds from `earlier` to `later`, both timestamps; fractional, NULL if
35
+ * either is NULL. Positive when `later` is after `earlier`.
36
+ */
37
+ | {
38
+ op: 'secondsBetween';
39
+ later: AggregateOperand;
40
+ earlier: AggregateOperand;
41
+ }
42
+ /**
43
+ * A comparison. `eq`/`ne` against a NULL literal compare with IS [NOT] NULL;
44
+ * any other comparison involving NULL is NULL (not true), as in SQL.
45
+ */
46
+ | {
47
+ op: AggregateComparisonOp;
48
+ left: AggregateOperand;
49
+ right: AggregateOperand;
50
+ } | {
51
+ op: 'and' | 'or';
52
+ operands: AggregateOperand[];
53
+ } | {
54
+ op: 'not';
55
+ operand: AggregateOperand;
56
+ } | {
57
+ op: 'isNull' | 'isNotNull';
58
+ operand: AggregateOperand;
59
+ }
60
+ /** The first operand that is not NULL. */
61
+ | {
62
+ op: 'coalesce';
63
+ operands: AggregateOperand[];
64
+ };
65
+ /** A table joined to the aggregate's `from`. */
66
+ export interface AggregateJoin {
67
+ /** The joined table. */
68
+ table: string;
69
+ /** Its alias in the query; referenced as `alias.column`. Unique, no dots. */
70
+ alias: string;
71
+ /** `inner` (default) or `left`. */
72
+ type?: 'inner' | 'left';
73
+ /**
74
+ * The join condition. Callers put a tenant equality here (for example
75
+ * `eq(a.tenant_id, b.tenant_id)`) so ids that collide across tenants never
76
+ * match; the builder does not know what a tenant is.
77
+ */
78
+ on: AggregateExpression;
79
+ }
80
+ /** Where an expression renders: its identifier checks and its parameter numbering. */
81
+ export interface ExpressionContext {
82
+ adapterType: SqlAdapterType | undefined;
83
+ /** The parameter index the next bound value takes. Advanced as values are bound. */
84
+ nextIndex: number;
85
+ /** Values bound so far, in index order. */
86
+ values: unknown[];
87
+ /** Validates (and returns) an identifier; throws on anything unsafe. */
88
+ identifier: (identifier: string) => string;
89
+ /** Called with each column reference so the caller can check its table alias. */
90
+ onColumn?: (column: string) => void;
91
+ /** Nodes rendered so far across every expression of one build; capped at {@link MAX_NODES}. */
92
+ nodeCount?: number;
93
+ }
94
+ /**
95
+ * Render an expression node to SQL, binding its string and date literals into
96
+ * `ctx` as numbered parameters. Throws on any unknown operator, unsafe
97
+ * identifier, non-finite number, or an expression too large to be legitimate.
98
+ */
99
+ export declare function renderAggregateExpression(node: AggregateOperand, ctx: ExpressionContext): string;
100
+ //# sourceMappingURL=aggregate-expression.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregate-expression.d.ts","sourceRoot":"","sources":["../src/aggregate-expression.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD;;;;;;;GAOG;AAEH,mFAAmF;AACnF,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,IAAI,CAAC;AAEvE,MAAM,MAAM,qBAAqB,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC;AAClE,MAAM,MAAM,qBAAqB,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,CAAC;AAE9E,MAAM,MAAM,gBAAgB;AAC1B,mFAAmF;AACjF;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE;AACjB,uBAAuB;GACrB;IAAE,KAAK,EAAE,gBAAgB,CAAA;CAAE,GAC3B,mBAAmB,CAAC;AAExB,MAAM,MAAM,mBAAmB;AAC7B;;;GAGG;AACD;IACE,EAAE,EAAE,qBAAqB,CAAC;IAC1B,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,EAAE,gBAAgB,CAAC;CACzB;AACH;;;GAGG;GACD;IAAE,EAAE,EAAE,gBAAgB,CAAC;IAAC,KAAK,EAAE,gBAAgB,CAAC;IAAC,OAAO,EAAE,gBAAgB,CAAA;CAAE;AAC9E;;;GAGG;GACD;IACE,EAAE,EAAE,qBAAqB,CAAC;IAC1B,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GACD;IAAE,EAAE,EAAE,KAAK,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;CAAE,GAClD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,gBAAgB,CAAA;CAAE,GACxC;IAAE,EAAE,EAAE,QAAQ,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,gBAAgB,CAAA;CAAE;AAC3D,0CAA0C;GACxC;IAAE,EAAE,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAErD,gDAAgD;AAChD,MAAM,WAAW,aAAa;IAC5B,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACxB;;;;OAIG;IACH,EAAE,EAAE,mBAAmB,CAAC;CACzB;AAqBD,sFAAsF;AACtF,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,cAAc,GAAG,SAAS,CAAC;IACxC,oFAAoF;IACpF,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,MAAM,EAAE,OAAO,EAAE,CAAC;IAClB,wEAAwE;IACxE,UAAU,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,+FAA+F;IAC/F,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgKD;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,gBAAgB,EACtB,GAAG,EAAE,iBAAiB,GACrB,MAAM,CAER"}
@@ -1,3 +1,4 @@
1
+ import { type AggregateJoin, type AggregateOperand } from './aggregate-expression.js';
1
2
  import type { WhereClause } from './shared/types.js';
2
3
  import { type SqlAdapterType } from './shared/utils.js';
3
4
  export type AggregateTimeBucketUnit = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
@@ -5,9 +6,22 @@ export type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max' | (strin
5
6
  export type AggregateSelectExpr = {
6
7
  column: string;
7
8
  as?: string;
9
+ }
10
+ /** A row expression used as a grouping key (a computed column of each row). */
11
+ | {
12
+ expr: AggregateOperand;
13
+ as: string;
8
14
  } | {
9
15
  fn: AggregateFunction;
10
16
  column?: string;
17
+ /** A row expression to aggregate in place of `column` (not both). */
18
+ expr?: AggregateOperand;
19
+ /**
20
+ * Aggregate only the rows this predicate holds for (a filtered count or
21
+ * sum). Rows where it is false or NULL are skipped. With `count` and no
22
+ * column or expression it counts the matching rows.
23
+ */
24
+ filter?: AggregateOperand;
11
25
  as: string;
12
26
  distinct?: boolean;
13
27
  } | {
@@ -17,8 +31,17 @@ export type AggregateSelectExpr = {
17
31
  };
18
32
  export interface AggregateSpec {
19
33
  from: string;
34
+ /** Alias for `from`, so its columns can be qualified (`o.total`) beside joined tables. */
35
+ fromAlias?: string;
36
+ /**
37
+ * Tables joined to `from`, in order. With joins, a column naming a table must
38
+ * be qualified by `from`, `fromAlias` or a join alias.
39
+ */
40
+ joins?: AggregateJoin[];
20
41
  select: AggregateSelectExpr[];
21
42
  where?: WhereClause;
43
+ /** A row predicate built from expression nodes, ANDed with `where`. */
44
+ whereExpr?: AggregateOperand;
22
45
  groupBy?: string[];
23
46
  having?: WhereClause;
24
47
  orderBy?: string | string[];
@@ -1 +1 @@
1
- {"version":3,"file":"aggregate.d.ts","sourceRoot":"","sources":["../src/aggregate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAIL,KAAK,cAAc,EAEpB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,uBAAuB,GAC/B,QAAQ,GACR,MAAM,GACN,KAAK,GACL,MAAM,GACN,OAAO,GACP,SAAS,GACT,MAAM,CAAC;AAEX,MAAM,MAAM,iBAAiB,GACzB,OAAO,GACP,KAAK,GACL,KAAK,GACL,KAAK,GACL,KAAK,GACL,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB,MAAM,MAAM,mBAAmB,GAC3B;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/B;IACE,EAAE,EAAE,iBAAiB,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GACD;IACE,MAAM,EAAE,uBAAuB,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEN,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,mBAAmB,EAAE,CAAC;IAC9B,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,GAAG,EAAE,CAAC;CACf;AA+BD,wBAAgB,UAAU,CACxB,WAAW,EAAE,cAAc,GAAG,SAAS,EACvC,IAAI,EAAE,uBAAuB,EAC7B,MAAM,EAAE,MAAM,GACb,MAAM,CA4BR;AAgHD,wBAAgB,cAAc,CAC5B,IAAI,EAAE,aAAa,EACnB,UAAU,SAAI,EACd,WAAW,CAAC,EAAE,cAAc,GAC3B,oBAAoB,CA8EtB"}
1
+ {"version":3,"file":"aggregate.d.ts","sourceRoot":"","sources":["../src/aggregate.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,gBAAgB,EAGtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAIL,KAAK,cAAc,EAEpB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,uBAAuB,GAC/B,QAAQ,GACR,MAAM,GACN,KAAK,GACL,MAAM,GACN,OAAO,GACP,SAAS,GACT,MAAM,CAAC;AAEX,MAAM,MAAM,iBAAiB,GACzB,OAAO,GACP,KAAK,GACL,KAAK,GACL,KAAK,GACL,KAAK,GACL,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB,MAAM,MAAM,mBAAmB,GAC3B;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE;AACjC,+EAA+E;GAC7E;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GACtC;IACE,EAAE,EAAE,iBAAiB,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GACD;IACE,MAAM,EAAE,uBAAuB,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEN,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,KAAK,CAAC,EAAE,aAAa,EAAE,CAAC;IACxB,MAAM,EAAE,mBAAmB,EAAE,CAAC;IAC9B,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,uEAAuE;IACvE,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,GAAG,EAAE,CAAC;CACf;AAgCD,wBAAgB,UAAU,CACxB,WAAW,EAAE,cAAc,GAAG,SAAS,EACvC,IAAI,EAAE,uBAAuB,EAC7B,MAAM,EAAE,MAAM,GACb,MAAM,CA4BR;AA+PD,wBAAgB,cAAc,CAC5B,IAAI,EAAE,aAAa,EACnB,UAAU,SAAI,EACd,WAAW,CAAC,EAAE,cAAc,GAC3B,oBAAoB,CAmItB"}
@@ -1,5 +1,5 @@
1
+ import { a as NestedTransactionError, o as resolveSchemas, s as convertUniqueIndexesToInlineConstraints } from "./query-B6ATOWzc.js";
1
2
  import { c as wrapDatabaseError, n as buildWhere, o as resolveInsertColumns, r as formatDbError, t as DatabaseSchemaManager } from "./schema-manager-tfASrb_b.js";
2
- import { a as NestedTransactionError, o as resolveSchemas, s as convertUniqueIndexesToInlineConstraints } from "./query-DubG7aF0.js";
3
3
  import { _ as validateColumnNames, c as createTransactionLock, d as validateDatabaseCacheOptions, f as escapeQuotedIdentifier, g as validateColumnName, h as generateCreateIndexStatement, m as generateAddColumnStatement, p as escapeStringLiteral, v as validateIndexName, y as validateTableName } from "./postgres-dialect-DIy5f5YR.js";
4
4
  import { n as createDuckDBResourceCloser, r as throwWithDuckDBCleanup, t as serializeDuckDBStatements } from "./duckdb-statement-queue-BJ6-PQRr.js";
5
5
  import { t as redactDatabaseUrl } from "./redact-database-url-CfMDvSdy.js";
@@ -1093,4 +1093,4 @@ async function getDatabase(options = {}) {
1093
1093
  //#endregion
1094
1094
  export { getDatabase };
1095
1095
 
1096
- //# sourceMappingURL=duckdb-D4x661Ux.js.map
1096
+ //# sourceMappingURL=duckdb-EvsYk7GK.js.map