uql-orm 0.74.1 → 0.76.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/dist/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +3 -3
- package/dist/dialect/abstractSqlDialect.d.ts +12 -2
- package/dist/dialect/abstractSqlDialect.js +41 -20
- package/dist/dialect/aliases.d.ts +5 -0
- package/dist/dialect/aliases.js +7 -0
- package/dist/dialect/mysqlLikeSqlDialect.js +1 -0
- package/dist/dialect/pgLikeSqlDialect.js +1 -0
- package/dist/entity/decorator/members.d.ts +15 -3
- package/dist/entity/metadata/definition.js +12 -0
- package/dist/mongo/mongoDialect.d.ts +18 -2
- package/dist/mongo/mongoDialect.js +44 -13
- package/dist/mongo/mongodbQuerier.js +3 -1
- package/dist/mssql/mssqlDialect.js +1 -0
- package/dist/querier/abstractQuerier.d.ts +6 -0
- package/dist/querier/abstractQuerier.js +56 -2
- package/dist/querier/queryError.d.ts +13 -1
- package/dist/querier/queryError.js +19 -0
- package/dist/sqlite/sqliteDialect.js +1 -0
- package/dist/type/dialect.d.ts +5 -0
- package/dist/type/entity.d.ts +32 -3
- package/dist/type/entity.js +6 -0
- package/dist/type/query.d.ts +10 -3
- package/dist/util/dialect.util.js +3 -1
- package/dist/util/fieldOption.util.d.ts +15 -2
- package/dist/util/fieldOption.util.js +25 -10
- package/package.json +1 -1
- package/skills/uql-orm/SKILL.md +13 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var p=[];function y(e){for(let r of p)r(e)}function _(e){p.push(e);let r=p.length-1;return()=>{p.splice(r,1)}}var
|
|
1
|
+
var p=[];function y(e){for(let r of p)r(e)}function _(e){p.push(e);let r=p.length-1;return()=>{p.splice(r,1)}}var P=["$select","$populate","$exclude","$where","$sort"],E=["$count"],T=["$skip","$limit"],f=["$candidates"],O=["$distinct"],v=["$lock",...E,...f];var b=Symbol("rawValue"),W=Symbol("rawAlias");function c(e){return e?Object.keys(e):[]}function C(e){if(typeof e!=="object"||e===null)return!0;if(Array.isArray(e))return!1;let r=Object.getPrototypeOf(e);return r!==Object.prototype&&r!==null}var Y=new Set([...P,...E,...T,...f,...O,"hardDelete","count"]);function i(e){if(!e)return"";let r=new URLSearchParams;for(let n of c(e)){let o=e[n];if(o===void 0)continue;r.append(n,typeof o==="object"&&o!==null?u(o):String(o))}let t=r.toString();return t?`?${t}`:""}function u(e){return JSON.stringify(e,(r,t)=>{if(typeof t!=="object"||t===null)return t;if(b in t)throw TypeError("raw SQL cannot travel over HTTP: what leaves the browser is JSON");if(t instanceof ArrayBuffer||ArrayBuffer.isView(t))throw TypeError("binary cannot travel over HTTP: what leaves the browser is JSON");return t})}class K extends Error{status;constructor(e,r){super(e);this.status=r;this.name="RequestError"}}function R(e,r){return a(e,{method:"get"},r)}function h(e,r,t){return a(e,{method:"post",body:u(r)},t)}function x(e,r,t){return a(e,{method:"patch",body:u(r)},t)}function Q(e,r,t){return a(e,{method:"put",body:u(r)},t)}function g(e,r){return a(e,{method:"delete"},r)}function k(e,r,t){return a(e,{method:"QUERY",body:u(r)},t)}function a(e,r,t){if(y({phase:"start",opts:t}),r.headers={accept:"application/json","content-type":"application/json",...t?.headers},t?.signal)r.signal=t.signal;return fetch(e,r).then((n)=>n.json().then((o)=>{if(n.status>=200&&n.status<300)return y({phase:"success",opts:t}),o;let S=o,l={message:S?.error?.message??n.statusText,code:S?.error?.code??n.status};throw y({phase:"error",error:l,opts:t}),new K(l.message,l.code)})).finally(()=>{y({phase:"complete",opts:t})})}function U(e){let r=e.charAt(0).toLowerCase();for(let t=1;t<e.length;++t)r+=e[t]===e[t].toUpperCase()?"-"+e[t].toLowerCase():e[t];return r}var s={findMany:{method:"GET",path:""},findOne:{method:"GET",path:"/one"},count:{method:"GET",path:"/count"},findOneById:{method:"GET",path:"/:id"},insertOne:{method:"POST",path:""},insertMany:{method:"POST",path:"/many"},saveOne:{method:"PUT",path:""},saveMany:{method:"PUT",path:"/many"},updateMany:{method:"PATCH",path:""},updateOneById:{method:"PATCH",path:"/:id"},deleteOneById:{method:"DELETE",path:"/:id"},deleteMany:{method:"DELETE",path:""}},F=c(s),re=new Map(F.filter((e)=>s[e].method==="GET"&&s[e].path!=="/:id").map((e)=>[s[e].path,e]));function j(e){return U(e.name)}function w(e,r){if(!C(r))throw TypeError(`'${e.name}' was addressed by an id object, which the HTTP route cannot carry.`);return String(r)}class m{basePath;defaults;constructor(e,r={}){this.basePath=e;this.defaults=r}async findOneById(e,r,t,n){let o=this.getBasePath(e),d=i(t);return R(`${o}/${w(e,r)}${d}`,this.buildOptions(n))}findOne(e,r,t){return this.read(`${this.getBasePath(e)}${s.findOne.path}`,r,t)}findMany(e,r,t){let n={...r};if(t?.count)n.count=!0;return this.read(this.getBasePath(e),n,t)}async findManyAndCount(e,r,t){let n=await this.findMany(e,r,{...t,count:!0});if(typeof n.count!=="number")throw TypeError("findManyAndCount response has an invalid count");return{...n,count:n.count}}count(e,r,t){return this.read(`${this.getBasePath(e)}${s.count.path}`,r,t)}async exists(e,r,t){let n=await this.count(e,{...r,$limit:1},t);return{...n,data:n.data>0}}insertOne(e,r,t){let n=this.getBasePath(e);return h(n,r,this.buildOptions(t))}insertMany(e,r,t){let n=this.getBasePath(e);return h(`${n}${s.insertMany.path}`,r,this.buildOptions(t))}async updateOneById(e,r,t,n){let o=this.getBasePath(e);return x(`${o}/${w(e,r)}`,t,this.buildOptions(n))}updateMany(e,r,t,n){let o=this.getBasePath(e),d=i(r);return x(`${o}${d}`,t,this.buildOptions(n))}saveOne(e,r,t){let n=this.getBasePath(e);return Q(n,r,this.buildOptions(t))}saveMany(e,r,t){let n=this.getBasePath(e);return Q(`${n}${s.saveMany.path}`,r,this.buildOptions(t))}async deleteOneById(e,r,t={}){let n=this.getBasePath(e),o=t.hardDelete?i({hardDelete:t.hardDelete}):"";return g(`${n}/${w(e,r)}${o}`,this.buildOptions(t))}deleteMany(e,r,t={}){let n=this.getBasePath(e),o=i(t.hardDelete?{...r,hardDelete:t.hardDelete}:r);return g(`${n}${o}`,this.buildOptions(t))}getBasePath(e){return`${this.basePath}/${(this.defaults.entityPath??j)(e)}`}read(e,r,t){if(this.defaults.readMethod==="QUERY")return k(e,r??{},this.buildOptions(t));return R(`${e}${i(r)}`,this.buildOptions(t))}buildOptions(e){if(!this.defaults.headers&&!e?.headers)return e;return{...e,headers:{...this.defaults.headers,...e?.headers}}}}var q={getQuerier:()=>new m("/api")};function de(e){q=e}function V(){return q}function pe(){return V().getQuerier()}export{m as HttpQuerier,K as RequestError,R as get,pe as getQuerier,V as getQuerierPool,y as notify,_ as on,x as patch,h as post,Q as put,k as query,g as remove,de as setQuerierPool};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=3410F618088BC65664756E2164756E21
|
|
4
4
|
//# sourceMappingURL=uql-browser.min.js.map
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"sources": ["../../src/browser/http/bus.ts", "../../src/type/query.ts", "../../src/type/queryRaw.ts", "../../src/util/object.util.ts", "../../src/http/query.ts", "../../src/browser/http/http.ts", "../../src/util/string.util.ts", "../../src/http/contract.ts", "../../src/browser/querier/httpQuerier.ts", "../../src/browser/options.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"import type { RequestCallback, RequestNotification } from '../type/index.js';\n\nconst subscriptors: RequestCallback[] = [];\n\nexport function notify(notification: RequestNotification): void {\n for (const subscriptor of subscriptors) {\n subscriptor(notification);\n }\n}\n\nexport function on(cb: RequestCallback): () => void {\n subscriptors.push(cb);\n const index = subscriptors.length - 1;\n return (): void => {\n subscriptors.splice(index, 1);\n };\n}\n",
|
|
6
|
-
"import type { FieldKey, JsonFieldPaths, RelationKey, RelationTarget, ToManyRelationKey, WrittenId } from './entity.js';\nimport type { QueryLock } from './queryLock.js';\nimport type { QueryRaw } from './queryRaw.js';\nimport type { QueryWhere } from './queryWhere.js';\nimport type { BooleanLike, Except, IsMany, PrimaryKey } from './utility.js';\nimport type { QueryVectorQuery, QueryVectorSearch } from './vector.js';\n\nexport type QueryOptions = {\n /**\n * Toggle named entity filters for this query. `false` disables all filters;\n * `{ softDelete: false }` disables one; `{ myFilter: true }` force-enables a `default: false` filter.\n * Security filters cannot be disabled here.\n */\n filters?: false | Record<string, boolean>;\n /**\n * Delete only: physically remove rows instead of soft-deleting, ignoring the soft-delete filter so\n * already-deleted rows are removed too. No effect on entities without a soft-delete field.\n */\n hardDelete?: boolean;\n /**\n * `updateMany`/`deleteMany` only: address every row of the table on purpose. Without it a bulk write\n * that names none - no `$where` and no `$limit` - is refused, since a forgotten filter and the whole\n * table look alike. The entity's own filters never count as naming one.\n */\n unfiltered?: boolean;\n /**\n * prefix the query with this.\n */\n prefix?: string;\n /**\n * automatically infer the prefix for the query.\n */\n autoPrefix?: boolean;\n};\n\n/**\n * Field selection - `{ name: true }` whitelists fields; relations go in `$populate`. Declared over\n * `F extends keyof E`, like every map keyed by an entity's members, so each key stays linked to its\n * property and an editor rename reaches it. `F` is also how a projection passes its captured key set.\n */\nexport type QuerySelect<E, F extends keyof E = FieldKey<E>, V = BooleanLike> = {\n [K in F]?: V;\n};\n\n/**\n * Accepted `$select` value: a field map, or raw SQL projections built with `raw()`\n * (e.g. ``[raw`*`, raw`LOG10(points)`.as('score')]``). The raw form is SQL-only.\n */\nexport type QuerySelectValue<E, Raw = QueryRaw> = QuerySelect<E> | readonly Raw[];\n\n/**\n * Fields to exclude from the query result - `{ name: true }` blacklists fields.\n * Mutually exclusive with positive field selections in `$select`.\n */\nexport type QueryExclude<E> = QuerySelect<E>;\n\n/**\n * relation population map.\n */\nexport type QueryPopulate<E, Raw = QueryRaw, R extends keyof E = RelationKey<E>> = {\n [K in R]?: BooleanLike | QueryPopulateRelationOptions<E[K], Raw>;\n};\n\n/**\n * The key a read carries its relation tallies under. One spelling for the type and the runtime that\n * fills it: they sit in different modules, so a drift would type-check and answer `undefined`.\n */\nexport const COUNT_RESULT_KEY = '_count';\n\n/**\n * How many rows each named relation holds per parent, `true` for all of them or a filter to narrow\n * which ones count: a correlated count in the read's own statement, so no related row is loaded. Comes\n * back under `_count`, which keeps it clear of a relation of the same name `$populate` filled.\n */\nexport type QueryCount<E, Raw = QueryRaw, R extends keyof E = ToManyRelationKey<E>> = {\n [K in R]?: BooleanLike | QueryFilter<RelationTarget<E[K]>, Raw>;\n};\n\n/**\n * query conflict paths - subset of field keys used to detect upsert conflicts.\n */\nexport type QueryConflictPaths<E> = QuerySelect<E, FieldKey<E>, true>;\n\n/**\n * Options to populate a relation declared as `V`, by its cardinality.\n */\nexport type QueryPopulateRelationOptions<V, Raw = QueryRaw> =\n IsMany<V> extends true\n ? RelationQuery<RelationTarget<V>, Raw>\n : QueryUnique<RelationTarget<V>, Raw> & { $required?: boolean };\n\n/**\n * The per-request context parameterized filters read, set with `withContext(ctx, cb)`. An interface,\n * so its keys can be typed once: `declare module 'uql-orm' { interface UqlContext { tenantId: number } }`.\n */\nexport interface UqlContext {\n [key: string]: unknown;\n}\n\n/**\n * A filter's `$where` fragment: a plain fragment, or a function of the ambient {@link UqlContext}.\n * Return `undefined` when the condition can't resolve (see {@link FilterOptions.onMissing}).\n */\nexport type FilterWhere<E> = QueryWhere<E> | ((context: UqlContext | undefined) => QueryWhere<E> | undefined);\n\n/**\n * What to do when a filter's condition returns `undefined`. `skip` omits it (convenience filters);\n * `throw` fails closed (the default for `security` filters).\n */\nexport type FilterOnMissing = 'skip' | 'throw';\n\n/**\n * Authoring shape for `@Entity({ filters })` / `@Filter` / `defineFilter`.\n */\nexport type FilterOptions<E = unknown> = {\n readonly where: FilterWhere<E>;\n /** Applied to every query unless bypassed via `QueryOptions.filters`. Defaults to `true`. */\n readonly default?: boolean;\n} & (\n | {\n readonly security?: false;\n /** What to do when {@link FilterOptions.where} returns `undefined`. Defaults to `skip`. */\n readonly onMissing?: FilterOnMissing;\n }\n | {\n /**\n * Row-level-security filter: always applied (ignores `QueryOptions.filters` bypass) and\n * AND-merged so a client `$where` on the same field can't override it. It fails closed.\n */\n readonly security: true;\n readonly onMissing?: 'throw';\n }\n);\n\n/**\n * direction for the sort.\n */\nexport type QuerySortDirection = -1 | 1 | 'asc' | 'desc';\n\n/**\n * Accepted value for a field in `$sort` - either a direction or a vector similarity search.\n */\nexport type QuerySortValue = QuerySortDirection | QueryVectorSearch;\n\n/**\n * Ordering parents by how many rows a to-many relation holds - \"the ten users with the most posts\".\n * The tally is computed per parent as a correlated count, never by loading the rows.\n */\nexport type QuerySortByCount = {\n $count: QuerySortDirection;\n};\n\n/** The fields of `E` a vector search can rank by. */\ntype VectorFieldKey<E> = { [P in FieldKey<E>]: NonNullable<E[P]> extends readonly number[] ? P : never }[FieldKey<E>];\n\n/**\n * Ordering parents by the row of a to-many nearest a vector, per vector field: its distance is the\n * smallest of theirs. Nothing to `$project`, since no one row of the parent's answers under it. Never\n * where the target has no vector, since an empty map would admit any value at all.\n */\nexport type QuerySortByNearest<E> = [VectorFieldKey<E>] extends [never]\n ? never\n : { [P in VectorFieldKey<E>]?: QueryVectorQuery };\n\n/**\n * Ordering by relevance to the `$text` at the root of `$where`, in either direction as any key sorts. The\n * object form also answers it under the name `$project` gives it, most relevant first unless `$order` says.\n */\nexport type QuerySortByText = {\n $text?: QuerySortDirection | { readonly $project: string; readonly $order?: QuerySortDirection };\n};\n\n/**\n * A row with the value a `$sort` projects under the name its `$project` gives - a vector's distance, or a\n * `$text` relevance - which is not inferred: `(await querier.findMany(Post, q)) as WithProjection<Post, 'score'>[]`.\n */\nexport type WithProjection<E, K extends string> = E & Record<K, number>;\n\n/**\n * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count` or nearest row, a vector\n * distance, or - where `Root` says it sorts the queried entity itself, not a relation's rows - a `$text`\n * relevance or a distance it projects. One mapped type over the key sets: an intersection is checked once\n * per member, which made this the costliest.\n */\nexport type QuerySortMap<E, Root extends boolean = true, K extends keyof E = FieldKey<E> | RelationKey<E>> = {\n [P in K]?: P extends RelationKey<E>\n ? // A to-many has no single value to order by, so what it offers instead is its size or nearest row.\n IsMany<E[P]> extends true\n ? QuerySortByCount | QuerySortByNearest<RelationTarget<E[P]>>\n : QuerySortMap<RelationTarget<E[P]>, false>\n : NonNullable<E[P]> extends readonly number[]\n ? Root extends true\n ? QuerySortValue\n : QuerySortDirection | QueryVectorQuery\n : QuerySortDirection;\n} & ([JsonFieldPaths<E>] extends [never] ? unknown : { [P in JsonFieldPaths<E>]?: QuerySortDirection }) &\n (Root extends true ? QuerySortByText : unknown);\n\n/**\n * pager options.\n */\nexport type QueryPager = {\n /**\n * Index from where start the search\n */\n $skip?: number;\n\n /**\n * Max number of records to retrieve\n */\n $limit?: number;\n};\n\n/**\n * Which rows a statement addresses.\n */\nexport type QueryFilter<E, Raw = QueryRaw> = {\n /**\n * filtering options.\n */\n $where?: QueryWhere<E, Raw>;\n};\n\n/**\n * A filter plus the page `count` takes. No `$sort`: ordering picks *which* rows a page holds, never\n * how many, so a count that accepted one would promise an influence it cannot have.\n */\nexport type QueryPage<E, Raw = QueryRaw> = QueryFilter<E, Raw> & QueryPager;\n\n/**\n * A filter plus the ordering and page `updateMany`/`deleteMany` take. Both settle the\n * rows they address with a SELECT first, so the page is portable rather than MySQL-only, and a\n * vector `$sort` is as valid here as on a read: it ranks the settle query's rows, which has the\n * projection list to hold the distance. `$lock` stays off these, declared on {@link Query} instead.\n */\nexport type QuerySearch<E, Raw = QueryRaw> = QueryPage<E, Raw> & {\n /**\n * sorting options.\n */\n $sort?: QuerySortMap<E>;\n};\n\n/**\n * query options.\n */\nexport type Query<E, Raw = QueryRaw> = {\n /**\n * field selection - `{ name: true }` whitelists fields, or raw SQL projections\n * (``[raw`LOG10(points)`.as('score')]``, SQL dialects only - MongoDB rejects the raw-array form).\n * Mutually exclusive with `$exclude`.\n */\n $select?: QuerySelectValue<E, Raw>;\n\n /**\n * relation population options.\n */\n $populate?: QueryPopulate<E, Raw>;\n\n /**\n * how many rows each named relation holds, under `_count` on every row. See {@link QueryCount}.\n */\n $count?: QueryCount<E, Raw>;\n\n /**\n * field exclusion - `{ name: true }` blacklists fields. Mutually exclusive with positive `$select`.\n * Keys a relation is assembled from (a joined row's primary key, a to-many's foreign key) are kept\n * regardless, since subtracting them would leave the relation unfilled.\n */\n $exclude?: QueryExclude<E>;\n\n /**\n * sorting options, vector similarity search included: a SELECT is the one statement with a\n * projection list to hold the distance such a search computes.\n */\n $sort?: QuerySortMap<E>;\n\n /**\n * whether to return only distinct rows.\n */\n $distinct?: boolean;\n\n /**\n * Lock the rows this query returns, `SELECT ... FOR UPDATE`, inside an open transaction: outside one\n * it is refused, since the lock would drop before the rows are used. SQL only, and not the SQLite family.\n */\n $lock?: QueryLock;\n\n /**\n * How many candidates an ANN index explores before ranking a vector search, in that index's own units\n * (`hnsw.ef_search`, `numCandidates`...); ignored where the search is exact. Postgres needs a transaction.\n */\n $candidates?: number;\n\n // `$where`, `$skip` and `$limit` are declared here rather than intersected in from\n // {@link QueryFilter} and {@link QueryPager}: an assignability check against an intersection is\n // repeated per constituent, and every query in a consuming codebase pays that. The two shapes are\n // pinned together in `queryStatementClauses.test-d.ts` so the copies cannot drift.\n\n /**\n * filtering options.\n */\n $where?: QueryWhere<E, Raw>;\n\n /**\n * Index from where start the search\n */\n $skip?: number;\n\n /**\n * Max number of records to retrieve\n */\n $limit?: number;\n};\n\n/**\n * A {@link Query} as it travels as JSON, which a `raw` SQL fragment cannot: what the browser client takes,\n * and what an RPC contract (tRPC, oRPC, TanStack Start) declares as its input.\n */\nexport type WireQuery<E> = Query<E, never>;\n\n/**\n * `Query`'s clauses grouped by the shape of their value, for the wire parser and the relation query\n * check alike; `satisfies` keeps them in step with `Query`.\n */\nexport const QUERY_OBJECT_CLAUSES = [\n '$select',\n '$populate',\n '$exclude',\n '$where',\n '$sort',\n] as const satisfies readonly (keyof Query<unknown>)[];\n\n/**\n * Object clauses only the statement itself takes: a populated relation's rows keep their declared type,\n * so a `$count` inside one would have no `_count` to land in.\n */\nexport const QUERY_ROOT_OBJECT_CLAUSES = ['$count'] as const satisfies readonly (keyof Query<unknown>)[];\n\nexport const QUERY_NUMBER_CLAUSES = ['$skip', '$limit'] as const satisfies readonly (keyof Query<unknown>)[];\n\n/**\n * Number clauses only the statement itself takes - the numeric mirror of {@link QUERY_ROOT_OBJECT_CLAUSES}.\n * `$candidates` tunes the index behind a vector search, and a vector search only ever ranks the rows\n * the statement returns, so a relation's own query has nothing to tune.\n */\nexport const QUERY_ROOT_NUMBER_CLAUSES = ['$candidates'] as const satisfies readonly (keyof Query<unknown>)[];\n\nexport const QUERY_BOOLEAN_CLAUSES = ['$distinct'] as const satisfies readonly (keyof Query<unknown>)[];\n\n/** The clauses that describe the statement, which a populated relation's own query refuses by name. */\nexport const QUERY_STATEMENT_CLAUSES = [\n '$lock',\n ...QUERY_ROOT_OBJECT_CLAUSES,\n ...QUERY_ROOT_NUMBER_CLAUSES,\n] as const satisfies readonly (keyof Query<unknown>)[];\n\ntype RelationClause = (\n | typeof QUERY_OBJECT_CLAUSES\n | typeof QUERY_NUMBER_CLAUSES\n | typeof QUERY_BOOLEAN_CLAUSES\n)[number];\n\n/**\n * A populated relation's own query: the clause groups its runtime check accepts, so the two cannot\n * drift, and a clause added to {@link Query} stays off it until it joins one of them.\n */\nexport type RelationQuery<E = object, Raw = QueryRaw> = Pick<Query<E, Raw>, RelationClause> & {\n $required?: boolean;\n};\n\n/**\n * options to get a single record.\n */\nexport type QueryOne<E, Raw = QueryRaw> = Except<Query<E, Raw>, '$limit'>;\n\n/**\n * options to get an unique record.\n */\nexport type QueryUnique<E, Raw = QueryRaw> = Pick<QueryOne<E, Raw>, '$select' | '$exclude' | '$populate' | '$where'>;\n\n/**\n * The clauses that shape a row, captured as key sets rather than maps: a naked type parameter skips\n * excess-property checks, while a key set fails its own constraint on a typo.\n * @internal\n */\ntype QueryProjection<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E>,\n Raw = QueryRaw,\n> = {\n $select?: QuerySelect<E, S, V> | readonly Raw[];\n $exclude?: QuerySelect<E, X, V>;\n $populate?: QueryPopulate<E, Raw, P>;\n // Narrowing the captured names to the to-many ones leaves a to-one relation no key here at all,\n // so counting one is an excess property rather than a value to check.\n $count?: QueryCount<E, Raw, C & ToManyRelationKey<E>>;\n};\n\n/**\n * A {@link Query} whose projection is captured, so {@link QueryFindResult} can shape the row.\n */\nexport type QueryProjected<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E> = never,\n Raw = QueryRaw,\n> = Query<E, Raw> & QueryProjection<E, S, V, X, P, C, Raw>;\n\n/**\n * A {@link QueryOne} whose projection is captured, so {@link QueryFindResult} can shape the row.\n */\nexport type QueryOneProjected<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E> = never,\n Raw = QueryRaw,\n> = QueryOne<E, Raw> & QueryProjection<E, S, V, X, P, C, Raw>;\n\n/**\n * The keys a query comes back with, as the runtime projects them: a positive `$select`'s, or every\n * field minus what `$select` or `$exclude` subtracts, plus the populated relations.\n * @internal\n */\ntype ProjectedKeys<E, S, V, X, P> =\n | ([V] extends [false | 0] ? Exclude<FieldKey<E>, S> : [S] extends [never] ? Exclude<FieldKey<E>, X> : S)\n | P;\n\n/**\n * Whether every entry of the captured map says the same thing: all selected, or all subtracted.\n * @internal\n */\ntype IsUniform<V> = [V] extends [true | 1] ? true : [V] extends [false | 0] ? true : false;\n\n/**\n * A find's row: the entity narrowed to what the query projected and populated, so reading anything\n * else does not compile. The entity itself where the projection is raw, absent or not uniform.\n * @example `QueryFindResult<User, 'id' | 'name'>`\n */\nexport type QueryFindResult<\n E,\n S extends FieldKey<E> = never,\n // A whitelist by default, so the hand-written form reads `QueryFindResult<User, 'id' | 'name'>`.\n V = true,\n X extends FieldKey<E> = never,\n P extends RelationKey<E> = never,\n C extends RelationKey<E> = never,\n> = QueryProjectedRow<E, S, V, X, P, C> & CountedRelations<C>;\n\n/**\n * The `_count` a query asked for, or an inert intersection member when it asked for none - so a read\n * without `$count` keeps exactly the row type it had.\n */\ntype CountedRelations<C extends PropertyKey> = [C] extends [never]\n ? unknown\n : { [K in typeof COUNT_RESULT_KEY]: { [R in C]: number } };\n\n/** @internal */\ntype QueryProjectedRow<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E>,\n> = [S | X] extends [never]\n ? E\n : IsUniform<V> extends true\n ? [PopulatedToMany<E, P>] extends [never]\n ? // `Pick`, not a key remap: an entity keyed by an index signature - a content type defined at\n // runtime - has `string` for its keys, and a remap keeps no literal one, so every projection\n // over one came back as `{}`.\n Pick<E, ProjectedKeys<E, S, V, X, P> & keyof E>\n : // A populated to-many is always a list, empty where the parent has no children, so it maps\n // and counts without a guard. Only that promotion needs a second member, and only a query\n // that populates one pays for it; every other key keeps the modifier the entity declared,\n // a to-one relation included, since a join that finds no row leaves it absent.\n Pick<E, Exclude<ProjectedKeys<E, S, V, X, P>, PopulatedToMany<E, P>> & keyof E> & {\n [K in PopulatedToMany<E, P>]-?: NonNullable<E[K]>;\n }\n : E;\n\n/** The to-many relations a query populated, which come back as lists rather than as optional ones. */\ntype PopulatedToMany<E, P> = Extract<P, ToManyRelationKey<E>>;\n\n/**\n * stringified query.\n */\nexport type QueryStringified = {\n [K in keyof Query<unknown>]?: string;\n};\n\n/** What upserting one row reports. `created` is only knowable for a single statement, so a batch has none. */\nexport type QueryUpsertOneResult<E> = {\n readonly id?: WrittenId<E>;\n readonly changes?: number;\n /** Whether the record was created (`true`) or updated (`false`), where the dialect can tell. */\n readonly created?: boolean;\n};\n\n/**\n * What upserting many rows reports. `ids` is payload-aligned like an insert's, so it zips with the\n * rows that were passed, and carries a composite key as the map naming it.\n */\nexport type QueryUpsertManyResult<E> = {\n readonly ids: (WrittenId<E> | undefined)[];\n readonly changes?: number;\n};\n\n/**\n * result of an update operation, as the driver reports it - which is what `run` hands back, where\n * there is no entity to name the ids against. The `QueryUpsert*Result` pair is the entity-level shape.\n */\nexport type QueryUpdateResult = {\n /**\n * number of affected records.\n */\n changes?: number;\n /**\n * the IDs the statement reported, in payload order, `undefined` where it reported none for that\n * row - a MongoDB upsert names only the documents it inserted. Exact on `'returning'` dialects;\n * inferred from the driver header on the others (see {@link InsertIdSource}), and absent\n * altogether when the header reports nothing.\n */\n ids?: (PrimaryKey | undefined)[];\n /**\n * first inserted ID.\n */\n firstId?: PrimaryKey;\n /**\n * whether the record was created (`true`) or updated (`false`).\n * `undefined` when the dialect cannot determine this (e.g. SQLite).\n */\n created?: boolean;\n};\n",
|
|
6
|
+
"import type { FieldKey, JsonFieldPaths, RelationKey, RelationTarget, ToManyRelationKey, WrittenId } from './entity.js';\nimport type { QueryLock } from './queryLock.js';\nimport type { QueryRaw } from './queryRaw.js';\nimport type { QueryWhere } from './queryWhere.js';\nimport type { BooleanLike, Except, IsMany, PrimaryKey } from './utility.js';\nimport type { QueryVectorQuery, QueryVectorSearch } from './vector.js';\n\nexport type QueryOptions = {\n /**\n * Toggle named entity filters for this query. `false` disables all filters;\n * `{ softDelete: false }` disables one; `{ myFilter: true }` force-enables a `default: false` filter.\n * Security filters cannot be disabled here.\n */\n filters?: false | Record<string, boolean>;\n /**\n * Delete only: physically remove rows instead of soft-deleting, ignoring the soft-delete filter so\n * already-deleted rows are removed too. No effect on entities without a soft-delete field.\n */\n hardDelete?: boolean;\n /**\n * `updateMany`/`deleteMany` only: address every row of the table on purpose. Without it a bulk write\n * that names none - no `$where` and no `$limit` - is refused, since a forgotten filter and the whole\n * table look alike. The entity's own filters never count as naming one.\n */\n unfiltered?: boolean;\n /**\n * prefix the query with this.\n */\n prefix?: string;\n /**\n * automatically infer the prefix for the query.\n */\n autoPrefix?: boolean;\n};\n\n/**\n * Field selection - `{ name: true }` whitelists fields; relations go in `$populate`. Declared over\n * `F extends keyof E`, like every map keyed by an entity's members, so each key stays linked to its\n * property and an editor rename reaches it. `F` is also how a projection passes its captured key set.\n */\nexport type QuerySelect<E, F extends keyof E = FieldKey<E>, V = BooleanLike> = {\n [K in F]?: V;\n};\n\n/**\n * Accepted `$select` value: a field map, or raw SQL projections built with `raw()`\n * (e.g. ``[raw`*`, raw`LOG10(points)`.as('score')]``). The raw form is SQL-only.\n */\nexport type QuerySelectValue<E, Raw = QueryRaw> = QuerySelect<E> | readonly Raw[];\n\n/**\n * Fields to exclude from the query result - `{ name: true }` blacklists fields.\n * Mutually exclusive with positive field selections in `$select`.\n */\nexport type QueryExclude<E> = QuerySelect<E>;\n\n/**\n * relation population map.\n */\nexport type QueryPopulate<E, Raw = QueryRaw, R extends keyof E = RelationKey<E>> = {\n [K in R]?: BooleanLike | QueryPopulateRelationOptions<E[K], Raw>;\n};\n\n/**\n * The key a read carries its relation tallies under. One spelling for the type and the runtime that\n * fills it: they sit in different modules, so a drift would type-check and answer `undefined`.\n */\nexport const COUNT_RESULT_KEY = '_count';\n\n/**\n * How many rows each named relation holds per parent, `true` for all of them or a filter to narrow\n * which ones count: a correlated count in the read's own statement, so no related row is loaded. Comes\n * back under `_count`, which keeps it clear of a relation of the same name `$populate` filled.\n */\nexport type QueryCount<E, Raw = QueryRaw, R extends keyof E = ToManyRelationKey<E>> = {\n [K in R]?: BooleanLike | QueryFilter<RelationTarget<E[K]>, Raw>;\n};\n\n/**\n * query conflict paths - subset of field keys used to detect upsert conflicts.\n */\nexport type QueryConflictPaths<E> = QuerySelect<E, FieldKey<E>, true>;\n\n/**\n * Options to populate a relation declared as `V`, by its cardinality.\n */\nexport type QueryPopulateRelationOptions<V, Raw = QueryRaw> =\n IsMany<V> extends true\n ? RelationQuery<RelationTarget<V>, Raw>\n : QueryUnique<RelationTarget<V>, Raw> & { $required?: boolean };\n\n/**\n * The per-request context parameterized filters read, set with `withContext(ctx, cb)`. An interface,\n * so its keys can be typed once: `declare module 'uql-orm' { interface UqlContext { tenantId: number } }`.\n */\nexport interface UqlContext {\n [key: string]: unknown;\n}\n\n/**\n * A filter's `$where` fragment: a plain fragment, or a function of the ambient {@link UqlContext}.\n * Return `undefined` when the condition can't resolve (see {@link FilterOptions.onMissing}).\n */\nexport type FilterWhere<E> = QueryWhere<E> | ((context: UqlContext | undefined) => QueryWhere<E> | undefined);\n\n/**\n * What to do when a filter's condition returns `undefined`. `skip` omits it (convenience filters);\n * `throw` fails closed (the default for `security` filters).\n */\nexport type FilterOnMissing = 'skip' | 'throw';\n\n/**\n * Authoring shape for `@Entity({ filters })` / `@Filter` / `defineFilter`.\n */\nexport type FilterOptions<E = unknown> = {\n readonly where: FilterWhere<E>;\n /** Applied to every query unless bypassed via `QueryOptions.filters`. Defaults to `true`. */\n readonly default?: boolean;\n} & (\n | {\n readonly security?: false;\n /** What to do when {@link FilterOptions.where} returns `undefined`. Defaults to `skip`. */\n readonly onMissing?: FilterOnMissing;\n }\n | {\n /**\n * Row-level-security filter: always applied (ignores `QueryOptions.filters` bypass) and\n * AND-merged so a client `$where` on the same field can't override it. It fails closed.\n */\n readonly security: true;\n readonly onMissing?: 'throw';\n }\n);\n\n/**\n * direction for the sort, and where nulls land in it.\n *\n * Unqualified, each engine has its own answer - Postgres and CockroachDB sort nulls last on `asc`, the\n * rest sort them first - so a placement is the only portable one. Engines with no `NULLS FIRST/LAST`\n * emulate it with a leading term, which no index can serve, which is why it is asked for and never\n * applied by default.\n */\nexport type QuerySortDirection = -1 | 1 | 'asc' | 'desc' | QuerySortNullsDirection;\n\n/** A {@link QuerySortDirection} stating where nulls land. */\nexport type QuerySortNullsDirection = 'ascNullsFirst' | 'ascNullsLast' | 'descNullsFirst' | 'descNullsLast';\n\n/**\n * Accepted value for a field in `$sort` - either a direction or a vector similarity search.\n */\nexport type QuerySortValue = QuerySortDirection | QueryVectorSearch;\n\n/**\n * Ordering parents by how many rows a to-many relation holds - \"the ten users with the most posts\".\n * The tally is computed per parent as a correlated count, never by loading the rows.\n */\nexport type QuerySortByCount = {\n $count: QuerySortDirection;\n};\n\n/** The fields of `E` a vector search can rank by. */\ntype VectorFieldKey<E> = { [P in FieldKey<E>]: NonNullable<E[P]> extends readonly number[] ? P : never }[FieldKey<E>];\n\n/**\n * Ordering parents by the row of a to-many nearest a vector, per vector field: its distance is the\n * smallest of theirs. Nothing to `$project`, since no one row of the parent's answers under it. Never\n * where the target has no vector, since an empty map would admit any value at all.\n */\nexport type QuerySortByNearest<E> = [VectorFieldKey<E>] extends [never]\n ? never\n : { [P in VectorFieldKey<E>]?: QueryVectorQuery };\n\n/**\n * Ordering by relevance to the `$text` at the root of `$where`, in either direction as any key sorts. The\n * object form also answers it under the name `$project` gives it, most relevant first unless `$order` says.\n */\nexport type QuerySortByText = {\n $text?: QuerySortDirection | { readonly $project: string; readonly $order?: QuerySortDirection };\n};\n\n/**\n * A row with the value a `$sort` projects under the name its `$project` gives - a vector's distance, or a\n * `$text` relevance - which is not inferred: `(await querier.findMany(Post, q)) as WithProjection<Post, 'score'>[]`.\n */\nexport type WithProjection<E, K extends string> = E & Record<K, number>;\n\n/**\n * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count` or nearest row, a vector\n * distance, or - where `Root` says it sorts the queried entity itself, not a relation's rows - a `$text`\n * relevance or a distance it projects. One mapped type over the key sets: an intersection is checked once\n * per member, which made this the costliest.\n */\nexport type QuerySortMap<E, Root extends boolean = true, K extends keyof E = FieldKey<E> | RelationKey<E>> = {\n [P in K]?: P extends RelationKey<E>\n ? // A to-many has no single value to order by, so what it offers instead is its size or nearest row.\n IsMany<E[P]> extends true\n ? QuerySortByCount | QuerySortByNearest<RelationTarget<E[P]>>\n : QuerySortMap<RelationTarget<E[P]>, false>\n : NonNullable<E[P]> extends readonly number[]\n ? Root extends true\n ? QuerySortValue\n : QuerySortDirection | QueryVectorQuery\n : QuerySortDirection;\n} & ([JsonFieldPaths<E>] extends [never] ? unknown : { [P in JsonFieldPaths<E>]?: QuerySortDirection }) &\n (Root extends true ? QuerySortByText : unknown);\n\n/**\n * pager options.\n */\nexport type QueryPager = {\n /**\n * Index from where start the search\n */\n $skip?: number;\n\n /**\n * Max number of records to retrieve\n */\n $limit?: number;\n};\n\n/**\n * Which rows a statement addresses.\n */\nexport type QueryFilter<E, Raw = QueryRaw> = {\n /**\n * filtering options.\n */\n $where?: QueryWhere<E, Raw>;\n};\n\n/**\n * A filter plus the page `count` takes. No `$sort`: ordering picks *which* rows a page holds, never\n * how many, so a count that accepted one would promise an influence it cannot have.\n */\nexport type QueryPage<E, Raw = QueryRaw> = QueryFilter<E, Raw> & QueryPager;\n\n/**\n * A filter plus the ordering and page `updateMany`/`deleteMany` take. Both settle the\n * rows they address with a SELECT first, so the page is portable rather than MySQL-only, and a\n * vector `$sort` is as valid here as on a read: it ranks the settle query's rows, which has the\n * projection list to hold the distance. `$lock` stays off these, declared on {@link Query} instead.\n */\nexport type QuerySearch<E, Raw = QueryRaw> = QueryPage<E, Raw> & {\n /**\n * sorting options.\n */\n $sort?: QuerySortMap<E>;\n};\n\n/**\n * query options.\n */\nexport type Query<E, Raw = QueryRaw> = {\n /**\n * field selection - `{ name: true }` whitelists fields, or raw SQL projections\n * (``[raw`LOG10(points)`.as('score')]``, SQL dialects only - MongoDB rejects the raw-array form).\n * Mutually exclusive with `$exclude`.\n */\n $select?: QuerySelectValue<E, Raw>;\n\n /**\n * relation population options.\n */\n $populate?: QueryPopulate<E, Raw>;\n\n /**\n * how many rows each named relation holds, under `_count` on every row. See {@link QueryCount}.\n */\n $count?: QueryCount<E, Raw>;\n\n /**\n * field exclusion - `{ name: true }` blacklists fields. Mutually exclusive with positive `$select`.\n * Keys a relation is assembled from (a joined row's primary key, a to-many's foreign key) are kept\n * regardless, since subtracting them would leave the relation unfilled.\n */\n $exclude?: QueryExclude<E>;\n\n /**\n * sorting options, vector similarity search included: a SELECT is the one statement with a\n * projection list to hold the distance such a search computes.\n */\n $sort?: QuerySortMap<E>;\n\n /**\n * whether to return only distinct rows.\n */\n $distinct?: boolean;\n\n /**\n * Lock the rows this query returns, `SELECT ... FOR UPDATE`, inside an open transaction: outside one\n * it is refused, since the lock would drop before the rows are used. SQL only, and not the SQLite family.\n */\n $lock?: QueryLock;\n\n /**\n * How many candidates an ANN index explores before ranking a vector search, in that index's own units\n * (`hnsw.ef_search`, `numCandidates`...); ignored where the search is exact. Postgres needs a transaction.\n */\n $candidates?: number;\n\n // `$where`, `$skip` and `$limit` are declared here rather than intersected in from\n // {@link QueryFilter} and {@link QueryPager}: an assignability check against an intersection is\n // repeated per constituent, and every query in a consuming codebase pays that. The two shapes are\n // pinned together in `queryStatementClauses.test-d.ts` so the copies cannot drift.\n\n /**\n * filtering options.\n */\n $where?: QueryWhere<E, Raw>;\n\n /**\n * Index from where start the search\n */\n $skip?: number;\n\n /**\n * Max number of records to retrieve\n */\n $limit?: number;\n};\n\n/**\n * A {@link Query} as it travels as JSON, which a `raw` SQL fragment cannot: what the browser client takes,\n * and what an RPC contract (tRPC, oRPC, TanStack Start) declares as its input.\n */\nexport type WireQuery<E> = Query<E, never>;\n\n/**\n * `Query`'s clauses grouped by the shape of their value, for the wire parser and the relation query\n * check alike; `satisfies` keeps them in step with `Query`.\n */\nexport const QUERY_OBJECT_CLAUSES = [\n '$select',\n '$populate',\n '$exclude',\n '$where',\n '$sort',\n] as const satisfies readonly (keyof Query<unknown>)[];\n\n/**\n * Object clauses only the statement itself takes: a populated relation's rows keep their declared type,\n * so a `$count` inside one would have no `_count` to land in.\n */\nexport const QUERY_ROOT_OBJECT_CLAUSES = ['$count'] as const satisfies readonly (keyof Query<unknown>)[];\n\nexport const QUERY_NUMBER_CLAUSES = ['$skip', '$limit'] as const satisfies readonly (keyof Query<unknown>)[];\n\n/**\n * Number clauses only the statement itself takes - the numeric mirror of {@link QUERY_ROOT_OBJECT_CLAUSES}.\n * `$candidates` tunes the index behind a vector search, and a vector search only ever ranks the rows\n * the statement returns, so a relation's own query has nothing to tune.\n */\nexport const QUERY_ROOT_NUMBER_CLAUSES = ['$candidates'] as const satisfies readonly (keyof Query<unknown>)[];\n\nexport const QUERY_BOOLEAN_CLAUSES = ['$distinct'] as const satisfies readonly (keyof Query<unknown>)[];\n\n/** The clauses that describe the statement, which a populated relation's own query refuses by name. */\nexport const QUERY_STATEMENT_CLAUSES = [\n '$lock',\n ...QUERY_ROOT_OBJECT_CLAUSES,\n ...QUERY_ROOT_NUMBER_CLAUSES,\n] as const satisfies readonly (keyof Query<unknown>)[];\n\ntype RelationClause = (\n | typeof QUERY_OBJECT_CLAUSES\n | typeof QUERY_NUMBER_CLAUSES\n | typeof QUERY_BOOLEAN_CLAUSES\n)[number];\n\n/**\n * A populated relation's own query: the clause groups its runtime check accepts, so the two cannot\n * drift, and a clause added to {@link Query} stays off it until it joins one of them.\n */\nexport type RelationQuery<E = object, Raw = QueryRaw> = Pick<Query<E, Raw>, RelationClause> & {\n $required?: boolean;\n};\n\n/**\n * options to get a single record.\n */\nexport type QueryOne<E, Raw = QueryRaw> = Except<Query<E, Raw>, '$limit'>;\n\n/**\n * options to get an unique record.\n */\nexport type QueryUnique<E, Raw = QueryRaw> = Pick<QueryOne<E, Raw>, '$select' | '$exclude' | '$populate' | '$where'>;\n\n/**\n * The clauses that shape a row, captured as key sets rather than maps: a naked type parameter skips\n * excess-property checks, while a key set fails its own constraint on a typo.\n * @internal\n */\ntype QueryProjection<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E>,\n Raw = QueryRaw,\n> = {\n $select?: QuerySelect<E, S, V> | readonly Raw[];\n $exclude?: QuerySelect<E, X, V>;\n $populate?: QueryPopulate<E, Raw, P>;\n // Narrowing the captured names to the to-many ones leaves a to-one relation no key here at all,\n // so counting one is an excess property rather than a value to check.\n $count?: QueryCount<E, Raw, C & ToManyRelationKey<E>>;\n};\n\n/**\n * A {@link Query} whose projection is captured, so {@link QueryFindResult} can shape the row.\n */\nexport type QueryProjected<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E> = never,\n Raw = QueryRaw,\n> = Query<E, Raw> & QueryProjection<E, S, V, X, P, C, Raw>;\n\n/**\n * A {@link QueryOne} whose projection is captured, so {@link QueryFindResult} can shape the row.\n */\nexport type QueryOneProjected<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E> = never,\n Raw = QueryRaw,\n> = QueryOne<E, Raw> & QueryProjection<E, S, V, X, P, C, Raw>;\n\n/**\n * The keys a query comes back with, as the runtime projects them: a positive `$select`'s, or every\n * field minus what `$select` or `$exclude` subtracts, plus the populated relations.\n * @internal\n */\ntype ProjectedKeys<E, S, V, X, P> =\n | ([V] extends [false | 0] ? Exclude<FieldKey<E>, S> : [S] extends [never] ? Exclude<FieldKey<E>, X> : S)\n | P;\n\n/**\n * Whether every entry of the captured map says the same thing: all selected, or all subtracted.\n * @internal\n */\ntype IsUniform<V> = [V] extends [true | 1] ? true : [V] extends [false | 0] ? true : false;\n\n/**\n * A find's row: the entity narrowed to what the query projected and populated, so reading anything\n * else does not compile. The entity itself where the projection is raw, absent or not uniform.\n * @example `QueryFindResult<User, 'id' | 'name'>`\n */\nexport type QueryFindResult<\n E,\n S extends FieldKey<E> = never,\n // A whitelist by default, so the hand-written form reads `QueryFindResult<User, 'id' | 'name'>`.\n V = true,\n X extends FieldKey<E> = never,\n P extends RelationKey<E> = never,\n C extends RelationKey<E> = never,\n> = QueryProjectedRow<E, S, V, X, P, C> & CountedRelations<C>;\n\n/**\n * The `_count` a query asked for, or an inert intersection member when it asked for none - so a read\n * without `$count` keeps exactly the row type it had.\n */\ntype CountedRelations<C extends PropertyKey> = [C] extends [never]\n ? unknown\n : { [K in typeof COUNT_RESULT_KEY]: { [R in C]: number } };\n\n/** @internal */\ntype QueryProjectedRow<\n E,\n S extends FieldKey<E>,\n V,\n X extends FieldKey<E>,\n P extends RelationKey<E>,\n C extends RelationKey<E>,\n> = [S | X] extends [never]\n ? E\n : IsUniform<V> extends true\n ? [PopulatedToMany<E, P>] extends [never]\n ? // `Pick`, not a key remap: an entity keyed by an index signature - a content type defined at\n // runtime - has `string` for its keys, and a remap keeps no literal one, so every projection\n // over one came back as `{}`.\n Pick<E, ProjectedKeys<E, S, V, X, P> & keyof E>\n : // A populated to-many is always a list, empty where the parent has no children, so it maps\n // and counts without a guard. Only that promotion needs a second member, and only a query\n // that populates one pays for it; every other key keeps the modifier the entity declared,\n // a to-one relation included, since a join that finds no row leaves it absent.\n Pick<E, Exclude<ProjectedKeys<E, S, V, X, P>, PopulatedToMany<E, P>> & keyof E> & {\n [K in PopulatedToMany<E, P>]-?: NonNullable<E[K]>;\n }\n : E;\n\n/** The to-many relations a query populated, which come back as lists rather than as optional ones. */\ntype PopulatedToMany<E, P> = Extract<P, ToManyRelationKey<E>>;\n\n/**\n * stringified query.\n */\nexport type QueryStringified = {\n [K in keyof Query<unknown>]?: string;\n};\n\n/** What upserting one row reports. `created` is only knowable for a single statement, so a batch has none. */\nexport type QueryUpsertOneResult<E> = {\n readonly id?: WrittenId<E>;\n readonly changes?: number;\n /** Whether the record was created (`true`) or updated (`false`), where the dialect can tell. */\n readonly created?: boolean;\n};\n\n/**\n * What upserting many rows reports. `ids` is payload-aligned like an insert's, so it zips with the\n * rows that were passed, and carries a composite key as the map naming it.\n */\nexport type QueryUpsertManyResult<E> = {\n readonly ids: (WrittenId<E> | undefined)[];\n readonly changes?: number;\n};\n\n/**\n * result of an update operation, as the driver reports it - which is what `run` hands back, where\n * there is no entity to name the ids against. The `QueryUpsert*Result` pair is the entity-level shape.\n */\nexport type QueryUpdateResult = {\n /**\n * number of affected records.\n */\n changes?: number;\n /**\n * the IDs the statement reported, in payload order, `undefined` where it reported none for that\n * row - a MongoDB upsert names only the documents it inserted. Exact on `'returning'` dialects;\n * inferred from the driver header on the others (see {@link InsertIdSource}), and absent\n * altogether when the header reports nothing.\n */\n ids?: (PrimaryKey | undefined)[];\n /**\n * first inserted ID.\n */\n firstId?: PrimaryKey;\n /**\n * whether the record was created (`true`) or updated (`false`).\n * `undefined` when the dialect cannot determine this (e.g. SQLite).\n */\n created?: boolean;\n};\n",
|
|
7
7
|
"import type { QueryContext, RelationAggregateSpec, SqlQueryDialect } from './dialect.js';\nimport type { Type } from './utility.js';\n\n/** What a `raw` callback receives. See {@link QueryRawFn}. */\nexport type QueryRawRenderOptions = {\n /** The dialect rendering the SQL. */\n dialect: SqlQueryDialect;\n /** The alias of the table in scope, unescaped; empty where there is none. */\n prefix: string;\n /** {@link prefix} escaped, with its trailing dot. */\n escapedPrefix: string;\n /** The query context the SQL is written into. */\n ctx: QueryContext;\n /**\n * The entity being rendered, which a ref read off a definition's map resolves its column against: a\n * computed field's own, or the one whose schema is built. Absent where a statement renders SQL.\n */\n entity?: Type<unknown>;\n};\n\n/** {@link QueryRawRenderOptions} as the callers along the way fill them in, every one still optional. */\nexport type QueryRawFnOptions = Partial<QueryRawRenderOptions>;\n\n/**\n * A `raw` callback: write into `ctx`, or return a string or number to have it appended. Anything else\n * it returns is ignored, which is why the return type is `unknown` rather than `void | Scalar` - the\n * latter rejected `({ ctx }) => ctx.append(...)`, the form every computed field is written in, because\n * TypeScript's \"returning a value where void is expected\" allowance does not apply to a union.\n */\nexport type QueryRawFn = (opts: QueryRawRenderOptions) => unknown;\n\nexport const RAW_VALUE: unique symbol = Symbol('rawValue');\nexport const RAW_ALIAS: unique symbol = Symbol('rawAlias');\n\nexport class QueryRaw {\n readonly [RAW_VALUE]: QueryRawFn;\n readonly [RAW_ALIAS]?: string;\n\n constructor(value: QueryRawFn, alias?: string) {\n this[RAW_VALUE] = value;\n this[RAW_ALIAS] = alias;\n }\n\n /** The same expression under an alias, for a `$select` projection. */\n as(alias: string): QueryRaw {\n return new QueryRaw(this[RAW_VALUE], alias);\n }\n\n /** Writes the expression into `opts.ctx`. The alias is the projection's to write, after the term. */\n render(opts: QueryRawRenderOptions): void {\n const emitted = this[RAW_VALUE](opts);\n if (typeof emitted === 'string' || (typeof emitted === 'number' && !Number.isNaN(emitted))) {\n opts.ctx.append(String(emitted));\n }\n }\n}\n\n/**\n * A field of an entity as SQL, read off `refs(Entity)` or a definition's refs: interpolated into `raw`, it\n * renders as the field's column. Its `key` is how an index tells a column from an expression.\n */\nexport class ColumnRef<K extends string = string> extends QueryRaw {\n constructor(\n readonly key: K,\n value: QueryRawFn,\n ) {\n super(value);\n }\n}\n\n/**\n * A relation aggregate as SQL, read off a `computed` field's refs: `(user) => user.resources.count()`.\n * It renders as the correlated subquery a `$count` reads, so a field holding one is read, filtered and\n * sorted like any other.\n *\n * `V` is the value it reads and `Storable` whether a trigger could keep it, both carried in phantom\n * fields so the aggregate a field declares decides the property's type and refuses `stored: true` on\n * one no delta can maintain.\n */\nexport class RelationAggregate<V = unknown, Storable extends boolean = boolean> extends QueryRaw {\n declare private readonly __value: V;\n declare private readonly __storable: Storable;\n\n constructor(\n /** What it reads, kept beside the SQL so a read decodes the value the way the target's field does. */\n readonly spec: RelationAggregateSpec,\n value: QueryRawFn,\n ) {\n super(value);\n }\n}\n",
|
|
8
8
|
"import type { EntityMeta } from '../type/index.js';\n\nexport function throwPendingTransaction(): never {\n throw TypeError('pending transaction');\n}\n\nexport function throwNoPendingTransaction(): never {\n throw TypeError('not a pending transaction');\n}\n\nexport function clone<T>(value: T): T {\n if (typeof value !== 'object' || value === null) {\n return value;\n }\n if (Array.isArray(value)) {\n return value.map((it) => clone(it)) as T;\n }\n return { ...value };\n}\n\n/** Whether `obj` has at least one enumerable key. Narrows away `undefined`/`null` for callers. */\nexport function hasKeys<T>(obj: T): obj is NonNullable<T> {\n if (typeof obj !== 'object' || obj === null) return false;\n for (const _ in obj) return true;\n return false;\n}\n\n/**\n * Whether any enumerable key of `obj` satisfies `pred`, short-circuiting on the first match\n * without materializing a key array (unlike `Object.keys(obj).some(pred)`).\n */\nexport function someKey<T extends object>(obj: T, pred: (key: keyof T & string) => boolean): boolean {\n for (const key in obj) {\n if (pred(key)) return true;\n }\n return false;\n}\n\n/** Whether `key` names an operator (`$eq`, `$push`...) rather than a field. */\nexport function isOperatorKey(key: string): boolean {\n return key.startsWith('$');\n}\n\n/** Whether `value` is a non-empty object with an operator key (`$eq`, `$push`...): the one test every dialect classifies with. */\nexport function isOperatorObject(value: unknown): value is Record<string, unknown> {\n return isRecord(value) && someKey(value, isOperatorKey);\n}\n\n/** Whether `value` is an object that is not an array, whose keys can be read. */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nexport function getKeys<T extends object>(obj: T | null | undefined): (keyof T & string)[] {\n return obj ? (Object.keys(obj) as (keyof T & string)[]) : [];\n}\n\n/** The entries of `record` holding a value: a key declared but left `undefined` is no entry at all. */\nexport function definedEntries<K extends string, V>(record: Partial<Record<K, V>>): [K, V][] {\n return (Object.entries(record) as [K, V | undefined][]).filter((entry): entry is [K, V] => entry[1] !== undefined);\n}\n\n/**\n * The entity's own name, declared or its class's. `meta.name` holds only what the author wrote, so\n * the fallback is what an entity that named no table is called - which is why the sites spelling this\n * out reached for three different fallbacks, `?? ''` among them, and named nothing at all.\n */\nexport function entityName<E>(meta: EntityMeta<E>): string {\n return meta.name ?? meta.entity.name;\n}\n\n/**\n * Whether `value` addresses a row by itself rather than naming columns: every primitive, and the\n * object ids a driver deals in (`ObjectId`, `Date`, bytes). Only a plain object names columns, which\n * is what a `$where` map and a composite key's id object both are; an array is a list of either.\n */\nexport function isScalarId(value: unknown): boolean {\n if (typeof value !== 'object' || value === null) {\n return true;\n }\n if (Array.isArray(value)) {\n return false;\n }\n // `null` as well as `Object.prototype`: an object with no prototype is what a query-string parser\n // hands back (`qs`, express's `req.params`), and reading one as a bare id would name one column\n // with a map of several.\n const proto = Object.getPrototypeOf(value);\n return proto !== Object.prototype && proto !== null;\n}\n\n/** Whether `value` is a plain object naming columns, the one shape a `$where` takes. */\nexport function isWhereMap(value: unknown): value is Record<string, unknown> {\n return !Array.isArray(value) && !isScalarId(value);\n}\n",
|
|
9
9
|
"import type { QueryOptions, WireQuery } from '../type/index.js';\n// the clause lists themselves, not the barrel: this module is in the browser bundle's graph\nimport {\n QUERY_BOOLEAN_CLAUSES,\n QUERY_NUMBER_CLAUSES,\n QUERY_OBJECT_CLAUSES,\n QUERY_ROOT_NUMBER_CLAUSES,\n QUERY_ROOT_OBJECT_CLAUSES,\n} from '../type/query.js';\n// the brand alone, not the class: importing `QueryRaw` for an `instanceof` kept it, and `ColumnRef`\n// with it, in the browser bundle, which is on a size budget\nimport { RAW_VALUE } from '../type/queryRaw.js';\n// the specific util module, not the barrel, so the browser bundle does not pull in entity metadata\nimport { getKeys, isWhereMap } from '../util/object.util.js';\n\n/**\n * Keys accepted from the wire - query structure ({@link Query}) plus the `hardDelete`/`count` scalar\n * flags. Anything else (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't\n * bypass a security filter or inject ambient context - those are server-only. The `satisfies` ties\n * every entry to a real query/option key, so a typo or a renamed option fails to compile.\n */\nconst ALLOWED_QUERY_KEYS = new Set<string>([\n ...QUERY_OBJECT_CLAUSES,\n ...QUERY_ROOT_OBJECT_CLAUSES,\n ...QUERY_NUMBER_CLAUSES,\n ...QUERY_ROOT_NUMBER_CLAUSES,\n ...QUERY_BOOLEAN_CLAUSES,\n 'hardDelete',\n 'count',\n] satisfies (keyof WireQuery<unknown> | keyof Pick<QueryOptions, 'hardDelete'> | 'count')[]);\n\n/**\n * Keys that mean something locally but that this transport can never honor, so they are rejected\n * rather than dropped like the rest. Each request runs on its own auto-committing connection, so a\n * row lock taken here is released before the response is written: honoring `$lock` is impossible,\n * and ignoring it would hand the caller a read they believe is serialized and is not.\n */\nconst REJECTED_QUERY_KEYS = new Set<string>(['$lock'] satisfies (keyof WireQuery<unknown>)[]);\n\n/**\n * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.\n * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.\n */\nexport function parseQueryParams(params: Record<string, unknown> = {}): WireQuery<unknown> {\n const query: Record<string, unknown> = {};\n for (const key of getKeys(params)) {\n if (REJECTED_QUERY_KEYS.has(key)) {\n throw Object.assign(new TypeError(`'${key}' is not supported over HTTP`), { status: 400 });\n }\n if (ALLOWED_QUERY_KEYS.has(key)) {\n query[key] = params[key];\n }\n }\n\n for (const key of [...QUERY_OBJECT_CLAUSES, ...QUERY_ROOT_OBJECT_CLAUSES]) {\n const value = query[key];\n if (typeof value === 'string') {\n try {\n query[key] = JSON.parse(value);\n } catch {\n throw Object.assign(new SyntaxError(`invalid JSON in '${key}'`), { status: 400 });\n }\n }\n }\n\n query['$where'] ??= {};\n if (!isWhereMap(query['$where'])) {\n throw Object.assign(new TypeError(\"'$where' must be a JSON object\"), { status: 400 });\n }\n\n // A query string carries every value as text, so what decodes a clause is the shape its group\n // declares. `'false'` is the reason the boolean pass exists rather than the raw value being taken:\n // it is a non-empty string, so a `$distinct=false` would otherwise read as asking for one.\n for (const key of [...QUERY_NUMBER_CLAUSES, ...QUERY_ROOT_NUMBER_CLAUSES]) {\n if (query[key] !== undefined) {\n query[key] = Number(query[key]);\n }\n }\n for (const key of QUERY_BOOLEAN_CLAUSES) {\n if (query[key] !== undefined) {\n query[key] = query[key] === true || query[key] === 'true';\n }\n }\n\n return query as WireQuery<unknown>;\n}\n\n/**\n * Serialize a UQL query object into a percent-encoded query string where object values\n * are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.\n */\nexport function stringifyQuery(query?: Record<string, unknown>): string {\n if (!query) {\n return '';\n }\n const params = new URLSearchParams();\n for (const key of getKeys(query)) {\n const value = query[key];\n if (value === undefined) {\n continue;\n }\n params.append(key, typeof value === 'object' && value !== null ? wireJson(value) : String(value));\n }\n const qs = params.toString();\n return qs ? `?${qs}` : '';\n}\n\n/**\n * What leaves the browser, as JSON, refusing what JSON keeps nothing of rather than letting the server\n * build a statement around the remains. A `raw` fragment renders SQL against a dialect the client does not\n * have and arrives as `{}`; binary arrives as an object keyed by index. A `Date` is not among them - it\n * serializes to ISO 8601, which is what a date column reads. This is what a cast, or a JavaScript caller,\n * hits where the client's types already refuse a fragment.\n */\nexport function wireJson(value: unknown): string {\n return JSON.stringify(value, (_key: string, held: unknown) => {\n if (typeof held !== 'object' || held === null) {\n return held;\n }\n if (RAW_VALUE in held) {\n throw new TypeError('raw SQL cannot travel over HTTP: what leaves the browser is JSON');\n }\n // A blob is a field value, so no type parameter reaches it: this is the only place it is caught.\n if (held instanceof ArrayBuffer || ArrayBuffer.isView(held)) {\n throw new TypeError('binary cannot travel over HTTP: what leaves the browser is JSON');\n }\n return held;\n });\n}\n",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"import { CRUD_ROUTES, entityPath, type HttpMethod } from '../../http/contract.js';\nimport { stringifyQuery } from '../../http/query.js';\nimport type {\n EntityWrite,\n EntityId,\n FieldKey,\n QueryFilter,\n QueryFindResult,\n QueryOneProjected,\n QueryOptions,\n QueryPage,\n QueryProjected,\n QuerySearch,\n RelationKey,\n RequestCountedSuccessResponse,\n RequestSuccessResponse,\n Type,\n UpdateWrite,\n WireQuery,\n WrittenId,\n} from '../../type/index.js';\nimport { isScalarId } from '../../util/object.util.js';\nimport { get, query as httpQuery, patch, post, put, remove } from '../http/index.js';\nimport type { ClientQuerier, RequestFindOptions, RequestOptions } from '../type/index.js';\n\nexport type HttpQuerierDefaults = {\n /**\n * headers sent with every request from this instance, merged under per-call headers.\n * Create one instance per request (e.g. during SSR) to scope auth headers safely.\n */\n readonly headers?: Record<string, string>;\n /**\n * transport for read queries (findOne, findMany, count). 'QUERY' (RFC 10008) sends the\n * JSON query in the request body, avoiding URL-length limits for large queries; requires\n * infrastructure (proxies, CDNs) that forwards the QUERY method. Defaults to 'GET'.\n */\n readonly readMethod?: Extract<HttpMethod, 'GET' | 'QUERY'>;\n /**\n * The URL segment an entity is addressed by, defaulting to its kebab-cased class name - the same\n * option the server handler takes, so one map serves both. State it where the default cannot: a\n * build that minifies class names renames every route.\n */\n readonly entityPath?: (entity: Type<unknown>) => string;\n};\n\n/** The id as one path segment, refusing a composite key, which has no spelling in `/:id` yet. Callers are `async`. */\nfunction idSegment<E>(entity: Type<E>, id: EntityId<E>): string {\n if (!isScalarId(id)) {\n throw new TypeError(`'${entity.name}' was addressed by an id object, which the HTTP route cannot carry.`);\n }\n return String(id);\n}\n\nexport class HttpQuerier implements ClientQuerier {\n constructor(\n readonly basePath: string,\n readonly defaults: HttpQuerierDefaults = {},\n ) {}\n\n async findOneById<\n E extends object,\n const S extends FieldKey<E> = never,\n const V = true,\n const X extends FieldKey<E> = never,\n const P extends RelationKey<E> = never,\n const C extends RelationKey<E> = never,\n >(\n entity: Type<E>,\n id: EntityId<E>,\n q?: QueryOneProjected<E, S, V, X, P, C, never>,\n opts?: RequestOptions,\n ): Promise<RequestSuccessResponse<QueryFindResult<E, S, V, X, P, C> | undefined>> {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(q);\n return get<QueryFindResult<E, S, V, X, P, C> | undefined>(\n `${basePath}/${idSegment(entity, id)}${qs}`,\n this.buildOptions(opts),\n );\n }\n\n findOne<\n E extends object,\n const S extends FieldKey<E> = never,\n const V = true,\n const X extends FieldKey<E> = never,\n const P extends RelationKey<E> = never,\n const C extends RelationKey<E> = never,\n >(\n entity: Type<E>,\n q: QueryOneProjected<E, S, V, X, P, C, never>,\n opts?: RequestOptions,\n ): Promise<RequestSuccessResponse<QueryFindResult<E, S, V, X, P, C> | undefined>> {\n return this.read<QueryFindResult<E, S, V, X, P, C> | undefined>(\n `${this.getBasePath(entity)}${CRUD_ROUTES.findOne.path}`,\n q,\n opts,\n );\n }\n\n findMany<\n E extends object,\n const S extends FieldKey<E> = never,\n const V = true,\n const X extends FieldKey<E> = never,\n const P extends RelationKey<E> = never,\n const C extends RelationKey<E> = never,\n >(\n entity: Type<E>,\n q: QueryProjected<E, S, V, X, P, C, never>,\n opts?: RequestFindOptions,\n ): Promise<RequestSuccessResponse<QueryFindResult<E, S, V, X, P, C>[]>> {\n const data: WireQuery<E> & { count?: boolean } = { ...q };\n if (opts?.count) {\n data.count = true;\n }\n return this.read<QueryFindResult<E, S, V, X, P, C>[]>(this.getBasePath(entity), data, opts);\n }\n\n async findManyAndCount<\n E extends object,\n const S extends FieldKey<E> = never,\n const V = true,\n const X extends FieldKey<E> = never,\n const P extends RelationKey<E> = never,\n const C extends RelationKey<E> = never,\n >(\n entity: Type<E>,\n q: QueryProjected<E, S, V, X, P, C, never>,\n opts?: RequestFindOptions,\n ): Promise<RequestCountedSuccessResponse<QueryFindResult<E, S, V, X, P, C>[]>> {\n const response = await this.findMany(entity, q, { ...opts, count: true });\n if (typeof response.count !== 'number') {\n throw new TypeError('findManyAndCount response has an invalid count');\n }\n return { ...response, count: response.count };\n }\n\n count<E extends object>(entity: Type<E>, q?: QueryPage<E, never>, opts?: RequestOptions) {\n return this.read<number>(`${this.getBasePath(entity)}${CRUD_ROUTES.count.path}`, q, opts);\n }\n\n /** The `count` route capped at one row, so existence needs no endpoint of its own. */\n async exists<E extends object>(entity: Type<E>, q?: QueryFilter<E, never>, opts?: RequestOptions) {\n const res = await this.count(entity, { ...q, $limit: 1 }, opts);\n return { ...res, data: res.data > 0 };\n }\n\n insertOne<E extends object>(entity: Type<E>, payload: EntityWrite<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return post<WrittenId<E> | undefined>(basePath, payload, this.buildOptions(opts));\n }\n\n insertMany<E extends object>(entity: Type<E>, payload: EntityWrite<E>[], opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return post<(WrittenId<E> | undefined)[]>(\n `${basePath}${CRUD_ROUTES.insertMany.path}`,\n payload,\n this.buildOptions(opts),\n );\n }\n\n async updateOneById<E extends object>(\n entity: Type<E>,\n id: EntityId<E>,\n payload: UpdateWrite<E, never>,\n opts?: RequestOptions,\n ) {\n const basePath = this.getBasePath(entity);\n return patch<number>(`${basePath}/${idSegment(entity, id)}`, payload, this.buildOptions(opts));\n }\n\n updateMany<E extends object>(\n entity: Type<E>,\n q: QuerySearch<E, never>,\n payload: UpdateWrite<E, never>,\n opts?: RequestOptions,\n ) {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(q);\n return patch<number>(`${basePath}${qs}`, payload, this.buildOptions(opts));\n }\n\n saveOne<E extends object>(entity: Type<E>, payload: EntityWrite<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return put<WrittenId<E> | undefined>(basePath, payload, this.buildOptions(opts));\n }\n\n saveMany<E extends object>(entity: Type<E>, payload: EntityWrite<E>[], opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return put<(WrittenId<E> | undefined)[]>(\n `${basePath}${CRUD_ROUTES.saveMany.path}`,\n payload,\n this.buildOptions(opts),\n );\n }\n\n async deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts: QueryOptions & RequestOptions = {}) {\n const basePath = this.getBasePath(entity);\n const qs = opts.hardDelete ? stringifyQuery({ hardDelete: opts.hardDelete }) : '';\n return remove<number>(`${basePath}/${idSegment(entity, id)}${qs}`, this.buildOptions(opts));\n }\n\n deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E, never>, opts: QueryOptions & RequestOptions = {}) {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(opts.hardDelete ? { ...q, hardDelete: opts.hardDelete } : q);\n return remove<number>(`${basePath}${qs}`, this.buildOptions(opts));\n }\n\n getBasePath<E>(entity: Type<E>) {\n return `${this.basePath}/${(this.defaults.entityPath ?? entityPath)(entity)}`;\n }\n\n protected read<T>(path: string, q: Record<string, unknown> | undefined, opts?: RequestOptions) {\n if (this.defaults.readMethod === 'QUERY') {\n return httpQuery<T>(path, q ?? {}, this.buildOptions(opts));\n }\n return get<T>(`${path}${stringifyQuery(q)}`, this.buildOptions(opts));\n }\n\n protected buildOptions(opts?: RequestOptions): RequestOptions | undefined {\n if (!this.defaults.headers && !opts?.headers) {\n return opts;\n }\n return { ...opts, headers: { ...this.defaults.headers, ...opts?.headers } };\n }\n}\n",
|
|
14
14
|
"import { HttpQuerier } from './querier/httpQuerier.js';\nimport type { ClientQuerier, ClientQuerierPool } from './type/index.js';\n\nlet defaultPool: ClientQuerierPool = {\n getQuerier: () => new HttpQuerier('/api'),\n};\n\nexport function setQuerierPool<T extends ClientQuerierPool>(pool: T) {\n defaultPool = pool;\n}\n\nexport function getQuerierPool(): ClientQuerierPool {\n return defaultPool;\n}\n\nexport function getQuerier(): ClientQuerier {\n return getQuerierPool().getQuerier();\n}\n"
|
|
15
15
|
],
|
|
16
|
-
"mappings": "AAEA,IAAM,EAAkC,CAAC,EAElC,SAAS,CAAM,CAAC,EAAyC,CAC9D,QAAW,KAAe,EACxB,EAAY,CAAY,EAIrB,SAAS,CAAE,CAAC,EAAiC,CAClD,EAAa,KAAK,CAAE,EACpB,IAAM,EAAQ,EAAa,OAAS,EACpC,MAAO,IAAY,CACjB,EAAa,OAAO,EAAO,CAAC,
|
|
17
|
-
"debugId": "
|
|
16
|
+
"mappings": "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",
|
|
17
|
+
"debugId": "3410F618088BC65664756E2164756E21",
|
|
18
18
|
"names": []
|
|
19
19
|
}
|
|
@@ -16,10 +16,14 @@ type InsertShape<E> = {
|
|
|
16
16
|
};
|
|
17
17
|
/** One entry of {@link AbstractSqlDialect.hydratableFields}: a field key and how it decodes. */
|
|
18
18
|
type HydratableField = readonly [string, HydrateKind];
|
|
19
|
+
/** A direction as a statement writes it: the suffix, and where the caller asked nulls to land. */
|
|
20
|
+
type SortOrder = {
|
|
21
|
+
readonly direction?: string;
|
|
22
|
+
readonly nulls?: 'first' | 'last';
|
|
23
|
+
};
|
|
19
24
|
/** A sort term of a relation's rows as their aggregate orders by it: the column carrying it out. */
|
|
20
|
-
export type SortRef = {
|
|
25
|
+
export type SortRef = SortOrder & {
|
|
21
26
|
readonly ref: string;
|
|
22
|
-
readonly direction: string;
|
|
23
27
|
};
|
|
24
28
|
/**
|
|
25
29
|
* One column of a read's projection: the key its row answers under, none for a raw expression written
|
|
@@ -443,6 +447,12 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
|
|
|
443
447
|
protected having(ctx: QueryContext, having: QueryHavingMap, emittedColumns: Record<string, string>): void;
|
|
444
448
|
private static readonly SORT_DIRECTION_MAP;
|
|
445
449
|
private resolveSortDirection;
|
|
450
|
+
/**
|
|
451
|
+
* One `ORDER BY` term. A placement the engine has no `NULLS FIRST/LAST` for becomes a term of its
|
|
452
|
+
* own in front of it, which is why one is only ever emitted where the caller asked for it: no index
|
|
453
|
+
* serves an expression. SQL Server needs a `CASE`, having no orderable boolean.
|
|
454
|
+
*/
|
|
455
|
+
protected orderByTerm(expr: string, { direction, nulls }: SortOrder): string;
|
|
446
456
|
/** Every operator of one `HAVING` condition, `AND`-joined. */
|
|
447
457
|
protected havingCondition(ctx: QueryContext, expr: string, condition: QueryHavingMap[string]): void;
|
|
448
458
|
/**
|
|
@@ -164,7 +164,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
164
164
|
if (!order.length || (q.$limit === undefined && q.$skip === undefined)) {
|
|
165
165
|
return false;
|
|
166
166
|
}
|
|
167
|
-
ctx.append(` ORDER BY ${order.map(({ ref,
|
|
167
|
+
ctx.append(` ORDER BY ${order.map(({ ref, ...term }) => this.orderByTerm(ref, term)).join(', ')}`);
|
|
168
168
|
return true;
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
@@ -323,13 +323,13 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
323
323
|
*/
|
|
324
324
|
carrySort(ctx, meta, q, opts) {
|
|
325
325
|
const columns = [];
|
|
326
|
-
const order = this.sortTerms(ctx, meta, q, opts).map(({ key, expr,
|
|
326
|
+
const order = this.sortTerms(ctx, meta, q, opts).map(({ key, expr, output, ...term }) => {
|
|
327
327
|
if (output) {
|
|
328
|
-
return { ref: expr,
|
|
328
|
+
return { ref: expr, ...term };
|
|
329
329
|
}
|
|
330
330
|
const column = relationSortColumn(key);
|
|
331
331
|
columns.push({ sql: expr, key: column });
|
|
332
|
-
return { ref: this.escapeId(column, true),
|
|
332
|
+
return { ref: this.escapeId(column, true), ...term };
|
|
333
333
|
});
|
|
334
334
|
return { columns, order };
|
|
335
335
|
}
|
|
@@ -800,7 +800,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
800
800
|
sort(ctx, entity, q, opts = {}) {
|
|
801
801
|
const terms = this.sortTerms(ctx, getMeta(entity), q, opts);
|
|
802
802
|
if (terms.length) {
|
|
803
|
-
ctx.append(` ORDER BY ${terms.map(({ expr,
|
|
803
|
+
ctx.append(` ORDER BY ${terms.map(({ expr, ...term }) => this.orderByTerm(expr, term)).join(', ')}`);
|
|
804
804
|
}
|
|
805
805
|
return terms.length > 0;
|
|
806
806
|
}
|
|
@@ -837,7 +837,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
837
837
|
// Where projected in the SELECT list, ordered by that alias rather than scored twice.
|
|
838
838
|
const { order, project } = textSortOf(sort);
|
|
839
839
|
const expr = project ? this.escapeId(project) : this.buildFragment(ctx, opts.rankText);
|
|
840
|
-
columns.push({ key, expr,
|
|
840
|
+
columns.push({ key, expr, ...this.resolveSortDirection(order), output: project !== undefined });
|
|
841
841
|
continue;
|
|
842
842
|
}
|
|
843
843
|
if (relation) {
|
|
@@ -851,10 +851,10 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
851
851
|
}
|
|
852
852
|
const expr = this.buildFragment(ctx, (fragmentCtx) => this.appendRelationAggregate(fragmentCtx, meta.entity, spec, prefix ?? ''));
|
|
853
853
|
if (spec.search) {
|
|
854
|
-
vectors.push({ key: name, expr,
|
|
854
|
+
vectors.push({ key: name, expr, output: false });
|
|
855
855
|
}
|
|
856
856
|
else {
|
|
857
|
-
columns.push({ key: keyPath, expr,
|
|
857
|
+
columns.push({ key: keyPath, expr, ...this.resolveSortDirection(direction), output: false });
|
|
858
858
|
}
|
|
859
859
|
}
|
|
860
860
|
if (rest === undefined) {
|
|
@@ -872,19 +872,18 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
872
872
|
if (isVectorSearch(value)) {
|
|
873
873
|
// Already projected in the SELECT list: order by that alias rather than recomputing it.
|
|
874
874
|
vectors.push(value.$project
|
|
875
|
-
? { key: keyPath, expr: this.escapeId(value.$project),
|
|
875
|
+
? { key: keyPath, expr: this.escapeId(value.$project), output: true }
|
|
876
876
|
: {
|
|
877
877
|
key: keyPath,
|
|
878
878
|
expr: this.buildFragment(ctx, (fragmentCtx) => this.appendVectorDistance(fragmentCtx, meta, key, value, prefix)),
|
|
879
|
-
direction: '',
|
|
880
879
|
output: false,
|
|
881
880
|
});
|
|
882
881
|
continue;
|
|
883
882
|
}
|
|
884
|
-
const
|
|
883
|
+
const order = this.resolveSortDirection(value);
|
|
885
884
|
// A JSON path can sort by more than one reading, each carried under a name of its own.
|
|
886
885
|
this.sortColumns(ctx, meta, key, prefix).forEach((column, index) => {
|
|
887
|
-
columns.push({ key: index ? `${keyPath}:${index}` : keyPath, ...column,
|
|
886
|
+
columns.push({ key: index ? `${keyPath}:${index}` : keyPath, ...column, ...order });
|
|
888
887
|
});
|
|
889
888
|
}
|
|
890
889
|
}
|
|
@@ -1097,7 +1096,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1097
1096
|
Object.entries(sort).forEach(([key, dir], index) => {
|
|
1098
1097
|
if (index > 0)
|
|
1099
1098
|
ctx.append(', ');
|
|
1100
|
-
ctx.append(this.aggregateRef(emittedColumns, key, '$sort')
|
|
1099
|
+
ctx.append(this.orderByTerm(this.aggregateRef(emittedColumns, key, '$sort'), this.resolveSortDirection(dir)));
|
|
1101
1100
|
});
|
|
1102
1101
|
return true;
|
|
1103
1102
|
}
|
|
@@ -1119,14 +1118,36 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1119
1118
|
});
|
|
1120
1119
|
}
|
|
1121
1120
|
static SORT_DIRECTION_MAP = new Map([
|
|
1122
|
-
[1,
|
|
1123
|
-
['asc',
|
|
1124
|
-
['desc', ' DESC'],
|
|
1125
|
-
[-1, ' DESC'],
|
|
1121
|
+
[1, {}],
|
|
1122
|
+
['asc', {}],
|
|
1123
|
+
['desc', { direction: ' DESC' }],
|
|
1124
|
+
[-1, { direction: ' DESC' }],
|
|
1125
|
+
['ascNullsFirst', { nulls: 'first' }],
|
|
1126
|
+
['ascNullsLast', { nulls: 'last' }],
|
|
1127
|
+
['descNullsFirst', { direction: ' DESC', nulls: 'first' }],
|
|
1128
|
+
['descNullsLast', { direction: ' DESC', nulls: 'last' }],
|
|
1126
1129
|
]);
|
|
1127
1130
|
resolveSortDirection(sort) {
|
|
1128
|
-
const
|
|
1129
|
-
return orRefuse(
|
|
1131
|
+
const order = AbstractSqlDialect.SORT_DIRECTION_MAP.get(sort);
|
|
1132
|
+
return orRefuse(order, `unknown sort direction: ${sort}`);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* One `ORDER BY` term. A placement the engine has no `NULLS FIRST/LAST` for becomes a term of its
|
|
1136
|
+
* own in front of it, which is why one is only ever emitted where the caller asked for it: no index
|
|
1137
|
+
* serves an expression. SQL Server needs a `CASE`, having no orderable boolean.
|
|
1138
|
+
*/
|
|
1139
|
+
orderByTerm(expr, { direction = '', nulls }) {
|
|
1140
|
+
if (!nulls) {
|
|
1141
|
+
return expr + direction;
|
|
1142
|
+
}
|
|
1143
|
+
const first = nulls === 'first';
|
|
1144
|
+
if (this.features.nullsOrdering === 'clause') {
|
|
1145
|
+
return `${expr}${direction} NULLS ${first ? 'FIRST' : 'LAST'}`;
|
|
1146
|
+
}
|
|
1147
|
+
const lead = this.features.nullsOrdering === 'case'
|
|
1148
|
+
? `CASE WHEN ${expr} IS NULL THEN ${first ? 0 : 1} ELSE ${first ? 1 : 0} END`
|
|
1149
|
+
: `${expr} IS ${first ? 'NOT NULL' : 'NULL'}`;
|
|
1150
|
+
return `${lead}, ${expr}${direction}`;
|
|
1130
1151
|
}
|
|
1131
1152
|
/** Every operator of one `HAVING` condition, `AND`-joined. */
|
|
1132
1153
|
havingCondition(ctx, expr, condition) {
|
|
@@ -1839,7 +1860,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1839
1860
|
const key = relationTermKey(term);
|
|
1840
1861
|
return [key, `${alias}.${this.escapeId(key, true)}`];
|
|
1841
1862
|
}),
|
|
1842
|
-
order: order.map(({ ref,
|
|
1863
|
+
order: order.map(({ ref, ...term }) => this.orderByTerm(`${alias}.${ref}`, term)).join(', '),
|
|
1843
1864
|
};
|
|
1844
1865
|
}
|
|
1845
1866
|
/**
|
|
@@ -24,6 +24,11 @@ export declare const JSON_ELEM_ALIAS = "_uql_elem";
|
|
|
24
24
|
export declare const JSON_PULL_ALIAS = "_uql_pull";
|
|
25
25
|
/** The field a MongoDB `$sort` by `$text` reads `textScore` from where it projects none, taken back out after. */
|
|
26
26
|
export declare const TEXT_SCORE_ALIAS = "_uql_text_score";
|
|
27
|
+
/**
|
|
28
|
+
* The field a MongoDB `$sort` placing nulls orders by first: 1 where the value is null or missing, 0
|
|
29
|
+
* where it is not. MongoDB takes no `NULLS FIRST`, so the placement is this flag plus the sort on it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function nullsSortField(path: string): string;
|
|
27
32
|
/** Prefix for the field a MongoDB relation lookup parks its result on, one per condition. */
|
|
28
33
|
export declare const REL_TEMP_PREFIX = "_uql_rel_";
|
|
29
34
|
/** The field a ManyToMany lookup nests its target match under, inside the junction's own pipeline. */
|
package/dist/dialect/aliases.js
CHANGED
|
@@ -25,6 +25,13 @@ export const JSON_ELEM_ALIAS = '_uql_elem';
|
|
|
25
25
|
export const JSON_PULL_ALIAS = '_uql_pull';
|
|
26
26
|
/** The field a MongoDB `$sort` by `$text` reads `textScore` from where it projects none, taken back out after. */
|
|
27
27
|
export const TEXT_SCORE_ALIAS = '_uql_text_score';
|
|
28
|
+
/**
|
|
29
|
+
* The field a MongoDB `$sort` placing nulls orders by first: 1 where the value is null or missing, 0
|
|
30
|
+
* where it is not. MongoDB takes no `NULLS FIRST`, so the placement is this flag plus the sort on it.
|
|
31
|
+
*/
|
|
32
|
+
export function nullsSortField(path) {
|
|
33
|
+
return `_uql_nulls_${path.replace(/\./g, '_')}`;
|
|
34
|
+
}
|
|
28
35
|
/** Prefix for the field a MongoDB relation lookup parks its result on, one per condition. */
|
|
29
36
|
export const REL_TEMP_PREFIX = '_uql_rel_';
|
|
30
37
|
/** The field a ManyToMany lookup nests its target match under, inside the junction's own pipeline. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AggregateValue, ComputedRefs, EntityAggregate, EntityGetter, Except, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, RelationAggregate, TsTypeOf, Writable } from '../../type/index.js';
|
|
1
|
+
import type { AggregateValue, ComputedRefs, EntityAggregate, EntityGetter, Except, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, VersionKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, RelationAggregate, TsTypeOf, Writable } from '../../type/index.js';
|
|
2
2
|
import type { RejectIncompatible } from '../../util/index.js';
|
|
3
3
|
/** A member decorator that also constrains the property it may be applied to, on a class `O`. */
|
|
4
4
|
type MemberDecorator<V, O = unknown> = (value: undefined, context: ClassFieldDecoratorContext<O, V>) => void;
|
|
@@ -31,12 +31,14 @@ type DeclaredValue<O> = O extends {
|
|
|
31
31
|
/**
|
|
32
32
|
* The `null` a column reads back: every one holds it unless `nullable: false` says otherwise, so the
|
|
33
33
|
* property admits it too. A key holds none, whether `@Id` or `@Field({ isId: true })` declares it, since
|
|
34
|
-
* it is NOT NULL on every engine.
|
|
34
|
+
* it is NOT NULL on every engine, and neither does a version, which is NOT NULL DEFAULT 0.
|
|
35
35
|
*/
|
|
36
36
|
type NullOf<O> = O extends {
|
|
37
37
|
readonly nullable: false;
|
|
38
38
|
} | {
|
|
39
39
|
readonly isId: true;
|
|
40
|
+
} | {
|
|
41
|
+
readonly version: true;
|
|
40
42
|
} ? never : null;
|
|
41
43
|
/** The enum's members, or a named complaint where they widened for lack of `as const`, which would check nothing. */
|
|
42
44
|
type EnumValue<Members, Declared> = Declared extends Members ? {
|
|
@@ -51,7 +53,7 @@ export declare function Field<This, O extends FieldOptions<DeclaredValue<O>, Thi
|
|
|
51
53
|
type: FieldType;
|
|
52
54
|
} | {
|
|
53
55
|
references: EntityGetter;
|
|
54
|
-
}) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O): AdmittingDecorator<DeclaredValue<O> | NullOf<O>, NullOf<O>, This>;
|
|
56
|
+
}) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O & VersionIsBranded<O, This>): AdmittingDecorator<DeclaredValue<O> | NullOf<O>, NullOf<O>, This>;
|
|
55
57
|
/**
|
|
56
58
|
* Declares a field a relation aggregate computes, `@Field({ computed: (user) => user.resources.count() })`.
|
|
57
59
|
* The aggregate says what the field holds, so it takes no `type`, and only `count` and `sum` - the two a
|
|
@@ -80,6 +82,16 @@ type AggregateOptions<E> = (Except<FieldOptions<never, E>, 'computed' | 'stored'
|
|
|
80
82
|
type KeyIsNamed<This> = [NamedIdKey<This>] extends [never] ? {
|
|
81
83
|
readonly __keyNeedsIdKeyBrand: true;
|
|
82
84
|
} : unknown;
|
|
85
|
+
/**
|
|
86
|
+
* An optimistic lock the type level cannot see. `version: true` is what makes the column a lock at run
|
|
87
|
+
* time; the `versionKey` brand is what makes an update payload require it, and a lock only half
|
|
88
|
+
* declared would be a guarantee nothing enforces, so the decorator asks for both.
|
|
89
|
+
*/
|
|
90
|
+
type VersionIsBranded<O, This> = O extends {
|
|
91
|
+
readonly version: true;
|
|
92
|
+
} ? [VersionKey<This>] extends [never] ? {
|
|
93
|
+
readonly __versionNeedsVersionKeyBrand: true;
|
|
94
|
+
} : unknown : unknown;
|
|
83
95
|
/** {@link MemberDecorator} that also constrains the class, which is where a key is named. */
|
|
84
96
|
type IdDecorator<V> = <This>(value: undefined, context: ClassFieldDecoratorContext<This, V> & KeyIsNamed<This>) => void;
|
|
85
97
|
/**
|
|
@@ -213,6 +213,18 @@ export function defineEntity(entity, opts = {}) {
|
|
|
213
213
|
meta.softDelete = softDeleteKeys[0];
|
|
214
214
|
(meta.filters ??= {})[SOFT_DELETE_FILTER] = { where: { [meta.softDelete]: null }, default: true };
|
|
215
215
|
}
|
|
216
|
+
// The optimistic lock, derived the same way and just as singular: one row has one version.
|
|
217
|
+
const versionKeys = getKeys(meta.fields).filter((key) => meta.fields[key]?.version);
|
|
218
|
+
if (versionKeys.length > 1) {
|
|
219
|
+
throw TypeError(`'${entity.name}' must have at most one field with 'version'`);
|
|
220
|
+
}
|
|
221
|
+
if (versionKeys.length) {
|
|
222
|
+
meta.version = versionKeys[0];
|
|
223
|
+
// Implied rather than stated: the DDL default covers a row written around the ORM, and `onInsert`
|
|
224
|
+
// covers MongoDB, which has no DDL to default. Both, so every backend starts a row at the same 0.
|
|
225
|
+
const field = meta.fields[meta.version];
|
|
226
|
+
meta.fields[meta.version] = { ...field, nullable: false, defaultValue: 0, onInsert: 0 };
|
|
227
|
+
}
|
|
216
228
|
const ids = getIdKeys(meta);
|
|
217
229
|
if (!ids.length) {
|
|
218
230
|
throw TypeError(`'${entity.name}' must have at least one id field (use @Id, defineId, or defineEntity({ fields: { ..., isId: true } }))`);
|
|
@@ -2,10 +2,20 @@ import { type Document, type Filter, type Sort, type UpdateFilter } from 'mongod
|
|
|
2
2
|
import { AbstractDialect } from '../dialect/abstractDialect.js';
|
|
3
3
|
import type { DialectFeatures, EntityData, EntityMeta, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QueryPager, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
|
|
4
4
|
import { type CallbackKey } from '../util/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* An ordering as the pipeline runs it: the `$sort`, the fields it needs on the document first - a
|
|
7
|
+
* placement's null flags - and the ones to take back off after it. One object, so a caller cannot
|
|
8
|
+
* order by a field it forgot to add, or leave one behind in the rows it answers with.
|
|
9
|
+
*/
|
|
10
|
+
export type MongoSortPlan = {
|
|
11
|
+
readonly sort: Sort;
|
|
12
|
+
readonly stages: MongoAggregationPipelineEntry<Document>[];
|
|
13
|
+
readonly fields: string[];
|
|
14
|
+
};
|
|
5
15
|
/** What a read pipeline contributes to {@link MongoDialect.readStages} beyond the query itself. */
|
|
6
16
|
type MongoReadStages = {
|
|
7
17
|
/** Ordering, which runs after the lookups when it reads one of their fields. */
|
|
8
|
-
readonly sort?:
|
|
18
|
+
readonly sort?: MongoSortPlan;
|
|
9
19
|
readonly pager?: MongoAggregationPipelineEntry<Document>[];
|
|
10
20
|
/** A score the read answers as a field, a vector search's or a text search's; a temporary one leaves again. */
|
|
11
21
|
readonly score?: {
|
|
@@ -126,7 +136,13 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
126
136
|
* means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
|
|
127
137
|
* added for the sort alone would change what the caller gets back.
|
|
128
138
|
*/
|
|
129
|
-
sort<E extends Document>(entity: Type<E>,
|
|
139
|
+
sort<E extends Document>(entity: Type<E>, q: Query<E>): Sort;
|
|
140
|
+
/**
|
|
141
|
+
* The ordering, and the paths whose nulls it places. MongoDB sorts null and missing lowest and takes
|
|
142
|
+
* no placement, so one is emulated: {@link nullsSortStages} flags each path and the flag is ordered
|
|
143
|
+
* by ahead of the value itself.
|
|
144
|
+
*/
|
|
145
|
+
sortPlan<E extends Document>(entity: Type<E>, { $sort: sort, $populate: populate, $where: where }: Query<E>): MongoSortPlan;
|
|
130
146
|
/** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
|
|
131
147
|
private collectSort;
|
|
132
148
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ObjectId } from 'mongodb';
|
|
2
2
|
import { AbstractDialect } from '../dialect/abstractDialect.js';
|
|
3
|
-
import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
|
|
3
|
+
import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, nullsSortField, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
|
|
4
4
|
import { aggregateColumnField, groupPathField, resolveGroupJoins, relationSortTerms, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
|
|
5
5
|
import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
|
|
6
6
|
import { COUNT_RESULT_KEY } from '../type/query.js';
|
|
@@ -9,6 +9,7 @@ import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInte
|
|
|
9
9
|
import { decodeBigIntsExcept } from '../util/wideNumber.js';
|
|
10
10
|
import { textLanguage } from './textLanguage.js';
|
|
11
11
|
import { vectorDistanceExpr } from './vectorDistance.js';
|
|
12
|
+
const EMPTY_SORT_PLAN = { sort: {}, stages: [], fields: [] };
|
|
12
13
|
/** A scalar field's operator as the aggregation operator computing it. */
|
|
13
14
|
const MONGO_ARITHMETIC = { $inc: '$add', $mul: '$multiply' };
|
|
14
15
|
/** Default {@link DialectFeatures} for MongoDB. */
|
|
@@ -460,10 +461,19 @@ export class MongoDialect extends AbstractDialect {
|
|
|
460
461
|
* means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
|
|
461
462
|
* added for the sort alone would change what the caller gets back.
|
|
462
463
|
*/
|
|
463
|
-
sort(entity,
|
|
464
|
+
sort(entity, q) {
|
|
465
|
+
return this.sortPlan(entity, q).sort;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The ordering, and the paths whose nulls it places. MongoDB sorts null and missing lowest and takes
|
|
469
|
+
* no placement, so one is emulated: {@link nullsSortStages} flags each path and the flag is ordered
|
|
470
|
+
* by ahead of the value itself.
|
|
471
|
+
*/
|
|
472
|
+
sortPlan(entity, { $sort: sort, $populate: populate, $where: where }) {
|
|
464
473
|
const meta = getMeta(entity);
|
|
465
474
|
const nearest = {};
|
|
466
475
|
const columns = {};
|
|
476
|
+
const placed = [];
|
|
467
477
|
// Refused as the SQL dialects refuse it, before MongoDB answers a missing score with its own error.
|
|
468
478
|
if (sort?.$text) {
|
|
469
479
|
rankedTextSearch(where);
|
|
@@ -472,12 +482,17 @@ export class MongoDialect extends AbstractDialect {
|
|
|
472
482
|
// pipeline actually produces cannot drift apart - `$sort` contributes its own to-one joins here
|
|
473
483
|
// exactly as it does on the SQL dialects.
|
|
474
484
|
const joins = resolveQueryJoins(meta, { $populate: populate, $sort: sort });
|
|
475
|
-
this.collectSort(meta, sort, joins, '', nearest, columns);
|
|
476
|
-
|
|
477
|
-
return {
|
|
485
|
+
this.collectSort(meta, sort, joins, '', nearest, columns, placed);
|
|
486
|
+
const flags = Object.fromEntries(placed.map((path) => [nullsSortField(path), { $cond: [{ $eq: [{ $ifNull: [`$${path}`, null] }, null] }, 1, 0] }]));
|
|
487
|
+
return {
|
|
488
|
+
// A vector distance is the primary ordering wherever it appears, as on the SQL dialects.
|
|
489
|
+
sort: { ...nearest, ...columns },
|
|
490
|
+
stages: placed.length ? [{ $addFields: flags }] : [],
|
|
491
|
+
fields: placed.map(nullsSortField),
|
|
492
|
+
};
|
|
478
493
|
}
|
|
479
494
|
/** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
|
|
480
|
-
collectSort(meta, sort, joins, path, nearest, out) {
|
|
495
|
+
collectSort(meta, sort, joins, path, nearest, out, placed) {
|
|
481
496
|
for (const [key, value] of Object.entries(sort ?? {})) {
|
|
482
497
|
const relation = meta.relations[key];
|
|
483
498
|
if (key === '$text') {
|
|
@@ -495,7 +510,14 @@ export class MongoDialect extends AbstractDialect {
|
|
|
495
510
|
if (isVectorSearch(value)) {
|
|
496
511
|
throw new TypeError(`cannot $sort by a second vector '${key}' on MongoDB: $vectorSearch ranks by one`);
|
|
497
512
|
}
|
|
498
|
-
|
|
513
|
+
const docPath = path + this.pathOf(meta, key);
|
|
514
|
+
const nulls = sortNulls(value);
|
|
515
|
+
if (nulls) {
|
|
516
|
+
placed.push(docPath);
|
|
517
|
+
// The flag holds 1 for a null, so ordering by it descending brings the null block to the front.
|
|
518
|
+
out[nullsSortField(docPath)] = nulls === 'first' ? -1 : 1;
|
|
519
|
+
}
|
|
520
|
+
out[docPath] = sortDirection(value);
|
|
499
521
|
continue;
|
|
500
522
|
}
|
|
501
523
|
// A `$lookup` is what puts the relation's fields on the document, and only `$populate` asks for
|
|
@@ -521,7 +543,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
521
543
|
continue;
|
|
522
544
|
}
|
|
523
545
|
const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, rest, joins, `cannot $sort by relation '${relPath}' on MongoDB unless it is populated: only $populate adds its fields to the document`);
|
|
524
|
-
this.collectSort(join.meta, relationSort, joins, `${relPath}.`, nearest, out);
|
|
546
|
+
this.collectSort(join.meta, relationSort, joins, `${relPath}.`, nearest, out, placed);
|
|
525
547
|
}
|
|
526
548
|
}
|
|
527
549
|
/**
|
|
@@ -699,7 +721,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
699
721
|
return [
|
|
700
722
|
...this.matchStages(entity, q.$where, opts, this.aggregateKeys(entity, q)),
|
|
701
723
|
...this.readStages(entity, q, {
|
|
702
|
-
sort: this.
|
|
724
|
+
sort: this.sortPlan(entity, q),
|
|
703
725
|
pager: this.pagerStages(q),
|
|
704
726
|
score: text && { field: text.project ?? TEXT_SCORE_ALIAS, meta: 'textScore', temporary: !text.project },
|
|
705
727
|
}),
|
|
@@ -722,10 +744,12 @@ export class MongoDialect extends AbstractDialect {
|
|
|
722
744
|
// The value an ordering by a relation's aggregate reads, and the field it parks it on: both belong
|
|
723
745
|
// with the lookups, since the `$sort` right after them is what they exist for.
|
|
724
746
|
const aggregated = this.sortAggregateStages(entity, q.$sort);
|
|
747
|
+
const ordering = extra.sort ?? EMPTY_SORT_PLAN;
|
|
725
748
|
const lookups = [...this.lookupStages(meta, joins), ...aggregated.stages];
|
|
726
749
|
// Each to-many and each `$count`, which neither drop nor reorder a row, so they read the page alone.
|
|
727
750
|
const related = this.relationReadStages(entity, q);
|
|
728
|
-
|
|
751
|
+
// The flags a placement orders by travel with their `$sort`, wherever the pipeline puts it.
|
|
752
|
+
const sort = hasKeys(ordering.sort) ? [...ordering.stages, { $sort: ordering.sort }] : [];
|
|
729
753
|
const pager = extra.pager ?? [];
|
|
730
754
|
// The score becomes a real field before anything reads it, so the lookups, the sort and the projection
|
|
731
755
|
// that follow treat it like any other; merged into the query's own projection rather than standing in
|
|
@@ -743,7 +767,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
743
767
|
// which is the one way this differs from a SQL join. Taken back out once the `$sort` that needed
|
|
744
768
|
// it has run, so ordering by an unpopulated relation costs the same nothing it does there.
|
|
745
769
|
const sortOnly = [...joins.values()].filter((join) => !join.projected).map((join) => join.path);
|
|
746
|
-
const dropped = [...sortOnly, ...aggregated.fields];
|
|
770
|
+
const dropped = [...sortOnly, ...aggregated.fields, ...ordering.fields];
|
|
747
771
|
const unset = dropped.length ? [{ $unset: dropped }] : [];
|
|
748
772
|
// The grouping collapses rows onto the columns it projects, which leaves nothing for an ordering
|
|
749
773
|
// that reads a lookup those columns do not carry. Refused rather than answered all-equal, and in
|
|
@@ -1328,9 +1352,16 @@ export class MongoDialect extends AbstractDialect {
|
|
|
1328
1352
|
return { $vectorSearch: stage };
|
|
1329
1353
|
}
|
|
1330
1354
|
}
|
|
1331
|
-
/** `-1` for
|
|
1355
|
+
/** `-1` for every descending spelling, `1` for everything else - MongoDB knows no other value. */
|
|
1332
1356
|
function sortDirection(value) {
|
|
1333
|
-
return value ===
|
|
1357
|
+
return value === -1 || (typeof value === 'string' && value.startsWith('desc')) ? -1 : 1;
|
|
1358
|
+
}
|
|
1359
|
+
/** Where a direction asks nulls to land, where it asks at all. */
|
|
1360
|
+
function sortNulls(value) {
|
|
1361
|
+
if (typeof value !== 'string') {
|
|
1362
|
+
return undefined;
|
|
1363
|
+
}
|
|
1364
|
+
return value.endsWith('NullsFirst') ? 'first' : value.endsWith('NullsLast') ? 'last' : undefined;
|
|
1334
1365
|
}
|
|
1335
1366
|
/**
|
|
1336
1367
|
* A `computed` field writing SQL is refused wherever a query names it, since no document engine
|
|
@@ -78,6 +78,8 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
78
78
|
this.dialect.constrainsRelations(entity, q.$where) ||
|
|
79
79
|
this.dialect.sortsRelations(entity, q.$sort) ||
|
|
80
80
|
this.dialect.readsAggregates(entity, q) ||
|
|
81
|
+
// A placement is a field the pipeline adds and orders by; a `find` cursor can add none.
|
|
82
|
+
hasKeys(this.dialect.sortPlan(entity, q).fields) ||
|
|
81
83
|
textSortOf(q.$sort) !== undefined);
|
|
82
84
|
}
|
|
83
85
|
buildScalarProjection(entity, q) {
|
|
@@ -117,7 +119,7 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
117
119
|
this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts, vectorCandidates(q)),
|
|
118
120
|
// `$vectorSearch` has already applied `$limit`, so the pager is its own.
|
|
119
121
|
...this.dialect.readStages(entity, q, {
|
|
120
|
-
sort: this.dialect.
|
|
122
|
+
sort: this.dialect.sortPlan(entity, { ...q, $sort: vectorSort.regularSort }),
|
|
121
123
|
score: scoreAlias ? { field: scoreAlias, meta: 'vectorSearchScore' } : undefined,
|
|
122
124
|
}),
|
|
123
125
|
];
|
|
@@ -86,6 +86,12 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
86
86
|
updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
|
|
87
87
|
/** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
|
|
88
88
|
updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
|
|
89
|
+
/**
|
|
90
|
+
* Why an update matched no row: another writer moved the version on, or the row is gone. One read
|
|
91
|
+
* without the version predicate answers it, and it runs only on the failure, so the happy path
|
|
92
|
+
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
93
|
+
*/
|
|
94
|
+
private throwStaleVersion;
|
|
89
95
|
/** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
|
|
90
96
|
private updateColumns;
|
|
91
97
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
|
|
2
2
|
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isPagedQuery, hasKeys, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereIds, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
3
|
-
import { enrichError } from './queryError.js';
|
|
3
|
+
import { enrichError, UqlOptimisticLockError } from './queryError.js';
|
|
4
4
|
/**
|
|
5
5
|
* Refuses a nullish id, which would reduce to no filter at all, and a composite id missing a column,
|
|
6
6
|
* which would address every row agreeing on the rest. Callers are `async`, so it always rejects.
|
|
@@ -38,6 +38,29 @@ function assertNamesRows(entity, method, q, opts) {
|
|
|
38
38
|
}
|
|
39
39
|
throw new TypeError(`'${method}' over '${entity.name}' names no rows, so it would address every one: pass '{ unfiltered: true }' to mean it`);
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* An optimistic lock as one update applies it: the value the payload carried, out of the payload and
|
|
43
|
+
* into the filter, and the next one back in its place. The bump is a plain value rather than SQL
|
|
44
|
+
* arithmetic, since the filter pins what the column holds, which spares every engine a read-back.
|
|
45
|
+
*/
|
|
46
|
+
function lockVersion(meta, key, q, row) {
|
|
47
|
+
const expected = row[key];
|
|
48
|
+
if (expected === undefined || expected === null) {
|
|
49
|
+
throw new TypeError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
|
|
50
|
+
}
|
|
51
|
+
row[key] = (typeof expected === 'bigint' ? expected + 1n : Number(expected) + 1);
|
|
52
|
+
return { expected, q: { ...q, $where: { $and: [q.$where ?? {}, { [key]: expected }] } } };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Refuses a write that cannot carry the lock, rather than writing over whatever the row holds now.
|
|
56
|
+
* An upsert has no portable way to match a version - MySQL's `ON DUPLICATE KEY UPDATE` takes no
|
|
57
|
+
* `WHERE` - and a write the library itself composes has no version to carry.
|
|
58
|
+
*/
|
|
59
|
+
function assertUnversioned(meta, method) {
|
|
60
|
+
if (meta.version) {
|
|
61
|
+
throw new TypeError(`cannot '${method}' the versioned '${entityName(meta)}': it carries no '${meta.version}' to match, so update it by id`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
41
64
|
/**
|
|
42
65
|
* The id each written row is named by, in payload order. Read off the rows as written, so a key the
|
|
43
66
|
* database generated or the ORM filled is there, and a composite is named by every column of it.
|
|
@@ -198,9 +221,18 @@ export class AbstractQuerier {
|
|
|
198
221
|
const meta = getMeta(entity);
|
|
199
222
|
return this.hooked(entity, 'Update', [payload], async ([row]) => {
|
|
200
223
|
fillOnFields(meta, [row], 'onUpdate');
|
|
224
|
+
const { version } = meta;
|
|
225
|
+
const lock = version && lockVersion(meta, version, q, row);
|
|
201
226
|
const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
|
|
202
227
|
if (!relKeys.length && !this.settlesWrite(entity, q)) {
|
|
203
|
-
|
|
228
|
+
const changes = await this.updateColumns(entity, lock ? lock.q : q, row, opts, 0);
|
|
229
|
+
return lock && !changes ? this.throwStaleVersion(entity, version, q, lock.expected, opts) : changes;
|
|
230
|
+
}
|
|
231
|
+
if (lock) {
|
|
232
|
+
// Everything below reads the ids first and writes them in a second statement, which puts the
|
|
233
|
+
// race back in the gap between the two - the very thing the version is here to close.
|
|
234
|
+
// `settlesWrite` covers the paged forms, so `$sort`, `$limit` and `$skip` land here as well.
|
|
235
|
+
throw new TypeError(`cannot update '${entityName(meta)}' this way: a versioned row is matched and written in one statement, so it takes no '$sort', '$limit' or '$skip', writes no relation, and filters by none`);
|
|
204
236
|
}
|
|
205
237
|
const ids = await this.settleIds(entity, q, opts);
|
|
206
238
|
if (!ids.length) {
|
|
@@ -213,6 +245,19 @@ export class AbstractQuerier {
|
|
|
213
245
|
return changes;
|
|
214
246
|
});
|
|
215
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Why an update matched no row: another writer moved the version on, or the row is gone. One read
|
|
250
|
+
* without the version predicate answers it, and it runs only on the failure, so the happy path
|
|
251
|
+
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
252
|
+
*/
|
|
253
|
+
async throwStaleVersion(entity, key, q, expected, opts) {
|
|
254
|
+
const meta = getMeta(entity);
|
|
255
|
+
const row = await this.findOne(entity, { $select: { [key]: true }, $where: q.$where }, opts);
|
|
256
|
+
const actual = row?.[key];
|
|
257
|
+
throw new UqlOptimisticLockError(actual === undefined
|
|
258
|
+
? `no row of '${entityName(meta)}' matched the update: it is gone, or the filter names none`
|
|
259
|
+
: `'${entityName(meta)}' moved on: the payload carries '${key}' ${String(expected)}, the row is at ${String(actual)}`, expected, actual);
|
|
260
|
+
}
|
|
216
261
|
/** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
|
|
217
262
|
async updateColumns(entity, q, row, opts, unwritten) {
|
|
218
263
|
const writes = filterFieldKeys(getMeta(entity), row, 'onUpdate').length > 0;
|
|
@@ -241,6 +286,7 @@ export class AbstractQuerier {
|
|
|
241
286
|
if (!meta.softDelete) {
|
|
242
287
|
throw new TypeError(`'${entity.name}' has not enabled 'softDelete'`);
|
|
243
288
|
}
|
|
289
|
+
assertUnversioned(meta, 'restoreMany');
|
|
244
290
|
const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
|
|
245
291
|
return this.updateMany(entity, { ...q, $where }, { [meta.softDelete]: null }, {
|
|
246
292
|
filters: { softDelete: false },
|
|
@@ -249,6 +295,7 @@ export class AbstractQuerier {
|
|
|
249
295
|
/** Fires `beforeUpsert`/`afterUpsert`: which branch a row takes is the database's to decide, so neither the insert's nor the update's pair fits. */
|
|
250
296
|
async upsertOne(entity, conflictPaths, payload) {
|
|
251
297
|
const meta = getMeta(entity);
|
|
298
|
+
assertUnversioned(meta, 'upsertOne');
|
|
252
299
|
return this.hooked(entity, 'Upsert', [payload], async (rows) => {
|
|
253
300
|
const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
|
|
254
301
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -258,6 +305,7 @@ export class AbstractQuerier {
|
|
|
258
305
|
}
|
|
259
306
|
async upsertMany(entity, conflictPaths, payload) {
|
|
260
307
|
const meta = getMeta(entity);
|
|
308
|
+
assertUnversioned(meta, 'upsertMany');
|
|
261
309
|
return this.hooked(entity, 'Upsert', payload, async (rows) => {
|
|
262
310
|
const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
|
|
263
311
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -295,6 +343,8 @@ export class AbstractQuerier {
|
|
|
295
343
|
return changes;
|
|
296
344
|
}
|
|
297
345
|
async saveOne(entity, payload) {
|
|
346
|
+
// Named here as well as in `saveMany`, so the refusal names the method the caller reached for.
|
|
347
|
+
assertUnversioned(getMeta(entity), 'saveOne');
|
|
298
348
|
const [id] = await this.saveMany(entity, [payload]);
|
|
299
349
|
return id;
|
|
300
350
|
}
|
|
@@ -305,6 +355,7 @@ export class AbstractQuerier {
|
|
|
305
355
|
*/
|
|
306
356
|
async saveMany(entity, payload) {
|
|
307
357
|
const meta = getMeta(entity);
|
|
358
|
+
assertUnversioned(meta, 'saveMany');
|
|
308
359
|
// Indexes, not rows: the result is reported in payload order so it can be zipped with what was
|
|
309
360
|
// passed, which concatenating the branches did not do.
|
|
310
361
|
const toInsert = [];
|
|
@@ -409,6 +460,9 @@ export class AbstractQuerier {
|
|
|
409
460
|
}
|
|
410
461
|
/** Each parent gets its own referenced row, and its own column pointing at it. */
|
|
411
462
|
async saveManyToOne(entity, relEntity, localColumn, writes) {
|
|
463
|
+
// Before anything is written: the follow-up that points each row at its new relation carries no
|
|
464
|
+
// version, and half an insert is worse than a refusal.
|
|
465
|
+
assertUnversioned(getMeta(entity), 'save a to-one relation of');
|
|
412
466
|
const pointing = writes.filter(({ value }) => value);
|
|
413
467
|
const referenceIds = await this.insertMany(relEntity, pointing.map(({ value }) => value));
|
|
414
468
|
for (const [index, { id }] of pointing.entries()) {
|
|
@@ -11,7 +11,19 @@ export interface QueryError extends Error {
|
|
|
11
11
|
* What a failed query ran into, named the same on every engine. `retryable` is a deadlock, a
|
|
12
12
|
* serialization failure, a lock timeout or a busy database: the transaction can simply run again.
|
|
13
13
|
*/
|
|
14
|
-
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'retryable';
|
|
14
|
+
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable';
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
|
|
17
|
+
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
18
|
+
* where there is no row left. `status` is what an HTTP transport answers with.
|
|
19
|
+
*/
|
|
20
|
+
export declare class UqlOptimisticLockError extends Error {
|
|
21
|
+
readonly expected: unknown;
|
|
22
|
+
readonly actual: unknown;
|
|
23
|
+
name: string;
|
|
24
|
+
readonly status = 409;
|
|
25
|
+
constructor(message: string, expected: unknown, actual: unknown);
|
|
26
|
+
}
|
|
15
27
|
/**
|
|
16
28
|
* Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
|
|
17
29
|
* read, so it works on any driver error, whether or not a querier saw it first.
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
|
|
3
|
+
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
4
|
+
* where there is no row left. `status` is what an HTTP transport answers with.
|
|
5
|
+
*/
|
|
6
|
+
export class UqlOptimisticLockError extends Error {
|
|
7
|
+
expected;
|
|
8
|
+
actual;
|
|
9
|
+
name = 'UqlOptimisticLockError';
|
|
10
|
+
status = 409;
|
|
11
|
+
constructor(message, expected, actual) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.expected = expected;
|
|
14
|
+
this.actual = actual;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
1
17
|
/** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
|
|
2
18
|
const SQLSTATE_KINDS = new Map([
|
|
3
19
|
['23505', 'uniqueViolation'],
|
|
@@ -52,6 +68,9 @@ export function queryErrorKind(err) {
|
|
|
52
68
|
if (typeof err !== 'object' || err === null) {
|
|
53
69
|
return undefined;
|
|
54
70
|
}
|
|
71
|
+
if (err instanceof UqlOptimisticLockError) {
|
|
72
|
+
return 'optimisticLock';
|
|
73
|
+
}
|
|
55
74
|
const { code, errno, number, errorLabels, message } = err;
|
|
56
75
|
const text = typeof message === 'string' ? message : '';
|
|
57
76
|
return (SQLSTATE_KINDS.get(code) ??
|
package/dist/type/dialect.d.ts
CHANGED
|
@@ -144,6 +144,11 @@ export interface SqlDialectFeatures extends DialectFeatures {
|
|
|
144
144
|
readonly rowLockWithWindow: boolean;
|
|
145
145
|
/** Whether a lock can be narrowed to one table of a join, `FOR UPDATE OF`, which MariaDB lacks. */
|
|
146
146
|
readonly rowLockOf: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* How a `$sort` states where nulls land: the `NULLS FIRST/LAST` clause, a leading `IS NULL` term
|
|
149
|
+
* (MySQL, MariaDB), or a leading `CASE` (SQL Server, which has no orderable boolean).
|
|
150
|
+
*/
|
|
151
|
+
readonly nullsOrdering: 'clause' | 'expression' | 'case';
|
|
147
152
|
/**
|
|
148
153
|
* Whether a fulltext index's heavier column needs a fulltext index of its own to be scored by, as
|
|
149
154
|
* MySQL's `MATCH` does, which reads only an index over exactly its columns.
|
package/dist/type/entity.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ import type { Except, ExactlyOne, IsEqual, IsMany, Json, Scalar, Type, Unpacked,
|
|
|
6
6
|
import type { VectorDistance, VectorIndexOptions, VectorIndexType } from './vector.js';
|
|
7
7
|
/** Brands the property an entity is identified by, where it is not `id`, `_id` or `uuid`. */
|
|
8
8
|
export declare const idKey: unique symbol;
|
|
9
|
+
/**
|
|
10
|
+
* Brands the property holding an entity's optimistic-lock version, which is what makes an update
|
|
11
|
+
* payload require it. No conventional name, unlike {@link idKey}: a field merely called `version`
|
|
12
|
+
* must not start demanding one.
|
|
13
|
+
*/
|
|
14
|
+
export declare const versionKey: unique symbol;
|
|
9
15
|
/** The filter `@Field({ softDelete })` registers, a name reserved against an entity's own filters. */
|
|
10
16
|
export declare const SOFT_DELETE_FILTER = "softDelete";
|
|
11
17
|
/** A filter name an entity may declare: any but {@link SOFT_DELETE_FILTER}, which a refusal names. */
|
|
@@ -30,8 +36,20 @@ export type WritableKey<E> = {
|
|
|
30
36
|
}[FieldKey<E>];
|
|
31
37
|
/** A whole-record write as a caller supplies one: {@link EntityData} without the fields it cannot write. */
|
|
32
38
|
export type EntityWrite<E> = EntityData<E, WritableKey<E>>;
|
|
33
|
-
/**
|
|
34
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The property an entity brands with {@link versionKey} as its optimistic lock, `never` where it
|
|
41
|
+
* brands none. The brand is what carries `@Field({ version: true })` to the type level, since a
|
|
42
|
+
* decorator's options never reach `E`.
|
|
43
|
+
*/
|
|
44
|
+
export type VersionKey<E> = E extends {
|
|
45
|
+
[versionKey]?: infer K;
|
|
46
|
+
} ? K & FieldKey<E> : never;
|
|
47
|
+
/**
|
|
48
|
+
* A partial write as a caller supplies one: {@link UpdatePayload} without the fields it cannot write,
|
|
49
|
+
* and with the version where the entity keeps one - a write that cannot say which row state it read
|
|
50
|
+
* is refused here rather than silently overwriting whatever is there now.
|
|
51
|
+
*/
|
|
52
|
+
export type UpdateWrite<E, Raw = QueryRaw> = UpdatePayload<E, Raw, WritableKey<E>> & Required<Pick<E, VersionKey<E>>>;
|
|
35
53
|
/** The relation names of an entity: every key but its fields and its methods, so the two sets cannot drift. */
|
|
36
54
|
export type RelationKey<E> = Exclude<Key<E>, FieldKey<E> | MethodKey<E>>;
|
|
37
55
|
/**
|
|
@@ -247,6 +265,12 @@ export type FieldOptions<V = TsTypeOf<FieldType>, E = unknown> = {
|
|
|
247
265
|
* stamps `new Date()`, anything else is the value or callback stamped, `softDelete: () => Date.now()`.
|
|
248
266
|
*/
|
|
249
267
|
readonly softDelete?: true | OnFieldCallback<V>;
|
|
268
|
+
/**
|
|
269
|
+
* Makes the column an optimistic lock: an update matches the version its payload carries and writes
|
|
270
|
+
* the next one, so a write against a row someone else moved on throws instead of overwriting it. The
|
|
271
|
+
* column is `NOT NULL DEFAULT 0`, and the entity brands the property with {@link versionKey}.
|
|
272
|
+
*/
|
|
273
|
+
readonly version?: true;
|
|
250
274
|
/** The SQL type, where it differs from the one `type` implies: `type: String, columnType: 'decimal'`. */
|
|
251
275
|
readonly columnType?: ColumnType;
|
|
252
276
|
/** A string column's length. */
|
|
@@ -643,6 +667,8 @@ export type EntityMeta<E> = {
|
|
|
643
667
|
/** Every column of the primary key, in declaration order. */
|
|
644
668
|
ids: readonly IdKey<E>[];
|
|
645
669
|
softDelete?: FieldKey<E>;
|
|
670
|
+
/** The optimistic lock's column, from `@Field({ version: true })`. */
|
|
671
|
+
version?: FieldKey<E>;
|
|
646
672
|
/** Named, default-on `$where` filters applied to every query unless bypassed. */
|
|
647
673
|
filters?: Record<string, FilterOptions<E>>;
|
|
648
674
|
fields: {
|
|
@@ -729,7 +755,10 @@ export type IndexOptions = Except<EntityIndexMeta, 'columns' | 'where'> & {
|
|
|
729
755
|
*/
|
|
730
756
|
export type EntityIndexOptions<E> = Except<IndexOptions, 'include' | 'where'> & {
|
|
731
757
|
readonly include?: (refs: RefMap<E>) => readonly ColumnRef<FieldKey<E>>[];
|
|
732
|
-
/**
|
|
758
|
+
/**
|
|
759
|
+
* Partial-index predicate, written as the predicate the query passes: a planner matches the two by
|
|
760
|
+
* shape, so a hand-written `raw` that means the same thing leaves the index unused. See {@link EntityWhere}.
|
|
761
|
+
*/
|
|
733
762
|
readonly where?: EntityWhere<E>;
|
|
734
763
|
};
|
|
735
764
|
/**
|
package/dist/type/entity.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Brands the property an entity is identified by, where it is not `id`, `_id` or `uuid`. */
|
|
2
2
|
export const idKey = Symbol('idKey');
|
|
3
|
+
/**
|
|
4
|
+
* Brands the property holding an entity's optimistic-lock version, which is what makes an update
|
|
5
|
+
* payload require it. No conventional name, unlike {@link idKey}: a field merely called `version`
|
|
6
|
+
* must not start demanding one.
|
|
7
|
+
*/
|
|
8
|
+
export const versionKey = Symbol('versionKey');
|
|
3
9
|
/** The filter `@Field({ softDelete })` registers, a name reserved against an entity's own filters. */
|
|
4
10
|
export const SOFT_DELETE_FILTER = 'softDelete';
|
|
5
11
|
/** Every SQL column type a field may declare, by family: the unions below and `columnFamily` both read it. */
|
package/dist/type/query.d.ts
CHANGED
|
@@ -115,9 +115,16 @@ export type FilterOptions<E = unknown> = {
|
|
|
115
115
|
readonly onMissing?: 'throw';
|
|
116
116
|
});
|
|
117
117
|
/**
|
|
118
|
-
* direction for the sort.
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
* direction for the sort, and where nulls land in it.
|
|
119
|
+
*
|
|
120
|
+
* Unqualified, each engine has its own answer - Postgres and CockroachDB sort nulls last on `asc`, the
|
|
121
|
+
* rest sort them first - so a placement is the only portable one. Engines with no `NULLS FIRST/LAST`
|
|
122
|
+
* emulate it with a leading term, which no index can serve, which is why it is asked for and never
|
|
123
|
+
* applied by default.
|
|
124
|
+
*/
|
|
125
|
+
export type QuerySortDirection = -1 | 1 | 'asc' | 'desc' | QuerySortNullsDirection;
|
|
126
|
+
/** A {@link QuerySortDirection} stating where nulls land. */
|
|
127
|
+
export type QuerySortNullsDirection = 'ascNullsFirst' | 'ascNullsLast' | 'descNullsFirst' | 'descNullsLast';
|
|
121
128
|
/**
|
|
122
129
|
* Accepted value for a field in `$sort` - either a direction or a vector similarity search.
|
|
123
130
|
*/
|
|
@@ -69,7 +69,9 @@ export function getSoftDeleteValue(field) {
|
|
|
69
69
|
/** Fills each field `callbackKey` generates on `payload` in place, where the caller left it unset. */
|
|
70
70
|
export function fillOnFields(meta, payload, callbackKey) {
|
|
71
71
|
const payloads = Array.isArray(payload) ? payload : [payload];
|
|
72
|
-
|
|
72
|
+
// By presence, not truthiness, as `addInsertFieldKeys` above reads it: `onInsert: 0` and `onInsert: ''`
|
|
73
|
+
// are values a caller meant, and a falsy one was silently never filled.
|
|
74
|
+
const keys = getKeys(meta.fields).filter((key) => meta.fields[key][callbackKey] !== undefined);
|
|
73
75
|
if (keys.length === 0) {
|
|
74
76
|
return payloads;
|
|
75
77
|
}
|
|
@@ -20,6 +20,7 @@ declare const FIELD_OPTION_FAMILY: {
|
|
|
20
20
|
readonly onInsert: '*';
|
|
21
21
|
readonly onUpdate: '*';
|
|
22
22
|
readonly softDelete: '*';
|
|
23
|
+
readonly version: 'numeric';
|
|
23
24
|
readonly columnType: '*';
|
|
24
25
|
readonly length: 'string';
|
|
25
26
|
readonly precision: 'numeric';
|
|
@@ -44,8 +45,15 @@ type InlineRead = (typeof INLINE_READS)[number];
|
|
|
44
45
|
* DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
|
|
45
46
|
* `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
|
|
46
47
|
*/
|
|
47
|
-
declare const GENERATED_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement"];
|
|
48
|
+
declare const GENERATED_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement", "version"];
|
|
48
49
|
type GeneratedWrite = (typeof GENERATED_WRITES)[number];
|
|
50
|
+
/**
|
|
51
|
+
* What an optimistic lock cannot use: the querier writes the column on every update and matches the
|
|
52
|
+
* value the payload carried, so anything else deciding it would be fighting that, and the three that
|
|
53
|
+
* would make it another kind of column entirely. Its `nullable: false` and `DEFAULT 0` are implied.
|
|
54
|
+
*/
|
|
55
|
+
declare const VERSION_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement", "computed", "stored", "isId"];
|
|
56
|
+
type VersionWrite = (typeof VERSION_WRITES)[number];
|
|
49
57
|
/**
|
|
50
58
|
* The first option `opts` cannot use, phrased as the tail of `'Entity.field' ...`, or `undefined`
|
|
51
59
|
* where every option applies. The runtime half of the decorators' check, so the imperative API and
|
|
@@ -68,7 +76,12 @@ type DeadOptions<O> = (O extends {
|
|
|
68
76
|
readonly nullable: true;
|
|
69
77
|
} ? 'nullable' : never) | (O extends {
|
|
70
78
|
readonly updatable: false;
|
|
71
|
-
} ? 'onUpdate' : never)
|
|
79
|
+
} ? 'onUpdate' : never) | (O extends {
|
|
80
|
+
readonly version: true;
|
|
81
|
+
} ? VersionWrite : never) | (O extends {
|
|
82
|
+
readonly version: true;
|
|
83
|
+
readonly nullable: true;
|
|
84
|
+
} ? 'nullable' : never);
|
|
72
85
|
type Given<O> = Extract<keyof O, keyof FieldOptions>;
|
|
73
86
|
type Offending<O> = {
|
|
74
87
|
[K in Given<O>]: (typeof FIELD_OPTION_FAMILY)[K] extends OptionsFamily<O> | '*' ? K extends DeadOptions<O> ? K : never : K;
|
|
@@ -21,6 +21,7 @@ const FIELD_OPTION_FAMILY = {
|
|
|
21
21
|
onInsert: '*',
|
|
22
22
|
onUpdate: '*',
|
|
23
23
|
softDelete: '*',
|
|
24
|
+
version: 'numeric',
|
|
24
25
|
columnType: '*',
|
|
25
26
|
length: 'string',
|
|
26
27
|
precision: 'numeric',
|
|
@@ -46,12 +47,8 @@ const INLINE_READS = [
|
|
|
46
47
|
'eager',
|
|
47
48
|
'distance',
|
|
48
49
|
];
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
* DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
|
|
52
|
-
* `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
|
|
53
|
-
*/
|
|
54
|
-
const GENERATED_WRITES = [
|
|
50
|
+
/** Every option that decides what a column holds, or whether it is written at all. */
|
|
51
|
+
const VALUE_DECIDERS = [
|
|
55
52
|
'updatable',
|
|
56
53
|
'onInsert',
|
|
57
54
|
'onUpdate',
|
|
@@ -60,19 +57,37 @@ const GENERATED_WRITES = [
|
|
|
60
57
|
'autoIncrement',
|
|
61
58
|
];
|
|
62
59
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
60
|
+
* What a column the *database* writes cannot use. A stored computed column is a real column - it has
|
|
61
|
+
* DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
|
|
62
|
+
* `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
|
|
63
|
+
*/
|
|
64
|
+
const GENERATED_WRITES = [...VALUE_DECIDERS, 'version'];
|
|
65
|
+
/**
|
|
66
|
+
* What an optimistic lock cannot use: the querier writes the column on every update and matches the
|
|
67
|
+
* value the payload carried, so anything else deciding it would be fighting that, and the three that
|
|
68
|
+
* would make it another kind of column entirely. Its `nullable: false` and `DEFAULT 0` are implied.
|
|
66
69
|
*/
|
|
70
|
+
const VERSION_WRITES = [...VALUE_DECIDERS, 'computed', 'stored', 'isId'];
|
|
71
|
+
/**
|
|
72
|
+
* Whether `key` is the `nullable: true` a NOT NULL column contradicts. `nullable: false` says what
|
|
73
|
+
* such a column already is, and rejecting an accurate statement teaches an author to distrust the check.
|
|
74
|
+
*/
|
|
75
|
+
function contradictsNotNull(opts, key) {
|
|
76
|
+
return key === 'nullable' && opts.nullable === true;
|
|
77
|
+
}
|
|
78
|
+
/** Whatever leaves `key` unread, named for the message, or `undefined` where the field reads it. */
|
|
67
79
|
function deadOn(opts, key) {
|
|
68
80
|
if (isInlinedExpression(opts) && !INLINE_READS.some((read) => read === key))
|
|
69
81
|
return 'an inlined computed field';
|
|
70
82
|
if (opts.stored === true && GENERATED_WRITES.some((write) => write === key))
|
|
71
83
|
return 'a stored computed column';
|
|
72
|
-
if (opts.isId === true &&
|
|
84
|
+
if (opts.isId === true && contradictsNotNull(opts, key))
|
|
73
85
|
return 'a primary key';
|
|
74
86
|
if (opts.updatable === false && key === 'onUpdate')
|
|
75
87
|
return "a field declared 'updatable: false'";
|
|
88
|
+
if (opts.version === true && (VERSION_WRITES.some((write) => write === key) || contradictsNotNull(opts, key))) {
|
|
89
|
+
return 'a version field';
|
|
90
|
+
}
|
|
76
91
|
return undefined;
|
|
77
92
|
}
|
|
78
93
|
/**
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"homepage": "https://uql-orm.dev",
|
|
4
4
|
"description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, SQL Server, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.76.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24"
|
package/skills/uql-orm/SKILL.md
CHANGED
|
@@ -74,12 +74,17 @@ export class Post {
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
- Every `@Field` states its `type` (`String`, `Number`, `Boolean`, `Date`, `BigInt`, or a column type such as
|
|
78
|
-
|
|
79
|
-
- A column is nullable unless it says `nullable: false`, and its property must admit `null` to match:
|
|
80
|
-
`title?: string | null`. A property typed without `| null` on a nullable column is a compile error.
|
|
77
|
+
- Every `@Field` states its `type` (`String`, `Number`, `Boolean`, `Date`, `BigInt`, or a column type such as `'uuid'`, `'text'`, `'jsonb'`), except a foreign key, which takes `references` and inherits the target key's type.
|
|
78
|
+
- A column is nullable unless it says `nullable: false`, and its property must admit `null` to match: `title?: string | null`. A property typed without `| null` on a nullable column is a compile error.
|
|
81
79
|
- Members are named by callbacks, never by strings: `mappedBy: (post) => post.author`, `references: (post) => post.authorId`.
|
|
82
80
|
- `@ManyToMany({ entity: () => Tag, through: () => PostTag })` names its junction entity.
|
|
81
|
+
- `@Index((post) => [post.authorId], { where: { archived: { $ne: true } } })` states a partial index's filter as the predicate the query passes, never as `raw`: a planner matches the two by shape, so `raw` that means the same thing leaves the index unused.
|
|
82
|
+
- `@Field({ type: Number, version: true })`, with `[versionKey]?: 'version'` on the class, makes the column an
|
|
83
|
+
optimistic lock: every update payload must carry the version it read (a compile error otherwise), the update
|
|
84
|
+
matches on it and writes the next one, and a write against a row someone else moved on throws
|
|
85
|
+
`UqlOptimisticLockError` (`status` 409) instead of overwriting it. Save, upsert and `restoreMany` are refused on such
|
|
86
|
+
an entity (restore with `updateOneById`, `{ filters: { softDelete: false } }`); `updateMany` writes only the rows
|
|
87
|
+
still at the version it carries, and delete needs none.
|
|
83
88
|
- `defineEntity` defines the same entity without decorators: https://uql-orm.dev/entities/imperative.md
|
|
84
89
|
|
|
85
90
|
## Queries
|
|
@@ -100,6 +105,10 @@ const users = await pool.findMany(User, {
|
|
|
100
105
|
|
|
101
106
|
- The keys are `$select`, `$exclude`, `$where`, `$populate`, `$count`, `$distinct`, `$sort`, `$skip`, `$limit`;
|
|
102
107
|
`$count: { posts: true }` tallies a to-many under `_count` without loading it.
|
|
108
|
+
- `$sort` takes `'asc'`/`1` or `'desc'`/`-1`, and `'ascNullsLast'`, `'ascNullsFirst'`, `'descNullsFirst'` or
|
|
109
|
+
`'descNullsLast'` to say where nulls land, which reads the same on every engine (emulated where there is no
|
|
110
|
+
`NULLS FIRST`). Unqualified, each engine keeps its own answer: Postgres and CockroachDB sort nulls last on `asc`,
|
|
111
|
+
the rest sort them first.
|
|
103
112
|
- `$where` takes a value for equality or an operator map: `$eq`, `$ne`, `$lt`, `$lte`, `$gt`, `$gte`, `$in`,
|
|
104
113
|
`$nin`, `$between`, `$like`, `$ilike`, `$regex`, `$startsWith`, `$endsWith`, `$includes`, `$isNull`,
|
|
105
114
|
`$isNotNull`. `$and`, `$or`, `$not` and `$nor` combine clauses.
|