uql-orm 0.27.0 → 0.28.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
@@ -7,9 +7,9 @@
7
7
  </picture>
8
8
  </a>
9
9
 
10
- <h3>The smartest TypeScript ORM</h3>
10
+ <h3>The ORM with type-safe JSON queries</h3>
11
11
 
12
- <p>Serializable queries, type-safe to the leaf, no codegen, and one API across every SQL database, MongoDB, and every runtime. And the <a href="https://uql-orm.dev/benchmark">fastest</a>.</p>
12
+ <p>Queries are plain JSON values, typed to the leaf. Build them, store them, send them from the browser, and run them unchanged on every SQL database and MongoDB. And it is the <a href="https://uql-orm.dev/benchmark">fastest</a>.</p>
13
13
 
14
14
  <p>
15
15
  <a href="https://uql-orm.dev"><b>Website</b></a> ·
@@ -56,9 +56,9 @@ from the browser to the server. The same object runs on every supported database
56
56
  - **Relations without N+1.** [`$populate`](https://uql-orm.dev/querying/relations) loads a to-many with one query for all parents, not one per parent. Nothing is lazy, so nothing fires behind your back in a serializer.
57
57
  - **Migrations you read before they run.** Edit an entity, run `uql-migrate generate:entities`, review the SQL in the PR like any other file. [`drift:check`](https://uql-orm.dev/migrations) catches a database that no longer matches.
58
58
  - **Raw SQL when you want it.** [`raw()`](https://uql-orm.dev/querying/raw-sql) fits anywhere in a query, [virtual fields](https://uql-orm.dev/entities/virtual-fields) are sub-queries you can filter on, and a migration can be plain SQL.
59
- - **Light.** Zero runtime dependencies, 305 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
59
+ - **Light.** Zero runtime dependencies, 269 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
60
60
  - **The hard things are built in.** [Semantic and vector search](https://uql-orm.dev/ai-semantic-search), [multi-tenant filters you cannot bypass by accident](https://uql-orm.dev/multi-tenancy), [soft-delete with restore](https://uql-orm.dev/entities/soft-delete), [streaming](https://uql-orm.dev/querying/streaming), and [a REST API from your entities](https://uql-orm.dev/http).
61
- - **The fastest ORM.** On a full PostgreSQL round trip it adds the least over hand-written driver code of any ORM in our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark): 239µs, against 812µs for the next closest and 2,286µs for the slowest.
61
+ - **The fastest ORM.** On a full PostgreSQL round trip it adds the least over hand-written driver code of any ORM in our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark): 206µs, against 644µs for the next closest and 2,236µs for the slowest.
62
62
 
63
63
  ## Get started
64
64
 
@@ -1,6 +1,7 @@
1
- import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, type QueryPopulate, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelect, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
1
+ import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
2
2
  import type { HydrateKind } from './hydrateColumn.js';
3
3
  import { IndexSqlDialect } from './indexSqlDialect.js';
4
+ import { type QueryJoins, type QuerySortOptions } from './queryJoins.js';
4
5
  /** How a column's values are bound: see {@link AbstractSqlDialect.persistKind}. */
5
6
  type PersistKind = 'plain' | 'json' | 'vector';
6
7
  /** One entry of {@link AbstractSqlDialect.hydratableFields}: a field key and how it decodes. */
@@ -34,7 +35,8 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
34
35
  * straight into `ctx`'s own values array - shared by reference, not copied - so `addValue` numbers
35
36
  * its placeholder correctly against the real query from the start; a fresh, empty array would
36
37
  * instead number from `1` regardless of how many values `ctx` already has, misnumbering every
37
- * bound value on `$n`-placeholder dialects once `ctx` isn't otherwise empty.
38
+ * bound value on `$n`-placeholder dialects once `ctx` isn't otherwise empty. Generated aliases are
39
+ * shared for the same reason - see {@link SqlQueryContext}.
38
40
  */
39
41
  protected buildFragment(ctx: QueryContext, build: (fragmentCtx: QueryContext) => void): string;
40
42
  addValue(values: unknown[], value: unknown): string;
@@ -51,7 +53,7 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
51
53
  normalizeValues(values: unknown[] | undefined): unknown[] | undefined;
52
54
  placeholder(_index: number): string;
53
55
  returningId<E>(entity: Type<E>): string;
54
- search<E>(ctx: QueryContext, entity: Type<E>, q?: Query<E>, opts?: QueryOptions): void;
56
+ search<E>(ctx: QueryContext, entity: Type<E>, q?: Query<E>, opts?: QueryOptions, joins?: QueryJoins): void;
55
57
  selectFields<E>(ctx: QueryContext, entity: Type<E>, select: QuerySelectValue<E> | undefined, opts?: QuerySelectOptions, exclude?: QueryExclude<E>): void;
56
58
  /**
57
59
  * The expression a scalar field is read through, the plain column by default. MariaDB reads a
@@ -65,19 +67,11 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
65
67
  * than inheriting another engine's syntax.
66
68
  */
67
69
  protected appendTextSearch<E>(_ctx: QueryContext, _entity: Type<E>, _meta: EntityMeta<E>, _search: QueryTextSearchOptions<E>): void;
68
- select<E>(ctx: QueryContext, entity: Type<E>, select: QuerySelectValue<E> | undefined, exclude?: QueryExclude<E>, populate?: QueryPopulate<E>, opts?: QueryOptions, distinct?: boolean, sort?: QuerySortMap<E>): void;
70
+ select<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, opts?: QueryOptions, joins?: QueryJoins): void;
71
+ /** Columns are alias-qualified once anything else is in play: a join, or a to-many being filled. */
69
72
  private resolveRelationAwarePrefix;
70
- protected selectRelationFields<E>(ctx: QueryContext, entity: Type<E>, select: QuerySelect<E> | undefined, populate: QueryPopulate<E> | undefined, opts?: {
71
- prefix?: string;
72
- }): void;
73
- protected selectRelationJoins<E>(ctx: QueryContext, entity: Type<E>, select: QuerySelect<E> | undefined, populate: QueryPopulate<E> | undefined, opts?: {
74
- prefix?: string;
75
- }): void;
76
- /**
77
- * Iterates over joinable (11/m1) relations for a given select, resolving shared metadata.
78
- * Used by both `selectRelationFields` and `selectRelationJoins` to avoid duplicated iteration logic.
79
- */
80
- private forEachJoinableRelation;
73
+ protected selectRelationFields(ctx: QueryContext, joins: QueryJoins): void;
74
+ protected selectRelationJoins<E>(ctx: QueryContext, meta: EntityMeta<E>, tableName: string, joins: QueryJoins): void;
81
75
  where<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
82
76
  /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
83
77
  protected renderWhere<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
@@ -87,39 +81,57 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
87
81
  private readonly escapedColumns;
88
82
  private static readonly NEGATE_OP_MAP;
89
83
  private static readonly COMPARE_OP_MAP;
90
- private static readonly LIKE_OP_MAP;
91
84
  /**
92
- * The case-insensitive `LIKE_OP_MAP` keys - the value is lowercased, so the comparison must use
93
- * `ilikeExpr` (Postgres's `ILIKE`) rather than `LIKE`. `$includes` is deliberately excluded even
94
- * though it starts with the substring `$i`: it is case-sensitive, unlike `$iincludes`.
85
+ * Every `$like`-family operator: the pattern it wraps its value in, and whether it ignores case.
86
+ * Each case-sensitive operator is paired here with the `$i` twin that shares its pattern, so the
87
+ * two can never drift apart - and neither one decides case folding, which is
88
+ * {@link caseInsensitiveMatch}'s single call.
89
+ */
90
+ private static readonly LIKE_OPS;
91
+ /**
92
+ * How this engine matches case-insensitively. One decision, not two: folding the pattern while the
93
+ * comparison leaves the column alone matches neither case, which is what `$istartsWith: 'Some'`
94
+ * used to do wherever `LIKE` is case-sensitive.
95
+ *
96
+ * - `ilike`: the engine has a case-insensitive operator (`ILIKE`), so the pattern goes through as written.
97
+ * - `native`: plain `LIKE` already ignores case (SQLite, for ASCII). Folding the pattern in JS would
98
+ * only break the non-ASCII characters the engine cannot fold anyway - `'É'` would become an `'é'`
99
+ * that matches nothing.
100
+ * - `fold`: nothing ignores case on its own, so both sides are lowered explicitly. Not indexable as
101
+ * such; an expression index over `LOWER(column)` is what makes it so.
95
102
  */
96
- private static readonly LIKE_CASE_INSENSITIVE_OPS;
97
- /** Builds `prefix.column` from an already-resolved field. */
103
+ protected readonly caseInsensitiveMatch: 'ilike' | 'native' | 'fold';
104
+ /**
105
+ * A `$like`-family condition, or `undefined` when `op` is not one of them. Shared by columns and
106
+ * JSON paths, and the only place a pattern is folded - always together with the column it is
107
+ * compared against.
108
+ */
109
+ protected likeCondition(ctx: QueryContext, operand: string, op: string, val: unknown): string | undefined;
110
+ /** Builds `prefix.column` from an already-resolved field, through the same memo writes use. */
98
111
  private columnWithPrefix;
99
112
  /**
100
- * Resolves the SQL operand for a field comparison.
101
- * For QueryRaw virtuals, appends the raw expression to ctx and returns undefined.
113
+ * The SQL a field comparison reads its left-hand side from. A virtual field builds its expression
114
+ * as text rather than appending it, so every operator gets a real operand to wrap - `LOWER(...)`,
115
+ * `NOT (... <=> ...)` - instead of having to fall back to a form that takes none.
102
116
  */
103
- protected resolveOperandField<E>(ctx: QueryContext, entity: Type<E>, key: string, opts: QueryOptions): string | undefined;
104
- private appendFieldSql;
117
+ protected resolveOperandField<E>(ctx: QueryContext, entity: Type<E>, key: string, opts: QueryOptions): string;
105
118
  compareFieldOperator<E, K extends keyof QueryWhereFieldOperatorMap<E>>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, op: K, val: QueryWhereFieldOperatorMap<E>[K], opts?: QueryOptions): void;
106
119
  /**
107
- * Render `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, and
108
- * report whether `op` was one of them.
120
+ * `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, or
121
+ * `undefined` when `op` is not one of them.
109
122
  *
110
123
  * One implementation for three callers that each had their own: a WHERE column, a HAVING aggregate
111
- * expression, and a `$size` count (which passes no operand, since its expression is already in the
112
- * context). They previously disagreed - HAVING carried a second comparison-operator map and threw
113
- * `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of the
114
- * other two turned `$eq: null` into `IS NULL` the way the WHERE path does.
124
+ * expression, and a `$size` count (whose expression is already in the context, so it passes an
125
+ * empty operand). They previously disagreed - HAVING carried a second comparison-operator map and
126
+ * threw `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of
127
+ * the other two turned `$eq: null` into `IS NULL` the way the WHERE path does.
115
128
  *
116
129
  * The operators kept out are the ones that need more than an operand: `$not` recurses through the
117
130
  * entity, and `$all`/`$size`/`$elemMatch` address a JSON document.
118
131
  */
119
- protected appendOperatorCondition(ctx: QueryContext, operand: string | undefined, op: string, val: unknown): boolean;
120
- private appendLikeOp;
121
- private appendEqNe;
122
- private appendInNin;
132
+ protected operatorCondition(ctx: QueryContext, operand: string, op: string, val: unknown): string | undefined;
133
+ /** {@link operatorCondition}, appended; `false` when `op` needs more than an operand. */
134
+ protected appendOperatorCondition(ctx: QueryContext, operand: string, op: string, val: unknown): boolean;
123
135
  /**
124
136
  * Build a comparison condition for a JSON field.
125
137
  * Used by both `$elemMatch` and dot-notation paths.
@@ -181,23 +193,36 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
181
193
  * dialects use this - PostgreSQL binds JSON through {@link PgLikeSqlDialect.jsonScalarParam} instead.
182
194
  */
183
195
  protected jsonScalarParam(ctx: QueryContext, value: unknown): string;
184
- getComparisonKey<E>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, { prefix }?: QueryOptions): void;
185
- sort<E>(ctx: QueryContext, entity: Type<E>, sort: QuerySortMap<E> | undefined, { prefix }: QueryOptions): void;
196
+ /** {@link resolveOperandField}, appended. */
197
+ getComparisonKey<E>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, opts?: QueryOptions): void;
198
+ sort<E>(ctx: QueryContext, entity: Type<E>, sort: QuerySortMap<E> | undefined, opts?: QuerySortOptions): void;
199
+ /**
200
+ * Walks `$sort` against the metadata of the entity each level addresses, rather than flattening it
201
+ * to dotted strings and reading every key off the root: only that way does a related column resolve
202
+ * through its own `@Field({ name })`, and only that way is `tax.category` the one alias the join
203
+ * carries instead of two quoted identifiers.
204
+ */
205
+ private collectSortTerms;
206
+ /** The join an `ORDER BY` term addresses, or why the statement cannot order by it. */
207
+ private resolveSortJoin;
208
+ /**
209
+ * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
210
+ * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
211
+ */
212
+ private sortColumn;
186
213
  pager(ctx: QueryContext, opts: QueryPager): void;
187
214
  /** Whether this engine has row locks at all. The SQLite family locks the database instead. */
188
215
  readonly supportsRowLocks: boolean;
189
216
  /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
190
217
  readonly supportsLockOf: boolean;
191
- /** Whether this statement joins, which is what forces the lock to be narrowed to one table. */
192
- private joinsRelations;
193
218
  /** Validated before the querier checks for a transaction, so the clearer error wins. */
194
- assertLockSupported<E>(entity: Type<E>, q: Query<E>): void;
219
+ assertLockSupported<E>(entity: Type<E>, q: Query<E>, joins?: QueryJoins): void;
195
220
  /**
196
221
  * The trailing `FOR UPDATE`. Narrowing to the queried table is not a nicety once a relation is
197
222
  * joined: Postgres refuses a bare `FOR UPDATE` over the nullable side of an outer join outright,
198
223
  * and the other engines quietly widen the lock to the joined rows.
199
224
  */
200
- protected appendLock<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>): void;
225
+ protected appendLock<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, joins?: QueryJoins): void;
201
226
  count<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void;
202
227
  /** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
203
228
  * comes from query data, so anything outside this map must be rejected rather than passed
@@ -205,7 +230,9 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
205
230
  private static readonly AGGREGATE_FN_MAP;
206
231
  aggregate<E, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(ctx: QueryContext, entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): void;
207
232
  /**
208
- * ORDER BY for aggregate queries - handles both entity-field and alias references.
233
+ * ORDER BY for aggregate queries - handles both entity-field and alias references. A grouped
234
+ * statement has no joins to address, so a relation key is rejected rather than emitted as an alias
235
+ * nothing defines.
209
236
  */
210
237
  private aggregateSort;
211
238
  protected having(ctx: QueryContext, having: QueryHavingMap, aggregateExpressions: Record<string, string>): void;
@@ -253,7 +280,7 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
253
280
  protected getUpsertUpdateAssignments<E>(ctx: QueryContext, meta: EntityMeta<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[], callback?: (columnName: string) => string): string;
254
281
  protected getUpsertConflictPathsStr<E>(meta: EntityMeta<E>, conflictPaths: QueryConflictPaths<E>): string;
255
282
  delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void;
256
- escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string;
283
+ escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
257
284
  /**
258
285
  * Bind one persisted value, classifying its column on the spot. Dialects override
259
286
  * {@link appendJsonValue} and {@link appendVectorValue} rather than this, so the chain runs once per
@@ -386,6 +413,7 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
386
413
  * metadata is shared between dialects while this result is not, since `escapeIdChar` and the naming
387
414
  * strategy differ. Weakly keyed so a transient entity's metadata stays collectable.
388
415
  */
416
+ private escapedColumnOf;
389
417
  private escapedColumnName;
390
418
  private escapedColumn;
391
419
  /** As {@link escapedColumn}, but qualified by the query alias when the parent is nested. */
@@ -428,7 +456,6 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
428
456
  */
429
457
  protected get neOp(): string;
430
458
  protected neExpr(field: string, ph: string): string;
431
- protected ilikeExpr(f: string, ph: string): string;
432
459
  /**
433
460
  * Formats an IN/NOT IN expression, binding each value individually.
434
461
  * Postgres overrides to use `= ANY($1)` / `<> ALL($1)` with a single array parameter.