turbine-orm 0.81.0 → 0.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -25
- package/dist/checkout.d.ts +27 -2
- package/dist/checkout.js +75 -4
- package/dist/cjs/checkout.d.ts +27 -2
- package/dist/cjs/checkout.js +76 -3
- package/dist/cjs/cli/code-first-tags.d.ts +62 -0
- package/dist/cjs/cli/code-first-tags.js +124 -0
- package/dist/cjs/cli/compile-query.js +9 -0
- package/dist/cjs/cli/config.d.ts +35 -6
- package/dist/cjs/cli/config.js +17 -1
- package/dist/cjs/cli/destructive.d.ts +7 -4
- package/dist/cjs/cli/destructive.js +224 -20
- package/dist/cjs/cli/error-catalog.js +36 -0
- package/dist/cjs/cli/error-text.d.ts +15 -0
- package/dist/cjs/cli/error-text.js +40 -0
- package/dist/cjs/cli/index.d.ts +62 -4
- package/dist/cjs/cli/index.js +568 -108
- package/dist/cjs/cli/local-server-guard.d.ts +53 -0
- package/dist/cjs/cli/local-server-guard.js +119 -0
- package/dist/cjs/cli/local-server-output.d.ts +41 -0
- package/dist/cjs/cli/local-server-output.js +103 -0
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +121 -32
- package/dist/cjs/cli/migrate.d.ts +113 -5
- package/dist/cjs/cli/migrate.js +270 -21
- package/dist/cjs/cli/observe.d.ts +6 -1
- package/dist/cjs/cli/observe.js +85 -55
- package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cjs/cli/pii-predicate-guard.js +98 -37
- package/dist/cjs/cli/prisma-resolve.js +17 -13
- package/dist/cjs/cli/prisma-schema.js +12 -4
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +14 -2
- package/dist/cjs/cli/studio.js +244 -69
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +558 -85
- package/dist/cjs/client.js +1270 -395
- package/dist/cjs/connection-guard.d.ts +13 -0
- package/dist/cjs/connection-guard.js +28 -0
- package/dist/cjs/connection-url.d.ts +51 -16
- package/dist/cjs/connection-url.js +67 -34
- package/dist/cjs/diagnostics.d.ts +81 -0
- package/dist/cjs/diagnostics.js +147 -0
- package/dist/cjs/dialect.d.ts +449 -20
- package/dist/cjs/dialect.js +117 -76
- package/dist/cjs/engine-config.d.ts +7 -6
- package/dist/cjs/errors.d.ts +126 -6
- package/dist/cjs/errors.js +256 -13
- package/dist/cjs/generate.d.ts +57 -7
- package/dist/cjs/generate.js +635 -279
- package/dist/cjs/global-filters-config.d.ts +59 -0
- package/dist/cjs/global-filters-config.js +367 -0
- package/dist/cjs/index.d.ts +9 -4
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.d.ts +31 -6
- package/dist/cjs/introspect.js +108 -50
- package/dist/cjs/log-codes.d.ts +12 -0
- package/dist/cjs/log-codes.js +77 -0
- package/dist/cjs/logger.d.ts +87 -0
- package/dist/cjs/logger.js +98 -0
- package/dist/cjs/mssql.d.ts +8 -5
- package/dist/cjs/mssql.js +352 -65
- package/dist/cjs/mysql.d.ts +3 -5
- package/dist/cjs/mysql.js +420 -70
- package/dist/cjs/nested-write.d.ts +3 -0
- package/dist/cjs/nested-write.js +179 -114
- package/dist/cjs/pipeline-submittable.d.ts +12 -0
- package/dist/cjs/pipeline-submittable.js +118 -44
- package/dist/cjs/pipeline.js +21 -26
- package/dist/cjs/powdb-introspect.d.ts +5 -2
- package/dist/cjs/powdb-introspect.js +20 -15
- package/dist/cjs/powdb-shared.d.ts +36 -5
- package/dist/cjs/powdb-shared.js +67 -8
- package/dist/cjs/powdb.d.ts +4 -0
- package/dist/cjs/powdb.js +183 -51
- package/dist/cjs/powql.d.ts +394 -51
- package/dist/cjs/powql.js +1829 -284
- package/dist/cjs/prisma-compat.d.ts +23 -15
- package/dist/cjs/prisma-compat.js +507 -372
- package/dist/cjs/query/aggregates.d.ts +17 -1
- package/dist/cjs/query/aggregates.js +261 -29
- package/dist/cjs/query/batched-loader.d.ts +25 -6
- package/dist/cjs/query/batched-loader.js +103 -20
- package/dist/cjs/query/builder.d.ts +325 -184
- package/dist/cjs/query/builder.js +1452 -490
- package/dist/cjs/query/column-ref.d.ts +61 -0
- package/dist/cjs/query/column-ref.js +73 -0
- package/dist/cjs/query/compound-unique.d.ts +21 -0
- package/dist/cjs/query/compound-unique.js +98 -18
- package/dist/cjs/query/deferred.d.ts +53 -16
- package/dist/cjs/query/expr.d.ts +217 -0
- package/dist/cjs/query/expr.js +859 -0
- package/dist/cjs/query/filters.d.ts +155 -53
- package/dist/cjs/query/filters.js +613 -76
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/json-value.d.ts +43 -0
- package/dist/cjs/query/json-value.js +59 -0
- package/dist/cjs/query/normalize-args.d.ts +87 -0
- package/dist/cjs/query/normalize-args.js +527 -0
- package/dist/cjs/query/option-surface.d.ts +16 -4
- package/dist/cjs/query/option-surface.js +57 -14
- package/dist/cjs/query/relation-names.d.ts +14 -0
- package/dist/cjs/query/relation-names.js +77 -18
- package/dist/cjs/query/relations.d.ts +30 -3
- package/dist/cjs/query/relations.js +219 -63
- package/dist/cjs/query/row-lock.d.ts +49 -0
- package/dist/cjs/query/row-lock.js +138 -0
- package/dist/cjs/query/sql-comment.d.ts +161 -0
- package/dist/cjs/query/sql-comment.js +381 -0
- package/dist/cjs/query/stream-idle.d.ts +132 -0
- package/dist/cjs/query/stream-idle.js +212 -0
- package/dist/cjs/query/table-client.d.ts +101 -0
- package/dist/cjs/query/table-client.js +74 -0
- package/dist/cjs/query/types.d.ts +677 -298
- package/dist/cjs/query/utils.d.ts +105 -6
- package/dist/cjs/query/utils.js +426 -19
- package/dist/cjs/query/warn-registry.d.ts +18 -0
- package/dist/cjs/query/warn-registry.js +18 -0
- package/dist/cjs/query/where-compile.d.ts +138 -14
- package/dist/cjs/query/where-compile.js +513 -29
- package/dist/cjs/query/where.d.ts +159 -59
- package/dist/cjs/query/where.js +716 -246
- package/dist/cjs/query/writes.d.ts +112 -50
- package/dist/cjs/query/writes.js +1498 -200
- package/dist/cjs/query-events.d.ts +4 -2
- package/dist/cjs/query-events.js +3 -1
- package/dist/cjs/realtime.d.ts +7 -3
- package/dist/cjs/realtime.js +13 -2
- package/dist/cjs/schema-builder.d.ts +55 -4
- package/dist/cjs/schema-builder.js +382 -18
- package/dist/cjs/schema-metadata.d.ts +21 -1
- package/dist/cjs/schema-metadata.js +29 -7
- package/dist/cjs/schema-sql.d.ts +75 -5
- package/dist/cjs/schema-sql.js +497 -45
- package/dist/cjs/schema.d.ts +26 -5
- package/dist/cjs/schema.js +110 -13
- package/dist/cjs/sql-placeholders.d.ts +56 -0
- package/dist/cjs/sql-placeholders.js +204 -0
- package/dist/cjs/sqlite.d.ts +75 -20
- package/dist/cjs/sqlite.js +593 -95
- package/dist/cjs/supplied-pool-types.d.ts +48 -0
- package/dist/cjs/supplied-pool-types.js +256 -0
- package/dist/cjs/transaction-control.d.ts +51 -0
- package/dist/cjs/transaction-control.js +316 -0
- package/dist/cjs/transaction-state.d.ts +342 -0
- package/dist/cjs/transaction-state.js +707 -0
- package/dist/cjs/typed-sql.d.ts +37 -3
- package/dist/cjs/typed-sql.js +102 -8
- package/dist/cli/code-first-tags.d.ts +62 -0
- package/dist/cli/code-first-tags.js +120 -0
- package/dist/cli/compile-query.js +9 -0
- package/dist/cli/config.d.ts +35 -6
- package/dist/cli/config.js +16 -1
- package/dist/cli/destructive.d.ts +7 -4
- package/dist/cli/destructive.js +224 -20
- package/dist/cli/error-catalog.js +36 -0
- package/dist/cli/error-text.d.ts +15 -0
- package/dist/cli/error-text.js +37 -0
- package/dist/cli/index.d.ts +62 -4
- package/dist/cli/index.js +569 -112
- package/dist/cli/local-server-guard.d.ts +53 -0
- package/dist/cli/local-server-guard.js +113 -0
- package/dist/cli/local-server-output.d.ts +41 -0
- package/dist/cli/local-server-output.js +98 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +121 -34
- package/dist/cli/migrate.d.ts +113 -5
- package/dist/cli/migrate.js +261 -21
- package/dist/cli/observe.d.ts +6 -1
- package/dist/cli/observe.js +84 -54
- package/dist/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cli/pii-predicate-guard.js +100 -35
- package/dist/cli/prisma-resolve.js +15 -11
- package/dist/cli/prisma-schema.js +12 -4
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +14 -2
- package/dist/cli/studio.js +246 -71
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +558 -85
- package/dist/client.js +1275 -400
- package/dist/connection-guard.d.ts +13 -0
- package/dist/connection-guard.js +27 -0
- package/dist/connection-url.d.ts +51 -16
- package/dist/connection-url.js +63 -34
- package/dist/diagnostics.d.ts +81 -0
- package/dist/diagnostics.js +144 -0
- package/dist/dialect.d.ts +449 -20
- package/dist/dialect.js +115 -43
- package/dist/engine-config.d.ts +7 -6
- package/dist/errors.d.ts +126 -6
- package/dist/errors.js +252 -12
- package/dist/generate.d.ts +57 -7
- package/dist/generate.js +635 -281
- package/dist/global-filters-config.d.ts +59 -0
- package/dist/global-filters-config.js +361 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/introspect.d.ts +31 -6
- package/dist/introspect.js +106 -50
- package/dist/log-codes.d.ts +12 -0
- package/dist/log-codes.js +74 -0
- package/dist/logger.d.ts +87 -0
- package/dist/logger.js +93 -0
- package/dist/mssql.d.ts +8 -5
- package/dist/mssql.js +354 -67
- package/dist/mysql.d.ts +3 -5
- package/dist/mysql.js +423 -73
- package/dist/nested-write.d.ts +3 -0
- package/dist/nested-write.js +177 -112
- package/dist/pipeline-submittable.d.ts +12 -0
- package/dist/pipeline-submittable.js +117 -44
- package/dist/pipeline.js +21 -26
- package/dist/powdb-introspect.d.ts +5 -2
- package/dist/powdb-introspect.js +21 -16
- package/dist/powdb-shared.d.ts +36 -5
- package/dist/powdb-shared.js +65 -8
- package/dist/powdb.d.ts +4 -0
- package/dist/powdb.js +183 -51
- package/dist/powql.d.ts +394 -51
- package/dist/powql.js +1836 -291
- package/dist/prisma-compat.d.ts +23 -15
- package/dist/prisma-compat.js +508 -373
- package/dist/query/aggregates.d.ts +17 -1
- package/dist/query/aggregates.js +263 -32
- package/dist/query/batched-loader.d.ts +25 -6
- package/dist/query/batched-loader.js +101 -21
- package/dist/query/builder.d.ts +325 -184
- package/dist/query/builder.js +1458 -495
- package/dist/query/column-ref.d.ts +61 -0
- package/dist/query/column-ref.js +66 -0
- package/dist/query/compound-unique.d.ts +21 -0
- package/dist/query/compound-unique.js +97 -19
- package/dist/query/deferred.d.ts +53 -16
- package/dist/query/expr.d.ts +217 -0
- package/dist/query/expr.js +848 -0
- package/dist/query/filters.d.ts +155 -53
- package/dist/query/filters.js +597 -72
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +4 -0
- package/dist/query/json-value.d.ts +43 -0
- package/dist/query/json-value.js +54 -0
- package/dist/query/normalize-args.d.ts +87 -0
- package/dist/query/normalize-args.js +517 -0
- package/dist/query/option-surface.d.ts +16 -4
- package/dist/query/option-surface.js +56 -13
- package/dist/query/relation-names.d.ts +14 -0
- package/dist/query/relation-names.js +77 -19
- package/dist/query/relations.d.ts +30 -3
- package/dist/query/relations.js +220 -67
- package/dist/query/row-lock.d.ts +49 -0
- package/dist/query/row-lock.js +131 -0
- package/dist/query/sql-comment.d.ts +161 -0
- package/dist/query/sql-comment.js +374 -0
- package/dist/query/stream-idle.d.ts +132 -0
- package/dist/query/stream-idle.js +203 -0
- package/dist/query/table-client.d.ts +101 -0
- package/dist/query/table-client.js +70 -0
- package/dist/query/types.d.ts +677 -298
- package/dist/query/utils.d.ts +105 -6
- package/dist/query/utils.js +414 -18
- package/dist/query/warn-registry.d.ts +18 -0
- package/dist/query/warn-registry.js +18 -0
- package/dist/query/where-compile.d.ts +138 -14
- package/dist/query/where-compile.js +508 -31
- package/dist/query/where.d.ts +159 -59
- package/dist/query/where.js +711 -248
- package/dist/query/writes.d.ts +112 -50
- package/dist/query/writes.js +1496 -202
- package/dist/query-events.d.ts +4 -2
- package/dist/query-events.js +3 -1
- package/dist/realtime.d.ts +7 -3
- package/dist/realtime.js +13 -2
- package/dist/schema-builder.d.ts +55 -4
- package/dist/schema-builder.js +381 -18
- package/dist/schema-metadata.d.ts +21 -1
- package/dist/schema-metadata.js +28 -7
- package/dist/schema-sql.d.ts +75 -5
- package/dist/schema-sql.js +494 -44
- package/dist/schema.d.ts +26 -5
- package/dist/schema.js +109 -13
- package/dist/sql-placeholders.d.ts +56 -0
- package/dist/sql-placeholders.js +200 -0
- package/dist/sqlite.d.ts +75 -20
- package/dist/sqlite.js +597 -99
- package/dist/supplied-pool-types.d.ts +48 -0
- package/dist/supplied-pool-types.js +247 -0
- package/dist/transaction-control.d.ts +51 -0
- package/dist/transaction-control.js +311 -0
- package/dist/transaction-state.d.ts +342 -0
- package/dist/transaction-state.js +696 -0
- package/dist/typed-sql.d.ts +37 -3
- package/dist/typed-sql.js +100 -8
- package/package.json +7 -4
- package/skills/turbine-orm/SKILL.md +56 -6
package/dist/powql.js
CHANGED
|
@@ -36,20 +36,32 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { randomUUID } from 'node:crypto';
|
|
38
38
|
import { guardCheckout } from './connection-guard.js';
|
|
39
|
-
import {
|
|
39
|
+
import { resolveDiagnostics } from './diagnostics.js';
|
|
40
|
+
import { InvariantError, NotFoundError, OptimisticLockError, ReadOnlyError, RelationError, TimeoutError, UnsupportedFeatureError, ValidationError, } from './errors.js';
|
|
41
|
+
import { consoleLogger, logTo } from './logger.js';
|
|
40
42
|
import { executeNestedCreate, executeNestedUpdate, extractRelationFields, hasRelationFields, } from './nested-write.js';
|
|
41
|
-
import { ALL_POWDB_CAPABILITIES, atLeastVersion, baseTsType, coerceNativeValue, isJsonColumn, isPowdbDatetimeColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, parsePowdbSemver, powqlColumnType, quotePowqlDotted, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb-shared.js';
|
|
42
|
-
import { assertAggregatePiiOptIn } from './query/aggregates.js';
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
43
|
+
import { ALL_POWDB_CAPABILITIES, atLeastVersion, baseTsType, coerceNativeValue, coerceValue, isDateColumn, isJsonColumn, isPowdbDatetimeColumn, isPowdbDecimalTextColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, parsePowdbSemver, powdbEnumLabels, powqlColumnType, quotePowqlDotted, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb-shared.js';
|
|
44
|
+
import { assertAggregatePiiOptIn, assertGroupByKeys } from './query/aggregates.js';
|
|
45
|
+
import { markStitchOnly, resolveCountRelations, stitchOnlyFields } from './query/batched-loader.js';
|
|
46
|
+
import { unlockNestedWriteTx } from './query/builder.js';
|
|
47
|
+
import { isColumnRef } from './query/column-ref.js';
|
|
48
|
+
import { assertMutationWhereIdentifiesOneRow, assertWhereIdentifiesOneRow, expandCompoundUniqueWhere, uniqueKeyNames, upsertWhereMatchesCreate, whereIdentifiesOneRow, } from './query/compound-unique.js';
|
|
49
|
+
import { exprNotAllowedMessage, isSqlExpr } from './query/expr.js';
|
|
50
|
+
import { ARRAY_OPERATOR_KEYS, assertJsonNotOperand, assertOperatorOperands, cursorFieldValue, dedupeColumnList, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isUnmatchedPlainObject, isVectorOrderBy, normalizeOrderBy, operandColumnKind, orderByEntries, UPDATE_OPERATOR_KEYS, } from './query/filters.js';
|
|
51
|
+
import { isJsonValue } from './query/json-value.js';
|
|
52
|
+
import { assertCursorNotEmptied, assertCursorWritten, assertWhereNotEmptiedByPruning, hasCursor, pruneUndefinedArgs, } from './query/normalize-args.js';
|
|
45
53
|
import { warnUnknownQueryOptions } from './query/option-surface.js';
|
|
46
|
-
import { applyStableRelationOrderTo, normalizeWithClause } from './query/relation-names.js';
|
|
54
|
+
import { applyStableRelationOrderTo, normalizeWithClause, normalizeWithPagination } from './query/relation-names.js';
|
|
55
|
+
import { piiSelectRefusedMessage } from './query/relations.js';
|
|
47
56
|
// The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
|
|
48
57
|
// are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
|
|
49
58
|
// engines, so a spread request body cannot turn either on here either.
|
|
50
59
|
import { assertDirectionToken, resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './query/types.js';
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
60
|
+
import { assertMiddlewareArgsKept, availableClause, normalizePagination, ownLookup, readOnlyMiddlewareParams, relationInProjectionMessage, resolveColumnName, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
|
|
61
|
+
import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
|
|
62
|
+
import { assertJsonFilterKeys, emptyMutationWhereMessage, jsonStringEntries, normalizeRelationFilter, resolveGlobalFilterFrom, } from './query/where.js';
|
|
63
|
+
import { combinatorBranches, foldWhereFor, relationCardinalityProblem, relationFilterValueProblem, } from './query/where-compile.js';
|
|
64
|
+
import { assertNoBulkWritePaging, assertNoOptimisticLock } from './query/writes.js';
|
|
53
65
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
54
66
|
/**
|
|
55
67
|
* Max parent keys per relation-loader `in (…)` query. A `with` over a large
|
|
@@ -58,6 +70,8 @@ import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
|
58
70
|
* before grouping. Mirrors the chunking the parity matrix documents.
|
|
59
71
|
*/
|
|
60
72
|
const MAX_RELATION_KEYS = 1000;
|
|
73
|
+
/** A predicate that holds for every row (an empty `notIn`, an `OR` with an empty branch). */
|
|
74
|
+
const ALWAYS_TRUE = '(1 = 1)';
|
|
61
75
|
/**
|
|
62
76
|
* Max values one `in` / `notIn` on a PowDB-native `datetime` column may carry.
|
|
63
77
|
*
|
|
@@ -142,6 +156,71 @@ const POWQL_HAVING_AGG_FNS = new Map([
|
|
|
142
156
|
['_max', 'max'],
|
|
143
157
|
['_count', 'count'],
|
|
144
158
|
]);
|
|
159
|
+
/**
|
|
160
|
+
* Max distinct keys one client-side relation filter may resolve to.
|
|
161
|
+
*
|
|
162
|
+
* A relation filter becomes a literal `in (…)` list on the local key, and the
|
|
163
|
+
* engine evaluates that list per row, so the cost grows with rows times keys
|
|
164
|
+
* (measured on the 0.20 addon over 100k rows: about 0.3 s per 1,000 keys).
|
|
165
|
+
* Past this bound the filter is refused with a typed error naming the bound,
|
|
166
|
+
* instead of building a statement whose cost has no ceiling.
|
|
167
|
+
*/
|
|
168
|
+
export const MAX_RELATION_FILTER_KEYS = 10_000;
|
|
169
|
+
/** The relation-filter keys the SQL engines accept, in their compile order. */
|
|
170
|
+
const RELATION_FILTER_KEYS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
171
|
+
/**
|
|
172
|
+
* True for a branded column reference (`col('x')`). PowQL has no column-to-column
|
|
173
|
+
* operand form in a filter, so a reference is refused rather than bound as data.
|
|
174
|
+
* The brand is read by its registry key so this needs no import of the module
|
|
175
|
+
* that creates it.
|
|
176
|
+
*/
|
|
177
|
+
function isColumnReference(value) {
|
|
178
|
+
return typeof value === 'object' && value !== null && Symbol.for('turbine-orm.columnRef') in value;
|
|
179
|
+
}
|
|
180
|
+
/** A value a non-JSON column can be compared against. */
|
|
181
|
+
function isScalarOperand(value) {
|
|
182
|
+
const t = typeof value;
|
|
183
|
+
return (t === 'string' ||
|
|
184
|
+
t === 'number' ||
|
|
185
|
+
t === 'bigint' ||
|
|
186
|
+
t === 'boolean' ||
|
|
187
|
+
value instanceof Date ||
|
|
188
|
+
value instanceof Uint8Array);
|
|
189
|
+
}
|
|
190
|
+
/** The largest delay `setTimeout` honours; Node fires anything larger after 1 ms. */
|
|
191
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
192
|
+
/** The PowQL operator each arithmetic update operator compiles to. */
|
|
193
|
+
const POWQL_ARITHMETIC = {
|
|
194
|
+
increment: '+',
|
|
195
|
+
decrement: '-',
|
|
196
|
+
multiply: '*',
|
|
197
|
+
divide: '/',
|
|
198
|
+
};
|
|
199
|
+
/** The accepted `relationLoadStrategy` values, the SQL engines' set. */
|
|
200
|
+
const RELATION_LOAD_STRATEGIES = Object.freeze(['join', 'batched', 'auto', 'flatten']);
|
|
201
|
+
/** A short, value-safe rendering of a rejected option value. */
|
|
202
|
+
function describeOptionValue(value) {
|
|
203
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
204
|
+
return String(value);
|
|
205
|
+
if (typeof value === 'string')
|
|
206
|
+
return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}...` : value);
|
|
207
|
+
return value === null ? 'null' : `a value of type ${typeof value}`;
|
|
208
|
+
}
|
|
209
|
+
/** `{ equals: v }` and nothing else. */
|
|
210
|
+
/** `AND` / `OR` / `NOT`, the keys {@link combinatorBranches} reads. */
|
|
211
|
+
function isCombinatorKey(key) {
|
|
212
|
+
return key === 'AND' || key === 'OR' || key === 'NOT';
|
|
213
|
+
}
|
|
214
|
+
/** Short, data-free description of an operand for an error message. */
|
|
215
|
+
function describeOperand(value) {
|
|
216
|
+
if (value === null)
|
|
217
|
+
return 'null';
|
|
218
|
+
if (Array.isArray(value))
|
|
219
|
+
return 'an array';
|
|
220
|
+
if (typeof value === 'object')
|
|
221
|
+
return 'an object';
|
|
222
|
+
return typeof value;
|
|
223
|
+
}
|
|
145
224
|
/** Filters that have no PowDB representation and must throw E017. */
|
|
146
225
|
function rejectUnsupportedFilter(value, field) {
|
|
147
226
|
if ('distance' in value || 'metric' in value) {
|
|
@@ -168,8 +247,12 @@ function rejectUnsupportedFilter(value, field) {
|
|
|
168
247
|
}
|
|
169
248
|
/**
|
|
170
249
|
* The PowQL query interface. Constructed by `turbinePowDB` via the
|
|
171
|
-
* `queryInterfaceFactory` seam
|
|
172
|
-
*
|
|
250
|
+
* `queryInterfaceFactory` seam. `implements TableClient` makes a method the SQL
|
|
251
|
+
* accessor has and this class lacks, or one whose signature differs, a build
|
|
252
|
+
* error. Where a method's result is narrowed by `select` / `omit` / `with`, a
|
|
253
|
+
* public overload signature carries the SQL accessor's typing and the
|
|
254
|
+
* implementation below it is typed on the plain row; both return the same
|
|
255
|
+
* projected, relation-loaded rows at run time.
|
|
173
256
|
*/
|
|
174
257
|
export class PowqlInterface {
|
|
175
258
|
pool;
|
|
@@ -181,7 +264,10 @@ export class PowqlInterface {
|
|
|
181
264
|
defaultLimit;
|
|
182
265
|
warnOnUnlimited;
|
|
183
266
|
onQuery;
|
|
184
|
-
|
|
267
|
+
/** Where this interface's log lines go: the client's logger, else the console. */
|
|
268
|
+
logger;
|
|
269
|
+
/** The logger for advice lines, or `undefined` when `diagnostics` turns advice off. */
|
|
270
|
+
advice;
|
|
185
271
|
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
186
272
|
this.pool = pool;
|
|
187
273
|
this.table = table;
|
|
@@ -203,6 +289,10 @@ export class PowqlInterface {
|
|
|
203
289
|
? (warnOpt[table] ?? warnOpt[snakeToCamel(table)]) !== false
|
|
204
290
|
: warnOpt !== false;
|
|
205
291
|
this.onQuery = options._onQuery;
|
|
292
|
+
// Resolved by the client once and passed in; an interface built directly
|
|
293
|
+
// resolves here, at construction, as QueryInterface does.
|
|
294
|
+
this.logger = options.logger ?? consoleLogger;
|
|
295
|
+
this.advice = resolveDiagnostics(options.diagnostics).advice ? this.logger : undefined;
|
|
206
296
|
}
|
|
207
297
|
// -------------------------------------------------------------------------
|
|
208
298
|
// Column / value helpers
|
|
@@ -302,11 +392,44 @@ export class PowqlInterface {
|
|
|
302
392
|
*/
|
|
303
393
|
param(value, params, col) {
|
|
304
394
|
let tagged = value;
|
|
305
|
-
|
|
395
|
+
const labels = col ? this.enumLabels(col) : undefined;
|
|
396
|
+
if (labels && value !== null && value !== undefined) {
|
|
397
|
+
// An enum column takes one of its labels, in a write and in a filter
|
|
398
|
+
// alike, as PostgreSQL does (22P02 there).
|
|
399
|
+
this.assertEnumLabel(col, labels, value);
|
|
400
|
+
}
|
|
401
|
+
else if (isJsonValue(value)) {
|
|
402
|
+
// `jsonValue(v)`: serialized with JSON.stringify, as on the SQL engines.
|
|
403
|
+
tagged = new PowdbJsonParam(value.value, col?.name);
|
|
404
|
+
}
|
|
405
|
+
else if (col && typeof value === 'string' && isDateColumn(col)) {
|
|
406
|
+
// Date text against a Date column: the epoch micros the column stores,
|
|
407
|
+
// as PostgreSQL reads the same text against a date / timestamp.
|
|
408
|
+
tagged = isoTextMicros(value, col, this.table);
|
|
409
|
+
}
|
|
410
|
+
else if (col &&
|
|
411
|
+
isDateColumn(col) &&
|
|
412
|
+
(typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean')) {
|
|
413
|
+
// The column holds epoch MICROseconds, so a number bound as-is names a
|
|
414
|
+
// different instant (a millisecond count lands in 1970) and matches
|
|
415
|
+
// nothing. PostgreSQL refuses a number against a date / timestamp too.
|
|
416
|
+
throw new ValidationError(`Invalid value for the Date column "${col.name}" on "${this.table}": expected a Date or ISO 8601 text, ` +
|
|
417
|
+
`got ${describeOperand(value)}. Pass \`new Date(ms)\` for a millisecond timestamp.`);
|
|
418
|
+
}
|
|
419
|
+
else if (col && typeof value === 'string' && this.isIntegerCol(col)) {
|
|
420
|
+
// Integer text against an integer column, as PostgreSQL reads it; an int8
|
|
421
|
+
// value past 2^53 keeps every digit as a bigint.
|
|
422
|
+
tagged = integerTextValue(value, col, this.table);
|
|
423
|
+
}
|
|
424
|
+
else if (col && typeof value === 'number' && this.isFloatCol(col)) {
|
|
306
425
|
// Float column: force a float-form literal even for an integer value.
|
|
307
426
|
tagged = new PowdbFloatParam(value);
|
|
308
427
|
}
|
|
309
|
-
else if (col &&
|
|
428
|
+
else if (col &&
|
|
429
|
+
value !== null &&
|
|
430
|
+
typeof value === 'object' &&
|
|
431
|
+
!(value instanceof Date) &&
|
|
432
|
+
isJsonColumn(col, this.schema.enums)) {
|
|
310
433
|
// json document column: a JS object/array is serialized to canonical JSON
|
|
311
434
|
// text and stored as a json document (a JS string passes through raw, same
|
|
312
435
|
// contract as pg jsonb; `null` stays `null`).
|
|
@@ -326,14 +449,36 @@ export class PowqlInterface {
|
|
|
326
449
|
* and coercion stays unambiguous. Non-finite floats are still rejected.
|
|
327
450
|
*/
|
|
328
451
|
writeRef(value, col, params) {
|
|
452
|
+
// A value position (create data, `{ set }`): the SQL engines' rule and message.
|
|
453
|
+
if (isSqlExpr(value) || isColumnRef(value)) {
|
|
454
|
+
throw new ValidationError(exprNotAllowedMessage(this.table, col.field, value));
|
|
455
|
+
}
|
|
329
456
|
if (typeof value === 'number' && !Number.isFinite(value) && this.isFloatCol(col)) {
|
|
330
457
|
throw new ValidationError(`Non-finite value for float column "${col.name}" on "${this.table}".`);
|
|
331
458
|
}
|
|
332
459
|
return this.param(value, params, col);
|
|
333
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* A PowQL `int` column declared as an integer type (not a Date, which is
|
|
463
|
+
* stored as int micros, and not a column whose declared type is something
|
|
464
|
+
* else).
|
|
465
|
+
*/
|
|
466
|
+
isIntegerCol(col) {
|
|
467
|
+
if (isDateColumn(col))
|
|
468
|
+
return false;
|
|
469
|
+
const declared = (col.dialectType ?? col.pgType ?? '').toLowerCase();
|
|
470
|
+
if (declared !== '' && !/int|serial/.test(declared))
|
|
471
|
+
return false;
|
|
472
|
+
try {
|
|
473
|
+
return powqlColumnType(col, this.schema.enums) === 'int';
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
334
479
|
isFloatCol(col) {
|
|
335
480
|
try {
|
|
336
|
-
return powqlColumnType(col) === 'float';
|
|
481
|
+
return powqlColumnType(col, this.schema.enums) === 'float';
|
|
337
482
|
}
|
|
338
483
|
catch {
|
|
339
484
|
return false;
|
|
@@ -431,15 +576,6 @@ export class PowqlInterface {
|
|
|
431
576
|
const normalized = normalizeWithClause(this.schema, this.table, args.with);
|
|
432
577
|
return normalized === args.with ? args : { ...args, with: normalized };
|
|
433
578
|
}
|
|
434
|
-
/**
|
|
435
|
-
* Caller args in canonical form: declared relation spellings, and `take` /
|
|
436
|
-
* `skip` folded into `limit` / `offset`.
|
|
437
|
-
*
|
|
438
|
-
* Same composition as `QueryInterface.normalizeArgs` and here for the same
|
|
439
|
-
* reason the method above is here: nothing about a parallel implementation
|
|
440
|
-
* makes a core rule arrive on its own, and an engine that reads `take` but
|
|
441
|
-
* not `skip` pages differently from one that reads both.
|
|
442
|
-
*/
|
|
443
579
|
/**
|
|
444
580
|
* Fill in the deterministic per-relation ordering `stableRelationOrder` asks
|
|
445
581
|
* for, through the shared transform the SQL engines use.
|
|
@@ -449,6 +585,13 @@ export class PowqlInterface {
|
|
|
449
585
|
* option whose entire purpose is that they do not. Same resolution as
|
|
450
586
|
* QueryInterface: a per-query flag wins, else the client-level default.
|
|
451
587
|
*/
|
|
588
|
+
/** `take` / `skip` inside every `with` level folded into `limit` / `offset` (shared rule). */
|
|
589
|
+
withRelationPagination(args) {
|
|
590
|
+
if (!args?.with)
|
|
591
|
+
return args;
|
|
592
|
+
const normalized = normalizeWithPagination(this.schema, this.table, args.with);
|
|
593
|
+
return normalized === args.with ? args : { ...args, with: normalized };
|
|
594
|
+
}
|
|
452
595
|
withStableRelationOrder(args) {
|
|
453
596
|
if (!args?.with)
|
|
454
597
|
return args;
|
|
@@ -458,8 +601,158 @@ export class PowqlInterface {
|
|
|
458
601
|
const ordered = applyStableRelationOrderTo(this.schema, args.with, this.table);
|
|
459
602
|
return ordered === args.with ? args : { ...args, with: ordered };
|
|
460
603
|
}
|
|
461
|
-
|
|
462
|
-
|
|
604
|
+
/**
|
|
605
|
+
* Caller args in canonical form: `undefined` pruned (query/normalize-args.ts),
|
|
606
|
+
* declared relation spellings, stable relation order, and `take` / `skip`
|
|
607
|
+
* folded into `limit` / `offset` at the top level and inside `with`. Same composition as
|
|
608
|
+
* `QueryInterface.normalizeArgs`, run at the top of every public operation,
|
|
609
|
+
* so the two engines agree about what each argument means.
|
|
610
|
+
*
|
|
611
|
+
* `refuseEmptiedWhere` names the single-row read (`findFirst`,
|
|
612
|
+
* `findFirstOrThrow`) that refuses a `where` whose every key was `undefined`.
|
|
613
|
+
*/
|
|
614
|
+
normalizeArgs(args, refuseEmptiedWhere) {
|
|
615
|
+
const pruned = pruneUndefinedArgs(this.schema, this.table, args);
|
|
616
|
+
if (pruned !== args) {
|
|
617
|
+
if (refuseEmptiedWhere)
|
|
618
|
+
assertWhereNotEmptiedByPruning(this.table, refuseEmptiedWhere, args, pruned);
|
|
619
|
+
assertCursorNotEmptied(this.table, args, pruned);
|
|
620
|
+
}
|
|
621
|
+
this.assertQueryOptions(pruned);
|
|
622
|
+
const shaped = pruned;
|
|
623
|
+
return normalizePagination(this.withStableRelationOrder(this.withRelationPagination(this.withDeclaredRelationNames(shaped))));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The per-query `timeout` and `relationLoadStrategy`, checked with the SQL
|
|
627
|
+
* engines' rule and message: a timeout is a positive number of ms up to
|
|
628
|
+
* {@link MAX_TIMEOUT_MS} (or 0 for none), a strategy one of the SQL engines' values.
|
|
629
|
+
*/
|
|
630
|
+
assertQueryOptions(args) {
|
|
631
|
+
const { timeout, relationLoadStrategy } = args;
|
|
632
|
+
if (timeout !== undefined &&
|
|
633
|
+
!(typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 && timeout <= MAX_TIMEOUT_MS)) {
|
|
634
|
+
throw new ValidationError(`\`timeout\` on ${this.table} must be a positive number of milliseconds up to ${MAX_TIMEOUT_MS}, or 0 for ` +
|
|
635
|
+
`no timeout (received ${describeOptionValue(timeout)}). Leave it out for no timeout.`);
|
|
636
|
+
}
|
|
637
|
+
if (relationLoadStrategy !== undefined && !RELATION_LOAD_STRATEGIES.includes(relationLoadStrategy)) {
|
|
638
|
+
throw new ValidationError(`\`relationLoadStrategy\` must be one of ${RELATION_LOAD_STRATEGIES.map((v) => `'${v}'`).join(', ')} ` +
|
|
639
|
+
`(received ${describeOptionValue(relationLoadStrategy)}).`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Whether `cursor` carries a value, after the SQL engines' cursor checks: a
|
|
644
|
+
* cursor that names no row (`null`, `{}`) is E003, and each value is a row's
|
|
645
|
+
* value under the shared rule (`cursorFieldValue`). Unknown fields are E003.
|
|
646
|
+
*/
|
|
647
|
+
cursorCarriesValues(cursor) {
|
|
648
|
+
assertCursorWritten(this.table, 'findMany', cursor);
|
|
649
|
+
if (!hasCursor(cursor))
|
|
650
|
+
return false;
|
|
651
|
+
const flat = expandCompoundUniqueWhere(this.meta, cursor);
|
|
652
|
+
for (const [key, written] of Object.entries(flat)) {
|
|
653
|
+
if (written === undefined)
|
|
654
|
+
continue;
|
|
655
|
+
const col = this.column(key);
|
|
656
|
+
cursorFieldValue(key, written, {
|
|
657
|
+
column: col.name,
|
|
658
|
+
table: this.table,
|
|
659
|
+
kind: operandColumnKind(this.meta.pgTypes[col.name]),
|
|
660
|
+
nullable: col.nullable && !this.meta.primaryKey.includes(col.name),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* The SQL engines' cursor placement rule (query/builder.ts `planCursor`),
|
|
667
|
+
* so a cursor they refuse is the same E003 here and only a cursor they would
|
|
668
|
+
* serve reaches PowDB's E017. The page order is `orderBy` plus the
|
|
669
|
+
* tiebreaker; the cursor must carry a value for every column of it, or name
|
|
670
|
+
* one row by a unique key (the shared findUnique rule).
|
|
671
|
+
*/
|
|
672
|
+
assertCursorPlaceable(cursor, orderBy) {
|
|
673
|
+
const meta = this.meta;
|
|
674
|
+
const field = (column) => meta.reverseColumnMap[column] ?? column;
|
|
675
|
+
const flat = expandCompoundUniqueWhere(meta, cursor);
|
|
676
|
+
const valued = new Set();
|
|
677
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
678
|
+
if (value !== undefined)
|
|
679
|
+
valued.add(this.column(key).name);
|
|
680
|
+
}
|
|
681
|
+
const terms = [];
|
|
682
|
+
for (const [key, dir] of orderByEntries(orderBy).filter(([, d]) => d !== undefined)) {
|
|
683
|
+
if (isVectorOrderBy(dir) || isJsonPathOrderBy(dir) || (typeof dir !== 'string' && !isOrderBySpec(dir))) {
|
|
684
|
+
throw new ValidationError(`findMany on "${this.table}" refused: \`cursor\` cannot page past the orderBy entry "${key}", which ` +
|
|
685
|
+
'orders by a relation, a JSON path or a vector distance rather than a column. Order the cursor ' +
|
|
686
|
+
'query by columns only, or page it with `offset`.');
|
|
687
|
+
}
|
|
688
|
+
const column = this.column(key).name;
|
|
689
|
+
if (terms.some((t) => t.column === column))
|
|
690
|
+
continue;
|
|
691
|
+
terms.push({ column, desc: normalizeOrderBy(dir).dir === 'DESC' });
|
|
692
|
+
}
|
|
693
|
+
const nullable = (column) => !meta.primaryKey.includes(column) && meta.columns.find((c) => c.name === column)?.nullable === true;
|
|
694
|
+
const keys = uniqueKeyNames(meta).filter((cols) => cols.every((c) => !nullable(c)));
|
|
695
|
+
const ordered = new Set(terms.map((t) => t.column));
|
|
696
|
+
const tiebreak = keys.some((cols) => cols.every((c) => ordered.has(c)))
|
|
697
|
+
? []
|
|
698
|
+
: keys[0]?.filter((c) => !ordered.has(c));
|
|
699
|
+
if (!tiebreak) {
|
|
700
|
+
throw new ValidationError(`findMany on "${this.table}" refused: a \`cursor\` page needs an order with no ties, and "${this.table}" ` +
|
|
701
|
+
'has no primary key or unique key on NOT NULL columns to break them. Add a primary key, or page ' +
|
|
702
|
+
'this table with `offset`.');
|
|
703
|
+
}
|
|
704
|
+
const desc = terms.length > 0 ? terms[terms.length - 1].desc : false;
|
|
705
|
+
for (const column of tiebreak)
|
|
706
|
+
terms.push({ column, desc });
|
|
707
|
+
if (terms.every((t) => valued.has(t.column)) || whereIdentifiesOneRow(meta, flat))
|
|
708
|
+
return;
|
|
709
|
+
const missing = terms.filter((t) => !valued.has(t.column)).map((t) => `"${field(t.column)}"`);
|
|
710
|
+
const order = terms.map((t) => `${field(t.column)} ${t.desc ? 'desc' : 'asc'}`).join(', ');
|
|
711
|
+
const named = uniqueKeyNames(meta).map((cols) => cols.length === 1 ? `{ ${field(cols[0])} }` : `{ ${cols.map(field).join(', ')} }`);
|
|
712
|
+
throw new ValidationError(`findMany on "${this.table}" refused: the \`cursor\` has no value for ${missing.join(', ')}, which the ` +
|
|
713
|
+
`page order (${order}) needs, and it does not name one row by a unique key. Give the cursor a value ` +
|
|
714
|
+
`for every ordered field, or name the cursor row by ${named.length > 0 ? named.join(' or ') : 'a unique key'}.`);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* A paginated `findMany` (`limit` / `offset`) whose `orderBy` can tie gets
|
|
718
|
+
* a tiebreaker appended in the last term's direction (the primary key, else
|
|
719
|
+
* the first unique key on NOT NULL columns), as on the SQL engines, so a page
|
|
720
|
+
* boundary cannot fall between tied rows in a different order on each page.
|
|
721
|
+
* Returns `args` itself when the order is already total or nothing pages.
|
|
722
|
+
*/
|
|
723
|
+
completePageOrder(args) {
|
|
724
|
+
if (args.orderBy === undefined || (args.limit === undefined && args.offset === undefined))
|
|
725
|
+
return args;
|
|
726
|
+
if (args.distinct && args.distinct.length > 0)
|
|
727
|
+
return args;
|
|
728
|
+
const meta = this.meta;
|
|
729
|
+
const entries = orderByEntries(args.orderBy).filter(([, d]) => d !== undefined);
|
|
730
|
+
const ordered = new Set();
|
|
731
|
+
let lastDesc = false;
|
|
732
|
+
for (const [key, dir] of entries) {
|
|
733
|
+
lastDesc = false;
|
|
734
|
+
// A JSON-path, vector or relation ordering is not a column of this table.
|
|
735
|
+
if (typeof dir !== 'string' && !isOrderBySpec(dir))
|
|
736
|
+
continue;
|
|
737
|
+
const column = resolveColumnName(meta, key);
|
|
738
|
+
if (column === undefined)
|
|
739
|
+
continue;
|
|
740
|
+
ordered.add(column);
|
|
741
|
+
lastDesc = normalizeOrderBy(dir).dir === 'DESC';
|
|
742
|
+
}
|
|
743
|
+
const nullable = (column) => !meta.primaryKey.includes(column) && meta.columns.find((c) => c.name === column)?.nullable === true;
|
|
744
|
+
const keys = uniqueKeyNames(meta).filter((cols) => cols.every((c) => !nullable(c)));
|
|
745
|
+
if (keys.length === 0 || keys.some((cols) => cols.every((c) => ordered.has(c))))
|
|
746
|
+
return args;
|
|
747
|
+
const missing = keys[0].filter((c) => !ordered.has(c));
|
|
748
|
+
const dir = lastDesc ? 'desc' : 'asc';
|
|
749
|
+
return {
|
|
750
|
+
...args,
|
|
751
|
+
orderBy: [
|
|
752
|
+
...entries.map(([k, v]) => ({ [k]: v })),
|
|
753
|
+
...missing.map((c) => ({ [meta.reverseColumnMap[c] ?? c]: dir })),
|
|
754
|
+
],
|
|
755
|
+
};
|
|
463
756
|
}
|
|
464
757
|
/**
|
|
465
758
|
* `jsonEncoding` selects between PostgreSQL's `json_build_object` and
|
|
@@ -473,12 +766,34 @@ export class PowqlInterface {
|
|
|
473
766
|
return;
|
|
474
767
|
throw new UnsupportedFeatureError('jsonEncoding', 'PowDB', 'PowQL emits no JSON row encoding for relations, so there is nothing to select; omit the option');
|
|
475
768
|
}
|
|
769
|
+
/** `optimisticLock` is an `update` option; the shared refusal and message from query/writes.ts. */
|
|
770
|
+
assertNoOptimisticLock(args, operation) {
|
|
771
|
+
// The shared check reads only the table name from its builder context.
|
|
772
|
+
assertNoOptimisticLock({ table: this.table }, args, operation);
|
|
773
|
+
}
|
|
476
774
|
assertNoForceCustomPlan(args) {
|
|
477
775
|
if (args?.forceCustomPlan !== true)
|
|
478
776
|
return;
|
|
479
777
|
throw new UnsupportedFeatureError('The forceCustomPlan query option', 'powdb', 'Forcing a per-query custom plan means keeping the statement out of the PostgreSQL plan cache, and PowDB ' +
|
|
480
778
|
'has no such cache to keep it out of. Remove the option, or set it only on PostgreSQL queries.');
|
|
481
779
|
}
|
|
780
|
+
/** `lock` (a row-locking read): PowDB has no row locks. */
|
|
781
|
+
assertNoRowLock(args) {
|
|
782
|
+
if (args?.lock === undefined)
|
|
783
|
+
return;
|
|
784
|
+
throw new UnsupportedFeatureError('The lock read option', 'powdb', 'PowDB has no row locks: a transaction holds the single writer gate. Remove `lock`; inside ' +
|
|
785
|
+
'$transaction on PowDB, writes are already serialized.');
|
|
786
|
+
}
|
|
787
|
+
/** `_percentile`: PowQL has no percentile aggregate. */
|
|
788
|
+
assertNoPercentile(args) {
|
|
789
|
+
const spec = args._percentile;
|
|
790
|
+
// An empty block asks for nothing, as on the SQL engines.
|
|
791
|
+
if (spec === undefined)
|
|
792
|
+
return;
|
|
793
|
+
if (typeof spec === 'object' && spec !== null && !Object.values(spec).some((v) => v != null))
|
|
794
|
+
return;
|
|
795
|
+
throw new UnsupportedFeatureError('_percentile', 'powdb', 'Percentile aggregates need PostgreSQL; PowQL has count, sum, avg, min and max only.');
|
|
796
|
+
}
|
|
482
797
|
/** See query/compound-unique.ts: one rule and one message across engines. */
|
|
483
798
|
assertIdentifiesOneRow(where) {
|
|
484
799
|
assertWhereIdentifiesOneRow(this.meta, this.table, where);
|
|
@@ -534,6 +849,11 @@ export class PowqlInterface {
|
|
|
534
849
|
* an already-RESOLVED where (relation filters pre-resolved to literal in-lists
|
|
535
850
|
* by {@link resolveRelationFilters}): the relation-key branch below still
|
|
536
851
|
* throws, so an unresolved relation filter can never leak into a join.
|
|
852
|
+
*
|
|
853
|
+
* `NOT` here is PowQL's two-valued complement. A caller's where reaches this
|
|
854
|
+
* only through {@link sqlTrueWhere} (inside {@link resolveRelationFilters},
|
|
855
|
+
* and in {@link applyGlobalFilter}), which leaves `NOT` only where the two
|
|
856
|
+
* readings agree.
|
|
537
857
|
*/
|
|
538
858
|
buildWhere(where, params, alias) {
|
|
539
859
|
if (!where)
|
|
@@ -542,26 +862,36 @@ export class PowqlInterface {
|
|
|
542
862
|
for (const [key, value] of Object.entries(where)) {
|
|
543
863
|
if (value === undefined)
|
|
544
864
|
continue;
|
|
545
|
-
if (key === 'AND') {
|
|
546
|
-
const
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
865
|
+
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
866
|
+
const branches = combinatorBranches(key, value, this.table);
|
|
867
|
+
if (branches === null)
|
|
868
|
+
continue;
|
|
869
|
+
if (key === 'OR' && branches.length === 0) {
|
|
870
|
+
parts.push(this.alwaysFalse());
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const built = branches.map((w) => this.buildWhere(w, params, alias));
|
|
874
|
+
// A branch with no condition is TRUE, so the whole `OR` is: a constant
|
|
875
|
+
// condition rather than none, so a `NOT` around it matches no row.
|
|
876
|
+
if (key === 'OR' && built.some((c) => !c)) {
|
|
877
|
+
parts.push(ALWAYS_TRUE);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
const sub = built.filter(Boolean);
|
|
881
|
+
if (!sub.length)
|
|
882
|
+
continue;
|
|
883
|
+
if (key === 'OR')
|
|
553
884
|
parts.push(`(${sub.join(' or ')})`);
|
|
885
|
+
else if (key === 'AND')
|
|
886
|
+
parts.push(`(${sub.join(' and ')})`);
|
|
887
|
+
else
|
|
888
|
+
parts.push(...sub.map((c) => `not (${c})`));
|
|
554
889
|
}
|
|
555
|
-
else if (key
|
|
556
|
-
const sub = this.buildWhere(value, params, alias);
|
|
557
|
-
if (sub)
|
|
558
|
-
parts.push(`not (${sub})`);
|
|
559
|
-
}
|
|
560
|
-
else if (this.meta.relations[key]) {
|
|
890
|
+
else if (Object.hasOwn(this.meta.relations, key)) {
|
|
561
891
|
// Relation filters are pre-resolved to scalar in/notIn by
|
|
562
892
|
// resolveRelationFilters() before buildWhere runs, reaching here means
|
|
563
893
|
// a caller skipped that step (an internal bug, not user error).
|
|
564
|
-
throw new
|
|
894
|
+
throw new InvariantError('powql-relation-filter-unresolved', `relation filter "${key}" reached buildWhere unresolved: resolveRelationFilters() must run first.`);
|
|
565
895
|
}
|
|
566
896
|
else {
|
|
567
897
|
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
@@ -610,37 +940,158 @@ export class PowqlInterface {
|
|
|
610
940
|
'against a bound timestamp needs the fix. A nested `with` (the default nested-projection / join paths) ' +
|
|
611
941
|
'correlates column to column, so it loads the relation without any such comparison.');
|
|
612
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Refuse a value comparison on a `numeric` / `decimal` column. PowDB stores
|
|
945
|
+
* those as `str` (it has no decimal type, and `float` would lose digits), so
|
|
946
|
+
* every comparison, ordering and aggregate the engine could run on them is a
|
|
947
|
+
* TEXT comparison: `'7.50' > '7.5'`, `'10' < '9'`, and `'7.5' = '7.50'` is
|
|
948
|
+
* false; arithmetic on a `str` yields NULL. Null checks, reads and plain
|
|
949
|
+
* writes are unaffected.
|
|
950
|
+
*/
|
|
951
|
+
assertDecimalComparable(col, what) {
|
|
952
|
+
if (!isPowdbDecimalTextColumn(col, this.schema.enums))
|
|
953
|
+
return;
|
|
954
|
+
throw new UnsupportedFeatureError(`${what} on the numeric column "${col.name}" of "${this.table}"`, 'PowDB', 'PowDB stores numeric/decimal values as text, so the engine would compare, order and aggregate them as ' +
|
|
955
|
+
"strings ('10' < '9', '7.5' != '7.50') and arithmetic on them yields NULL. Reading, writing and `null` " +
|
|
956
|
+
'checks work. To compare, aggregate or do arithmetic, store the value as an integer of minor units ' +
|
|
957
|
+
'(cents) or as a float, or do it in application code.');
|
|
958
|
+
}
|
|
959
|
+
/** The labels of `col`'s enum type, or `undefined` when it is not an enum column. */
|
|
960
|
+
enumLabels(col) {
|
|
961
|
+
return powdbEnumLabels(col, this.schema.enums);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* E003 unless `value` is one of the enum column's labels. PostgreSQL refuses
|
|
965
|
+
* any other value (22P02, which Turbine reports as E003) whether it is
|
|
966
|
+
* written or compared, and the label set is exact: case and spelling count.
|
|
967
|
+
* The value itself is not echoed, only its kind.
|
|
968
|
+
*/
|
|
969
|
+
assertEnumLabel(col, labels, value) {
|
|
970
|
+
if (typeof value === 'string' && labels.includes(value))
|
|
971
|
+
return;
|
|
972
|
+
const shown = labels.slice(0, 10).map((l) => JSON.stringify(l));
|
|
973
|
+
if (labels.length > shown.length)
|
|
974
|
+
shown.push(`and ${labels.length - shown.length} more`);
|
|
975
|
+
throw new ValidationError(`Invalid value for the enum column "${col.name}" on "${this.table}": expected one of its labels ` +
|
|
976
|
+
`(${shown.join(', ')}), got ${typeof value === 'string' ? 'a string that is not one of them' : describeOperand(value)}.`);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Refuse an ORDERING of an enum column (orderBy, `_min` / `_max`). PostgreSQL
|
|
980
|
+
* orders an enum by the position of its labels in the type's declaration;
|
|
981
|
+
* PowDB stores the label text and would order it alphabetically, a different
|
|
982
|
+
* answer with no error. Range filters (`gt` / `gte` / `lt` / `lte`) do not come
|
|
983
|
+
* here: they compile to the exact set of labels on that side of the operand.
|
|
984
|
+
*/
|
|
985
|
+
assertEnumOrderable(col, what) {
|
|
986
|
+
if (!this.enumLabels(col))
|
|
987
|
+
return;
|
|
988
|
+
throw new UnsupportedFeatureError(`${what} on the enum column "${col.name}" of "${this.table}"`, 'PowDB', 'PostgreSQL orders an enum by the order its labels are declared in, and PowDB stores the label text, so ' +
|
|
989
|
+
'it would order the labels alphabetically. Equality, `in` / `notIn`, `gt` / `gte` / `lt` / `lte` and ' +
|
|
990
|
+
'groupBy keys work. Order by the label position in application code.');
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* An aggregate over an enum column: `_min` / `_max` order it (see
|
|
994
|
+
* {@link assertEnumOrderable}); `_sum` / `_avg` have no meaning on a label,
|
|
995
|
+
* which PostgreSQL refuses as E003 (42883, no such function).
|
|
996
|
+
*/
|
|
997
|
+
assertEnumAggregate(col, fn) {
|
|
998
|
+
if (!this.enumLabels(col))
|
|
999
|
+
return;
|
|
1000
|
+
if (fn === '_min' || fn === '_max') {
|
|
1001
|
+
this.assertEnumOrderable(col, `\`${fn}\``);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
throw new ValidationError(`\`${fn}\` on the enum column "${col.name}" of "${this.table}": an enum label is not a number. ` +
|
|
1005
|
+
'Use `_count`, or `_min` / `_max` on another engine.');
|
|
1006
|
+
}
|
|
1007
|
+
/** The E003 for an operand that is not a comparable value for its column. */
|
|
1008
|
+
operandError(field, opName, value, expected) {
|
|
1009
|
+
const position = opName === '=' ? `"${field}"` : `\`${opName}\` on "${field}"`;
|
|
1010
|
+
return new ValidationError(`Invalid operand for ${position} on "${this.table}": expected ${expected}, got ${describeOperand(value)}. ` +
|
|
1011
|
+
'A column is compared against a string, number, bigint, boolean or Date; pass an operator object ' +
|
|
1012
|
+
'(`{ gt: 5 }`, `{ in: [1, 2] }`) to choose the comparison.');
|
|
1013
|
+
}
|
|
613
1014
|
/** Build a single `field: value | operator` condition. */
|
|
614
1015
|
buildFieldCondition(field, value, params, alias) {
|
|
615
1016
|
const colMeta = this.column(field);
|
|
616
1017
|
const ref = this.ref(field, alias);
|
|
617
1018
|
if (value === null)
|
|
618
1019
|
return `${ref} is null`;
|
|
619
|
-
if (value
|
|
1020
|
+
if (isColumnReference(value)) {
|
|
1021
|
+
throw new UnsupportedFeatureError('Column references in a filter', 'PowDB', `field "${field}": PowQL has no column-to-column operand in a filter. Compare in application code.`);
|
|
1022
|
+
}
|
|
1023
|
+
if (isScalarOperand(value)) {
|
|
1024
|
+
this.assertDecimalComparable(colMeta, 'Equality');
|
|
620
1025
|
this.assertDatetimePredicateSupported(colMeta);
|
|
621
|
-
return `${ref} = ${this.
|
|
1026
|
+
return `${ref} = ${this.filterOperand(value, params, colMeta)}`;
|
|
622
1027
|
}
|
|
623
1028
|
const op = value;
|
|
624
1029
|
// JSON path / key filters on a json document column compile to PowQL `->`
|
|
625
1030
|
// path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
|
|
626
1031
|
// `hasKey`; on a NON-json column those fall through to the scalar operator
|
|
627
1032
|
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
628
|
-
if (isJsonColumn(colMeta) && isJsonFilter(value)) {
|
|
1033
|
+
if (isJsonColumn(colMeta, this.schema.enums) && isJsonFilter(value)) {
|
|
629
1034
|
requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
630
1035
|
return this.buildJsonPathCondition(colMeta, value, params, alias);
|
|
631
1036
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1037
|
+
if (!Array.isArray(op))
|
|
1038
|
+
rejectUnsupportedFilter(op, field);
|
|
1039
|
+
if (Array.isArray(op) || !Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
1040
|
+
// A json document column compares a whole document by value. Any other
|
|
1041
|
+
// column has no object or array value, so binding one as data compared
|
|
1042
|
+
// against nothing meaningful; it is refused, as on the SQL engines.
|
|
1043
|
+
if (!isJsonColumn(colMeta, this.schema.enums))
|
|
1044
|
+
throw this.operandError(field, '=', value, 'a scalar or an operator object');
|
|
1045
|
+
throw jsonDocumentEqualityUnsupported(colMeta);
|
|
637
1046
|
}
|
|
1047
|
+
// The SQL engines' operand rules and messages, so the engines agree on
|
|
1048
|
+
// which operator objects are valid; one naming no comparison
|
|
1049
|
+
// (`{ mode: 'insensitive' }`) is refused instead of compiling to nothing.
|
|
1050
|
+
const operand = {
|
|
1051
|
+
column: colMeta.name,
|
|
1052
|
+
table: this.table,
|
|
1053
|
+
kind: operandColumnKind(this.meta.pgTypes[colMeta.name]),
|
|
1054
|
+
};
|
|
1055
|
+
if (!isJsonColumn(colMeta, this.schema.enums))
|
|
1056
|
+
assertOperatorOperands(op, operand);
|
|
1057
|
+
// On a JSON column a `not` object made only of filter operators is refused,
|
|
1058
|
+
// as on the SQL engines (one spelling, one meaning on every engine).
|
|
1059
|
+
else
|
|
1060
|
+
assertJsonNotOperand(op.not, operand);
|
|
638
1061
|
const insensitive = op.mode === 'insensitive';
|
|
639
1062
|
const lhs = insensitive ? `lower(${ref})` : ref;
|
|
1063
|
+
const labels = this.enumLabels(colMeta);
|
|
640
1064
|
const conds = [];
|
|
641
1065
|
for (const [opName, opVal] of Object.entries(op)) {
|
|
642
1066
|
if (opVal === undefined || opName === 'mode')
|
|
643
1067
|
continue;
|
|
1068
|
+
if (labels) {
|
|
1069
|
+
const range = this.enumRangeCondition(colMeta, labels, ref, opName, opVal, insensitive, params);
|
|
1070
|
+
if (range !== undefined) {
|
|
1071
|
+
conds.push(range);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (opName === 'not' && opVal !== null && !isScalarOperand(opVal) && !isColumnReference(opVal)) {
|
|
1076
|
+
// `not` takes an operator object, as on the SQL engines: the rows where
|
|
1077
|
+
// the inner condition is FALSE (a NULL column is neither, so it is
|
|
1078
|
+
// excluded, which PowQL's two-valued `not` alone would not do).
|
|
1079
|
+
if (Array.isArray(opVal) || Object.keys(opVal).length === 0) {
|
|
1080
|
+
throw this.operandError(field, 'not', opVal, 'a scalar, null, or a non-empty operator object');
|
|
1081
|
+
}
|
|
1082
|
+
const inner = { ...(op.mode !== undefined ? { mode: op.mode } : {}), ...opVal };
|
|
1083
|
+
const negated = this.buildWhere(this.sqlFalseWhere({ [field]: inner }), params, alias);
|
|
1084
|
+
conds.push(negated || this.alwaysFalse());
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (opName === 'in' || opName === 'notIn') {
|
|
1088
|
+
if (!Array.isArray(opVal) || opVal.some((v) => v !== null && !isScalarOperand(v))) {
|
|
1089
|
+
throw this.operandError(field, opName, opVal, 'an array of scalars');
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
else if (opVal !== null && !isScalarOperand(opVal)) {
|
|
1093
|
+
throw this.operandError(field, opName, opVal, 'a scalar');
|
|
1094
|
+
}
|
|
644
1095
|
// Every operator below compares the column against a bound literal, which
|
|
645
1096
|
// is the shape a PowDB `datetime` column can only answer from engine 0.20
|
|
646
1097
|
// (see assertDatetimePredicateSupported; `in`/`notIn` reach the engine as
|
|
@@ -650,30 +1101,32 @@ export class PowqlInterface {
|
|
|
650
1101
|
// (a compile-time constant, never sent).
|
|
651
1102
|
const comparesNothing = (opVal === null && (opName === 'equals' || opName === 'not')) ||
|
|
652
1103
|
((opName === 'in' || opName === 'notIn') && Array.isArray(opVal) && opVal.length === 0);
|
|
653
|
-
if (!comparesNothing)
|
|
1104
|
+
if (!comparesNothing) {
|
|
1105
|
+
this.assertDecimalComparable(colMeta, `\`${opName}\``);
|
|
654
1106
|
this.assertDatetimePredicateSupported(colMeta);
|
|
1107
|
+
}
|
|
655
1108
|
switch (opName) {
|
|
656
1109
|
case 'equals':
|
|
657
|
-
conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive)}`);
|
|
1110
|
+
conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive, colMeta)}`);
|
|
658
1111
|
break;
|
|
659
1112
|
case 'not':
|
|
660
1113
|
// Spell as `lhs != $N` (not `not (lhs = $N)`) so a missing-value row is
|
|
661
1114
|
// EXCLUDED, matching SQL null semantics on PowDB >= 0.18.2. Behavior-neutral
|
|
662
1115
|
// on older engines (pre-0.18.2 `!=` MATCHED missing rows exactly like the
|
|
663
1116
|
// old `not (...)` spelling), so no capability gate is needed.
|
|
664
|
-
conds.push(opVal === null ? `${ref} is not null` : `${lhs} != ${this.bind(opVal, params, insensitive)}`);
|
|
1117
|
+
conds.push(opVal === null ? `${ref} is not null` : `${lhs} != ${this.bind(opVal, params, insensitive, colMeta)}`);
|
|
665
1118
|
break;
|
|
666
1119
|
case 'gt':
|
|
667
|
-
conds.push(`${lhs} > ${this.bind(opVal, params, insensitive)}`);
|
|
1120
|
+
conds.push(`${lhs} > ${this.bind(opVal, params, insensitive, colMeta)}`);
|
|
668
1121
|
break;
|
|
669
1122
|
case 'gte':
|
|
670
|
-
conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive)}`);
|
|
1123
|
+
conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive, colMeta)}`);
|
|
671
1124
|
break;
|
|
672
1125
|
case 'lt':
|
|
673
|
-
conds.push(`${lhs} < ${this.bind(opVal, params, insensitive)}`);
|
|
1126
|
+
conds.push(`${lhs} < ${this.bind(opVal, params, insensitive, colMeta)}`);
|
|
674
1127
|
break;
|
|
675
1128
|
case 'lte':
|
|
676
|
-
conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive)}`);
|
|
1129
|
+
conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive, colMeta)}`);
|
|
677
1130
|
break;
|
|
678
1131
|
case 'in':
|
|
679
1132
|
conds.push(this.buildInList(lhs, opVal, params, insensitive, false, colMeta));
|
|
@@ -682,13 +1135,9 @@ export class PowqlInterface {
|
|
|
682
1135
|
conds.push(this.buildInList(lhs, opVal, params, insensitive, true, colMeta));
|
|
683
1136
|
break;
|
|
684
1137
|
case 'contains':
|
|
685
|
-
conds.push(`${lhs} like ${this.bindLike(`%${escapeLike(String(opVal))}%`, params, insensitive)}`);
|
|
686
|
-
break;
|
|
687
1138
|
case 'startsWith':
|
|
688
|
-
conds.push(`${lhs} like ${this.bindLike(`${escapeLike(String(opVal))}%`, params, insensitive)}`);
|
|
689
|
-
break;
|
|
690
1139
|
case 'endsWith':
|
|
691
|
-
conds.push(
|
|
1140
|
+
conds.push(this.buildStringMatch(opName, ref, String(opVal), params, insensitive, `"${field}"`));
|
|
692
1141
|
break;
|
|
693
1142
|
default:
|
|
694
1143
|
throw new ValidationError(`Unsupported operator "${opName}" on PowDB.`);
|
|
@@ -757,9 +1206,8 @@ export class PowqlInterface {
|
|
|
757
1206
|
throw new UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
|
|
758
1207
|
}
|
|
759
1208
|
if (filter.equals !== undefined) {
|
|
760
|
-
if (filter.path === undefined || filter.path.length === 0)
|
|
761
|
-
throw
|
|
762
|
-
}
|
|
1209
|
+
if (filter.path === undefined || filter.path.length === 0)
|
|
1210
|
+
throw jsonDocumentEqualityUnsupported(col);
|
|
763
1211
|
conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
|
|
764
1212
|
}
|
|
765
1213
|
if (filter.hasKey !== undefined) {
|
|
@@ -791,30 +1239,70 @@ export class PowqlInterface {
|
|
|
791
1239
|
conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
|
|
792
1240
|
}
|
|
793
1241
|
// Substring comparisons against the text at `path`, in the same fixed
|
|
794
|
-
// order as the SQL path
|
|
795
|
-
|
|
1242
|
+
// order as the SQL path, through the same literal matcher as a column.
|
|
1243
|
+
const stringOps = {
|
|
1244
|
+
stringContains: 'contains',
|
|
1245
|
+
stringStartsWith: 'startsWith',
|
|
1246
|
+
stringEndsWith: 'endsWith',
|
|
1247
|
+
};
|
|
1248
|
+
for (const { op, value } of jsonStringEntries(filter, col.name)) {
|
|
796
1249
|
const insensitive = filter.mode === 'insensitive';
|
|
797
|
-
const
|
|
798
|
-
|
|
1250
|
+
const kind = stringOps[op];
|
|
1251
|
+
if (!kind)
|
|
1252
|
+
throw new InvariantError('powql-json-operator', `unknown JSON string operator "${op}" on "${col.name}".`);
|
|
1253
|
+
conds.push(this.buildStringMatch(kind, pathP(), value, params, insensitive, `the JSON path on "${col.name}"`));
|
|
799
1254
|
}
|
|
800
1255
|
// Every reachable shape now emits a condition or throws: assertJsonFilterKeys
|
|
801
1256
|
// rejects a filter that compares nothing, so an empty list here would be a
|
|
802
1257
|
// generator bug rather than a user error, and must not silently widen the
|
|
803
1258
|
// query the way it used to.
|
|
804
1259
|
if (!conds.length) {
|
|
805
|
-
throw new
|
|
1260
|
+
throw new InvariantError('powql-json-filter-empty', `JSON filter on "${col.name}" compiled to no condition.`);
|
|
806
1261
|
}
|
|
807
1262
|
return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
|
|
808
1263
|
}
|
|
809
1264
|
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
810
|
-
bind(value, params, insensitive) {
|
|
811
|
-
const ph = this.
|
|
1265
|
+
bind(value, params, insensitive, col) {
|
|
1266
|
+
const ph = this.filterOperand(value, params, col);
|
|
812
1267
|
return insensitive ? `lower(${ph})` : ph;
|
|
813
1268
|
}
|
|
814
|
-
/**
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1269
|
+
/**
|
|
1270
|
+
* A bound filter operand. An integral number compared with a float column is
|
|
1271
|
+
* cast to float in the statement: the typed wire carries an integral number as
|
|
1272
|
+
* an int, and the engine compares int with float by type tag inside `not`,
|
|
1273
|
+
* `or` and `in` (on 0.20 `not (.rating = 4)` matches the 4.0 row and
|
|
1274
|
+
* `.rating in (4, 5)` matches nothing).
|
|
1275
|
+
*/
|
|
1276
|
+
filterOperand(value, params, col) {
|
|
1277
|
+
const ph = this.param(value, params, col);
|
|
1278
|
+
return col && typeof value === 'number' && Number.isInteger(value) && this.isFloatCol(col) ? `(${ph} + 0.0)` : ph;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* `contains` / `startsWith` / `endsWith` against `expr` (a column or a JSON
|
|
1282
|
+
* path), matching `value` literally.
|
|
1283
|
+
*
|
|
1284
|
+
* PowQL's `like` has NO escape character: `%` and `_` in the pattern are
|
|
1285
|
+
* always wildcards and `\` is an ordinary character. So a prefix or suffix
|
|
1286
|
+
* test never goes through `like`: it compares the leading or trailing
|
|
1287
|
+
* `length($v)` characters with `substring`, which is exact for every value
|
|
1288
|
+
* on every engine version (and sidesteps the pre-0.27 matcher, which missed
|
|
1289
|
+
* text containing a literal `%`). `contains` has no such form, since PowQL
|
|
1290
|
+
* has no position function, so a value carrying `%` or `_` is refused rather
|
|
1291
|
+
* than sent as a pattern that means something else.
|
|
1292
|
+
*/
|
|
1293
|
+
buildStringMatch(op, expr, value, params, insensitive, target) {
|
|
1294
|
+
const fold = (e) => (insensitive ? `lower(${e})` : e);
|
|
1295
|
+
if (op === 'contains') {
|
|
1296
|
+
if (/[%_]/.test(value)) {
|
|
1297
|
+
throw new UnsupportedFeatureError('`contains` with a value containing `%` or `_`', 'PowDB', `${target} on "${this.table}": PowQL's \`like\` has no escape character, so those characters would act ` +
|
|
1298
|
+
'as wildcards and match other rows. `startsWith` and `endsWith` match such values exactly; for a ' +
|
|
1299
|
+
'substring, filter in application code.');
|
|
1300
|
+
}
|
|
1301
|
+
return `${fold(expr)} like ${fold(this.param(`%${value}%`, params))}`;
|
|
1302
|
+
}
|
|
1303
|
+
const ph = this.param(value, params);
|
|
1304
|
+
const start = op === 'startsWith' ? '1' : `length(${expr}) - length(${ph}) + 1`;
|
|
1305
|
+
return `(length(${expr}) >= length(${ph}) and ${fold(`substring(${expr}, ${start}, length(${ph}))`)} = ${fold(ph)})`;
|
|
818
1306
|
}
|
|
819
1307
|
/**
|
|
820
1308
|
* `lhs [not] in ($1, $2, …)`, empty list collapses to a constant.
|
|
@@ -822,15 +1310,41 @@ export class PowqlInterface {
|
|
|
822
1310
|
* A PowDB-native `datetime` column takes the expanded form instead (see
|
|
823
1311
|
* {@link buildDatetimeInList}): its `in` list is still broken upstream at 0.20.
|
|
824
1312
|
*/
|
|
1313
|
+
/**
|
|
1314
|
+
* The enum-specific shapes of one operator on an enum column, or `undefined`
|
|
1315
|
+
* for an operator compiled the ordinary way (equality, `in` / `notIn`, `not`,
|
|
1316
|
+
* whose labels {@link param} checks).
|
|
1317
|
+
*
|
|
1318
|
+
* `gt` / `gte` / `lt` / `lte` compile to `in` over the labels on that side of
|
|
1319
|
+
* the operand in DECLARATION order, which is what PostgreSQL compares; the
|
|
1320
|
+
* text comparison PowDB would run is alphabetical, a different answer. A null
|
|
1321
|
+
* operand matches no row, as `col > NULL` does. Case-insensitive matching and
|
|
1322
|
+
* `contains` / `startsWith` / `endsWith` are E003: PostgreSQL has no `lower`
|
|
1323
|
+
* or `LIKE` on an enum (42883).
|
|
1324
|
+
*/
|
|
1325
|
+
enumRangeCondition(col, labels, ref, opName, opVal, insensitive, params) {
|
|
1326
|
+
if (insensitive || opName === 'contains' || opName === 'startsWith' || opName === 'endsWith') {
|
|
1327
|
+
throw new ValidationError(`${insensitive ? "`mode: 'insensitive'`" : `\`${opName}\``} on the enum column "${col.name}" of ` +
|
|
1328
|
+
`"${this.table}": an enum compares whole labels exactly. Use \`equals\`, \`in\` or \`notIn\` with its labels.`);
|
|
1329
|
+
}
|
|
1330
|
+
if (opName !== 'gt' && opName !== 'gte' && opName !== 'lt' && opName !== 'lte')
|
|
1331
|
+
return undefined;
|
|
1332
|
+
if (opVal === null)
|
|
1333
|
+
return this.alwaysFalse();
|
|
1334
|
+
this.assertEnumLabel(col, labels, opVal);
|
|
1335
|
+
const at = labels.indexOf(opVal);
|
|
1336
|
+
const side = labels.filter((_, i) => opName === 'gt' ? i > at : opName === 'gte' ? i >= at : opName === 'lt' ? i < at : i <= at);
|
|
1337
|
+
return this.buildInList(ref, side, params, false, false, col);
|
|
1338
|
+
}
|
|
825
1339
|
buildInList(lhs, values, params, insensitive, negate, col) {
|
|
826
1340
|
if (!Array.isArray(values) || values.length === 0) {
|
|
827
1341
|
// `in []` matches nothing; `not in []` matches everything (SQL parity requires a
|
|
828
1342
|
// missing-value row to match `notIn []`, so NO presence guard is appended here).
|
|
829
|
-
return negate ?
|
|
1343
|
+
return negate ? ALWAYS_TRUE : this.alwaysFalse();
|
|
830
1344
|
}
|
|
831
1345
|
if (col && isPowdbDatetimeColumn(col))
|
|
832
1346
|
return this.buildDatetimeInList(lhs, values, params, negate, col);
|
|
833
|
-
const items = values.map((v) => this.bind(v, params, insensitive)).join(', ');
|
|
1347
|
+
const items = values.map((v) => this.bind(v, params, insensitive, col)).join(', ');
|
|
834
1348
|
if (negate) {
|
|
835
1349
|
// PowQL `not in` matches missing-value rows, so append `and lhs is not null`
|
|
836
1350
|
// for SQL null-semantics parity (non-empty list only, per the empty-list note above).
|
|
@@ -911,21 +1425,33 @@ export class PowqlInterface {
|
|
|
911
1425
|
return col && isPowdbDatetimeColumn(col) ? MAX_POWQL_DATETIME_TERMS : MAX_RELATION_KEYS;
|
|
912
1426
|
}
|
|
913
1427
|
/**
|
|
914
|
-
* Pre-resolve every relation filter
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
* emits only `in (<literal list>)`.
|
|
1428
|
+
* Pre-resolve every relation filter in a where clause into a plain scalar
|
|
1429
|
+
* condition on the **local key**, by running the inner predicate as its own
|
|
1430
|
+
* query and materializing the matching keys as a literal list. The compiled
|
|
1431
|
+
* where is then relation-free and `buildWhere` emits only `in (<literal list>)`.
|
|
919
1432
|
*
|
|
920
1433
|
* Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
|
|
921
1434
|
* executor caches a subquery's result by **plan shape, ignoring the literal**,
|
|
922
1435
|
* so a second subquery of the same shape with a different value returns the
|
|
923
1436
|
* first one's stale rows (reproduced live on the embedded engine; the
|
|
924
1437
|
* single-statement literal `in (list)` form is always correct). Resolving
|
|
925
|
-
* client-side trades extra round-trips for correctness, and recurses
|
|
1438
|
+
* client-side trades extra round-trips for correctness, and recurses: nested
|
|
926
1439
|
* relation filters in the inner predicate resolve when the target query runs.
|
|
1440
|
+
*
|
|
1441
|
+
* `skip` is the query's `skipGlobalFilters`, forwarded to every target query
|
|
1442
|
+
* so the target's global filter bounds the related rows exactly as the SQL
|
|
1443
|
+
* engines' EXISTS does, and an opt-out reaches the targets as it does there.
|
|
1444
|
+
*/
|
|
1445
|
+
async resolveRelationFilters(where, timeout, skip) {
|
|
1446
|
+
if (!where)
|
|
1447
|
+
return where;
|
|
1448
|
+
return this.resolveRelationFiltersIn(this.sqlTrueWhere(where), timeout, skip);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* {@link resolveRelationFilters} over a where already in {@link sqlTrueWhere}'s
|
|
1452
|
+
* form (`AND` / `OR` arrays, `NOT` objects).
|
|
927
1453
|
*/
|
|
928
|
-
async
|
|
1454
|
+
async resolveRelationFiltersIn(where, timeout, skip) {
|
|
929
1455
|
if (!where)
|
|
930
1456
|
return where;
|
|
931
1457
|
const scalar = {};
|
|
@@ -934,76 +1460,195 @@ export class PowqlInterface {
|
|
|
934
1460
|
if (value === undefined)
|
|
935
1461
|
continue;
|
|
936
1462
|
if (key === 'AND' || key === 'OR') {
|
|
937
|
-
scalar[key] = await Promise.all(value.map((w) => this.
|
|
938
|
-
|
|
939
|
-
else if (key === 'NOT') {
|
|
940
|
-
scalar[key] = await this.resolveRelationFilters(value, timeout);
|
|
1463
|
+
scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFiltersIn(w, timeout, skip)));
|
|
1464
|
+
continue;
|
|
941
1465
|
}
|
|
942
|
-
|
|
943
|
-
|
|
1466
|
+
if (key === 'NOT') {
|
|
1467
|
+
scalar[key] = await this.resolveRelationFiltersIn(value, timeout, skip);
|
|
1468
|
+
continue;
|
|
944
1469
|
}
|
|
945
|
-
|
|
1470
|
+
const rel = resolveRelationDef(this.meta.relations, key);
|
|
1471
|
+
if (!rel) {
|
|
946
1472
|
scalar[key] = value;
|
|
1473
|
+
continue;
|
|
947
1474
|
}
|
|
1475
|
+
const cond = await this.resolveRelationCondition(rel, key, value, timeout, skip);
|
|
1476
|
+
if (cond)
|
|
1477
|
+
relConds.push(cond);
|
|
948
1478
|
}
|
|
949
1479
|
if (!relConds.length)
|
|
950
1480
|
return scalar;
|
|
951
1481
|
const hasScalar = Object.keys(scalar).length > 0;
|
|
952
1482
|
return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
|
|
953
1483
|
}
|
|
954
|
-
/**
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1484
|
+
/**
|
|
1485
|
+
* Resolve one relation filter to a relation-free condition on the local key,
|
|
1486
|
+
* with the SQL engines' meaning for every branch:
|
|
1487
|
+
*
|
|
1488
|
+
* some / is EXISTS a related row matching the inner where
|
|
1489
|
+
* none / isNot NOT EXISTS such a row (true for a NULL local key)
|
|
1490
|
+
* every NOT EXISTS a related row for which the inner where is FALSE
|
|
1491
|
+
* is: null NOT EXISTS any related row
|
|
1492
|
+
* isNot: null EXISTS a related row
|
|
1493
|
+
*
|
|
1494
|
+
* A bare object on a to-one relation is an implicit `is` (the shared
|
|
1495
|
+
* {@link normalizeRelationFilter} rule). Branches combine with AND. A shape
|
|
1496
|
+
* that names no branch, or names something else, is refused: compiling it to
|
|
1497
|
+
* nothing would match every row, and a mutation carrying it would write them.
|
|
1498
|
+
* Returns `null` when every branch key is `undefined` (an absent filter).
|
|
1499
|
+
*/
|
|
1500
|
+
async resolveRelationCondition(rel, key, value, timeout, skip) {
|
|
1501
|
+
const shapeError = (detail) => new ValidationError(`Invalid relation filter "${key}" on "${this.table}": ${detail}. ` +
|
|
1502
|
+
(rel.type === 'belongsTo' || rel.type === 'hasOne'
|
|
1503
|
+
? `A to-one relation takes \`{ is: {...} }\`, \`{ isNot: {...} }\`, a bare where object, or \`is: null\`.`
|
|
1504
|
+
: 'A to-many relation takes `{ some: {...} }`, `{ none: {...} }` or `{ every: {...} }`.'));
|
|
1505
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1506
|
+
throw shapeError(`expected an object, got ${describeOperand(value)}`);
|
|
1507
|
+
}
|
|
1508
|
+
// The shared rule; its first parameter is unused, so it needs no SQL builder context.
|
|
1509
|
+
const filter = normalizeRelationFilter(undefined, rel, value);
|
|
1510
|
+
const keys = Object.keys(filter);
|
|
1511
|
+
if (keys.length === 0)
|
|
1512
|
+
throw shapeError('the filter object is empty');
|
|
1513
|
+
const unknown = keys.filter((k) => !RELATION_FILTER_KEYS.includes(k));
|
|
1514
|
+
if (unknown.length)
|
|
1515
|
+
throw shapeError(`unrecognized key ${unknown.map((k) => `"${k}"`).join(', ')}`);
|
|
1516
|
+
if (keys.every((k) => filter[k] === undefined))
|
|
1517
|
+
return null;
|
|
1518
|
+
// The shared value rule: `some` / `none` / `every` take a where object,
|
|
1519
|
+
// `is` / `isNot` a where object or null. Checked for every branch before
|
|
1520
|
+
// any of them runs a query.
|
|
1521
|
+
for (const branch of RELATION_FILTER_KEYS) {
|
|
1522
|
+
const problem = relationFilterValueProblem(branch, filter[branch]);
|
|
1523
|
+
if (problem !== null)
|
|
1524
|
+
throw shapeError(problem);
|
|
1525
|
+
}
|
|
1526
|
+
// The shared cardinality rule: `is` / `isNot` on a to-many relation.
|
|
1527
|
+
const cardinality = relationCardinalityProblem(rel, filter);
|
|
1528
|
+
if (cardinality !== null)
|
|
1529
|
+
throw shapeError(cardinality);
|
|
1530
|
+
const conds = [];
|
|
1531
|
+
for (const branch of RELATION_FILTER_KEYS) {
|
|
1532
|
+
const inner = filter[branch];
|
|
1533
|
+
if (inner === undefined)
|
|
1534
|
+
continue;
|
|
1535
|
+
const where = inner;
|
|
1536
|
+
switch (branch) {
|
|
1537
|
+
case 'some':
|
|
1538
|
+
conds.push(await this.relationMatch(rel, where, true, timeout, skip));
|
|
1539
|
+
break;
|
|
1540
|
+
case 'none':
|
|
1541
|
+
conds.push(await this.relationMatch(rel, where, false, timeout, skip));
|
|
1542
|
+
break;
|
|
1543
|
+
case 'every': {
|
|
1544
|
+
// `every` over a where that compiles to no condition (`{}`, `AND: []`)
|
|
1545
|
+
// holds for every parent, as in SQL. Otherwise the parent fails when
|
|
1546
|
+
// one related row makes the predicate FALSE; a row that leaves it
|
|
1547
|
+
// unknown (a NULL column) does not count against it.
|
|
1548
|
+
const targetQi = this.relationTargetQi(rel);
|
|
1549
|
+
if (targetQi.compilesToNothing(where))
|
|
1550
|
+
break;
|
|
1551
|
+
const violates = targetQi.sqlFalseWhere(where);
|
|
1552
|
+
conds.push(await this.relationMatch(rel, violates, false, timeout, skip));
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
case 'is':
|
|
1556
|
+
conds.push(await this.relationMatch(rel, where ?? {}, where !== null, timeout, skip));
|
|
1557
|
+
break;
|
|
1558
|
+
case 'isNot':
|
|
1559
|
+
conds.push(await this.relationMatch(rel, where ?? {}, where === null, timeout, skip));
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (conds.length === 0)
|
|
958
1564
|
return {};
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1565
|
+
return conds.length === 1 ? conds[0] : { AND: conds };
|
|
1566
|
+
}
|
|
1567
|
+
/** A fresh interface over a relation's target table, for an internal fetch. */
|
|
1568
|
+
relationTargetQi(rel) {
|
|
1569
|
+
if (!this.schema.tables[rel.to]) {
|
|
1570
|
+
throw new ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
963
1571
|
}
|
|
1572
|
+
return new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* `EXISTS` (or `NOT EXISTS`, when `exists` is false) a related row matching
|
|
1576
|
+
* `inner`, as a condition on this table's local key.
|
|
1577
|
+
*
|
|
1578
|
+
* The NOT EXISTS form keeps a NULL local key: such a row has no related row,
|
|
1579
|
+
* so it qualifies. PowQL's `not in` alone would drop it.
|
|
1580
|
+
*/
|
|
1581
|
+
async relationMatch(rel, inner, exists, timeout, skip) {
|
|
1582
|
+
const { localField, keys } = rel.type === 'manyToMany'
|
|
1583
|
+
? await this.manyToManySourceKeys(rel, inner, timeout, skip)
|
|
1584
|
+
: await this.directRelationKeys(rel, inner, timeout, skip);
|
|
1585
|
+
if (exists)
|
|
1586
|
+
return { [localField]: { in: keys } };
|
|
1587
|
+
const localCol = this.column(localField);
|
|
1588
|
+
if (!localCol.nullable)
|
|
1589
|
+
return { [localField]: { notIn: keys } };
|
|
1590
|
+
return { OR: [{ [localField]: null }, { [localField]: { notIn: keys } }] };
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* hasMany / hasOne / belongsTo: the local field, and the distinct correlation
|
|
1594
|
+
* keys of the target rows matching `inner` (inside the target's global filter).
|
|
1595
|
+
* hasMany/hasOne: local = our referenceKey, collected = the child's foreignKey.
|
|
1596
|
+
* belongsTo: local = our foreignKey, collected = the target's referenceKey.
|
|
1597
|
+
*/
|
|
1598
|
+
async directRelationKeys(rel, inner, timeout, skip) {
|
|
964
1599
|
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
965
1600
|
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
966
1601
|
if (fk.length > 1 || rk.length > 1) {
|
|
967
1602
|
throw new UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key, PowQL has no tuple-\`in\` to express it`);
|
|
968
1603
|
}
|
|
969
|
-
const
|
|
970
|
-
if (!targetMeta)
|
|
971
|
-
throw new ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
972
|
-
// hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
|
|
973
|
-
// belongsTo: localKey = our foreignKey, collect the target's referenceKey.
|
|
1604
|
+
const targetQi = this.relationTargetQi(rel);
|
|
974
1605
|
const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
975
1606
|
const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
976
1607
|
const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
|
|
977
|
-
const childField =
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1608
|
+
const childField = targetQi.meta.reverseColumnMap[childCol] ?? childCol;
|
|
1609
|
+
const keys = await targetQi.collectRelationKeys(inner, childField, rel.name, timeout, skip);
|
|
1610
|
+
return { localField, keys };
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* The distinct non-null values of `field` over this table's rows matching
|
|
1614
|
+
* `where` (inside this table's global filter, honouring `skip`), refused
|
|
1615
|
+
* past {@link MAX_RELATION_FILTER_KEYS}.
|
|
1616
|
+
*/
|
|
1617
|
+
async collectRelationKeys(where, field, relName, timeout, skip) {
|
|
1618
|
+
const rows = await this.findMany({
|
|
1619
|
+
where: where,
|
|
1620
|
+
select: { [field]: true },
|
|
1621
|
+
timeout,
|
|
1622
|
+
...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
|
|
1623
|
+
});
|
|
1624
|
+
return this.distinctKeys(rows.map((r) => r[field]), relName);
|
|
1625
|
+
}
|
|
1626
|
+
/** Dedupe correlation keys by value (a Date or bigint by its value, not identity) and enforce the cap. */
|
|
1627
|
+
distinctKeys(values, relName) {
|
|
1628
|
+
const byKey = new Map();
|
|
1629
|
+
for (const v of values) {
|
|
1630
|
+
const k = this.joinKey(v);
|
|
1631
|
+
if (k !== null && !byKey.has(k))
|
|
1632
|
+
byKey.set(k, v);
|
|
1633
|
+
}
|
|
1634
|
+
if (byKey.size > MAX_RELATION_FILTER_KEYS) {
|
|
1635
|
+
throw new UnsupportedFeatureError(`A relation filter on "${relName}" matching ${byKey.size} distinct keys`, 'PowDB', `PowDB resolves a relation filter into a literal list of keys, which the engine checks row by row, so ` +
|
|
1636
|
+
`the cost grows with rows times keys; Turbine refuses lists over ${MAX_RELATION_FILTER_KEYS}. Narrow the ` +
|
|
1637
|
+
'inner predicate, or query from the related side instead (filter the related table and load the parent ' +
|
|
1638
|
+
'with `with`).');
|
|
1639
|
+
}
|
|
1640
|
+
return [...byKey.values()];
|
|
1641
|
+
}
|
|
1642
|
+
/** manyToMany: the source key field, and the source keys linked (through the junction) to targets matching `inner`. */
|
|
1643
|
+
async manyToManySourceKeys(rel, inner, timeout, skip) {
|
|
998
1644
|
const through = rel.through;
|
|
999
1645
|
if (!through)
|
|
1000
1646
|
throw new ValidationError(`manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
|
|
1001
1647
|
const sourceJ = normalizeKeyColumns(through.sourceKey);
|
|
1002
1648
|
const targetJ = normalizeKeyColumns(through.targetKey);
|
|
1003
1649
|
const sourceRef = normalizeKeyColumns(rel.referenceKey);
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
throw new ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
1650
|
+
const targetQi = this.relationTargetQi(rel);
|
|
1651
|
+
const targetMeta = targetQi.meta;
|
|
1007
1652
|
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
|
|
1008
1653
|
throw new UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}", PowQL has no tuple-\`in\` for composite junction/target keys`);
|
|
1009
1654
|
}
|
|
@@ -1011,50 +1656,158 @@ export class PowqlInterface {
|
|
|
1011
1656
|
const targetJCol = targetJ[0];
|
|
1012
1657
|
const sourceRefCol = sourceRef[0];
|
|
1013
1658
|
const targetPkCol = targetMeta.primaryKey[0];
|
|
1014
|
-
const
|
|
1659
|
+
const localField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
1015
1660
|
const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
|
|
1016
1661
|
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
select: { [targetPkField]: true },
|
|
1022
|
-
timeout,
|
|
1023
|
-
});
|
|
1024
|
-
return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
|
|
1025
|
-
};
|
|
1026
|
-
// Junction source keys linking any of `targetPks` (literal IN-list, never a subquery).
|
|
1662
|
+
const targetPks = await targetQi.collectRelationKeys(inner, targetPkField, rel.name, timeout, skip);
|
|
1663
|
+
if (!targetPks.length)
|
|
1664
|
+
return { localField, keys: [] };
|
|
1665
|
+
// Junction source keys linking any of the targets (literal in-lists, never a subquery).
|
|
1027
1666
|
const junctionMeta = this.schema.tables[through.table];
|
|
1028
1667
|
const targetJColMeta = junctionMeta?.columns.find((c) => c.name === targetJCol);
|
|
1029
1668
|
const junctionChunk = this.keyChunkSize(junctionMeta, targetJCol);
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1669
|
+
const sources = [];
|
|
1670
|
+
for (let i = 0; i < targetPks.length; i += junctionChunk) {
|
|
1671
|
+
const chunk = targetPks.slice(i, i + junctionChunk);
|
|
1672
|
+
const params = [];
|
|
1673
|
+
const keyClause = this.inClause(this.colRefName(targetJCol), chunk, params, targetJColMeta);
|
|
1674
|
+
const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter ${keyClause} { ${this.colRefName(sourceJCol)} }`, params, timeout, 'findMany');
|
|
1675
|
+
for (const r of rows) {
|
|
1676
|
+
const v = r[sourceJCol];
|
|
1677
|
+
if (v != null)
|
|
1678
|
+
sources.push(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return { localField, keys: this.distinctKeys(sources, rel.name) };
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Rows where `where` is TRUE under SQL three-valued logic. PowQL's own
|
|
1685
|
+
* evaluation already excludes a NULL from every comparison, so this differs
|
|
1686
|
+
* from `where` only under `NOT`, where PowQL takes the plain complement and
|
|
1687
|
+
* SQL keeps a NULL comparison unknown.
|
|
1688
|
+
*
|
|
1689
|
+
* Combinators are read by the shared {@link combinatorBranches} rule, so a
|
|
1690
|
+
* shape the SQL engines refuse is refused here (E003), and the output uses
|
|
1691
|
+
* one spelling: `AND` / `OR` arrays and `NOT` objects. `OR: []` stays as
|
|
1692
|
+
* written and compiles to no row.
|
|
1693
|
+
*/
|
|
1694
|
+
sqlTrueWhere(where) {
|
|
1695
|
+
const parts = [];
|
|
1696
|
+
for (const [key, value] of Object.entries(where)) {
|
|
1697
|
+
if (value === undefined)
|
|
1698
|
+
continue;
|
|
1699
|
+
if (isCombinatorKey(key)) {
|
|
1700
|
+
const branches = combinatorBranches(key, value, this.table);
|
|
1701
|
+
if (branches === null)
|
|
1702
|
+
continue;
|
|
1703
|
+
if (key === 'NOT') {
|
|
1704
|
+
for (const branch of branches) {
|
|
1705
|
+
// A branch that compiles to no condition is skipped, as on the SQL engines.
|
|
1706
|
+
if (!this.compilesToNothing(branch)) {
|
|
1707
|
+
parts.push(this.sqlFalseWhere(branch));
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
else if (key === 'OR' || branches.length > 0) {
|
|
1712
|
+
parts.push({ [key]: branches.map((w) => this.sqlTrueWhere(w)) });
|
|
1043
1713
|
}
|
|
1044
1714
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1715
|
+
else {
|
|
1716
|
+
parts.push({ [key]: value });
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (parts.length === 0)
|
|
1720
|
+
return {};
|
|
1721
|
+
return (parts.length === 1 ? parts[0] : { AND: parts });
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Rows where `where` is FALSE under SQL three-valued logic, the complement
|
|
1725
|
+
* PowQL's two-valued `not` cannot express: a comparison against a NULL
|
|
1726
|
+
* column is unknown, which is neither TRUE nor FALSE, so the row belongs to
|
|
1727
|
+
* neither set. Relation filters (EXISTS) are two-valued and negate plainly.
|
|
1728
|
+
*
|
|
1729
|
+
* A sub-where that compiles to no condition is TRUE, so it is FALSE for no
|
|
1730
|
+
* row and contributes nothing; `OR: []` is FALSE for every row.
|
|
1731
|
+
*/
|
|
1732
|
+
sqlFalseWhere(where) {
|
|
1733
|
+
const parts = [];
|
|
1734
|
+
for (const [key, value] of Object.entries(where)) {
|
|
1735
|
+
if (value === undefined)
|
|
1736
|
+
continue;
|
|
1737
|
+
if (isCombinatorKey(key)) {
|
|
1738
|
+
const branches = combinatorBranches(key, value, this.table);
|
|
1739
|
+
if (branches === null)
|
|
1740
|
+
continue;
|
|
1741
|
+
if (key === 'OR' && branches.length === 0) {
|
|
1742
|
+
parts.push(this.trueWhere());
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
const live = branches.filter((w) => !this.compilesToNothing(w));
|
|
1746
|
+
// An `OR` with a branch that has no condition is TRUE: FALSE for no row.
|
|
1747
|
+
if (live.length === 0 || (key === 'OR' && live.length < branches.length))
|
|
1748
|
+
continue;
|
|
1749
|
+
if (key === 'AND')
|
|
1750
|
+
parts.push({ OR: live.map((w) => this.sqlFalseWhere(w)) });
|
|
1751
|
+
else if (key === 'OR')
|
|
1752
|
+
parts.push({ AND: live.map((w) => this.sqlFalseWhere(w)) });
|
|
1753
|
+
else
|
|
1754
|
+
for (const w of live)
|
|
1755
|
+
parts.push(this.sqlTrueWhere(w));
|
|
1756
|
+
}
|
|
1757
|
+
else if (resolveRelationDef(this.meta.relations, key)) {
|
|
1758
|
+
parts.push({ NOT: { [key]: value } });
|
|
1759
|
+
}
|
|
1760
|
+
else {
|
|
1761
|
+
// A column leaf is FALSE when the column is non-null and the leaf does
|
|
1762
|
+
// not hold; on a NULL column only an explicit `not: null` (or an empty
|
|
1763
|
+
// `in`) makes it FALSE, every other comparison is unknown there.
|
|
1764
|
+
const op = value !== null && typeof value === 'object' ? value : undefined;
|
|
1765
|
+
const falseAtNull = op !== undefined && (op.not === null || (Array.isArray(op.in) && op.in.length === 0));
|
|
1766
|
+
const branch = { AND: [{ [key]: { not: null } }, { NOT: { [key]: value } }] };
|
|
1767
|
+
parts.push(falseAtNull ? { OR: [branch, { [key]: null }] } : branch);
|
|
1768
|
+
}
|
|
1049
1769
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1770
|
+
// A where with no condition is TRUE for every row, so FALSE for none.
|
|
1771
|
+
if (parts.length === 0)
|
|
1772
|
+
return this.falseWhere();
|
|
1773
|
+
return (parts.length === 1 ? parts[0] : { OR: parts });
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Whether a where compiles to no condition, as {@link buildWhere} and the
|
|
1777
|
+
* SQL where walk both read it: only absent keys, and `AND` / `NOT` whose
|
|
1778
|
+
* branches all compile to nothing (`AND: []`, `NOT: [{}]`). Any `OR` is a
|
|
1779
|
+
* condition: `OR: []` matches no row, and an `OR` with a branch that has no
|
|
1780
|
+
* condition is constant TRUE (`OR: [{}]`), which `NOT` negates. So is any
|
|
1781
|
+
* column or relation key.
|
|
1782
|
+
*/
|
|
1783
|
+
compilesToNothing(where) {
|
|
1784
|
+
if (where === null || typeof where !== 'object' || Array.isArray(where))
|
|
1785
|
+
return false;
|
|
1786
|
+
for (const [key, value] of Object.entries(where)) {
|
|
1787
|
+
if (value === undefined)
|
|
1788
|
+
continue;
|
|
1789
|
+
if (!isCombinatorKey(key))
|
|
1790
|
+
return false;
|
|
1791
|
+
const branches = combinatorBranches(key, value, this.table);
|
|
1792
|
+
if (branches === null)
|
|
1793
|
+
continue;
|
|
1794
|
+
if (key === 'OR')
|
|
1795
|
+
return false;
|
|
1796
|
+
if (!branches.every((w) => this.compilesToNothing(w)))
|
|
1797
|
+
return false;
|
|
1052
1798
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
/** A where clause that matches every row (the primary key is null or not null). */
|
|
1802
|
+
trueWhere() {
|
|
1803
|
+
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name ?? 'id';
|
|
1804
|
+
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
1805
|
+
return { OR: [{ [field]: null }, { [field]: { not: null } }] };
|
|
1806
|
+
}
|
|
1807
|
+
/** A where clause that matches no row (an empty `in` on the primary key). */
|
|
1808
|
+
falseWhere() {
|
|
1809
|
+
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name ?? 'id';
|
|
1810
|
+
return { [this.meta.reverseColumnMap[pk] ?? pk]: { in: [] } };
|
|
1058
1811
|
}
|
|
1059
1812
|
// -------------------------------------------------------------------------
|
|
1060
1813
|
// Projection / order
|
|
@@ -1063,8 +1816,9 @@ export class PowqlInterface {
|
|
|
1063
1816
|
* Resolve the set of columns to project, honouring `select` / `omit` and the
|
|
1064
1817
|
* query-level `includePii` opt-in. PII-tagged (`defineSchema` `pii: true`)
|
|
1065
1818
|
* columns are EXCLUDED from a default (or omit-only) projection unless
|
|
1066
|
-
* `includePii` is
|
|
1067
|
-
*
|
|
1819
|
+
* `includePii: UNSAFE` is passed, and a `select` naming one without it is
|
|
1820
|
+
* refused (E003), as on the SQL engines. Untagged tables project exactly as
|
|
1821
|
+
* before.
|
|
1068
1822
|
*/
|
|
1069
1823
|
/**
|
|
1070
1824
|
* Resolve one projection field name, or throw. PowQL already refused an
|
|
@@ -1128,6 +1882,23 @@ export class PowqlInterface {
|
|
|
1128
1882
|
const forcedPk = [];
|
|
1129
1883
|
const hasSelect = select && Object.keys(select).length;
|
|
1130
1884
|
if (hasSelect) {
|
|
1885
|
+
// A PII column named in `select` is refused unless the read passes
|
|
1886
|
+
// `includePii: UNSAFE`, as on the SQL engines. A key the loader added
|
|
1887
|
+
// itself (a PII-tagged correlation column) carries the stitch-only mark.
|
|
1888
|
+
if (!includePii) {
|
|
1889
|
+
const pii = this.piiColumnNames();
|
|
1890
|
+
if (pii.size) {
|
|
1891
|
+
const stitchOnly = stitchOnlyFields(select);
|
|
1892
|
+
for (const [key, on] of Object.entries(select)) {
|
|
1893
|
+
if (!on || stitchOnly?.has(key))
|
|
1894
|
+
continue;
|
|
1895
|
+
const column = this.projectionColumn(key, 'select');
|
|
1896
|
+
if (pii.has(column) && !pk.has(column)) {
|
|
1897
|
+
throw new ValidationError(piiSelectRefusedMessage(this.table, this.meta.reverseColumnMap[column] ?? column));
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1131
1902
|
const picked = new Set(Object.entries(select)
|
|
1132
1903
|
.filter(([, v]) => v)
|
|
1133
1904
|
.map(([k]) => this.projectionColumn(k, 'select')));
|
|
@@ -1177,6 +1948,34 @@ export class PowqlInterface {
|
|
|
1177
1948
|
* every consumer of the key (relation stitching, reselect) has finished with
|
|
1178
1949
|
* it. A no-op, and free, when nothing was forced.
|
|
1179
1950
|
*/
|
|
1951
|
+
/**
|
|
1952
|
+
* The columns of this table that the post-fetch relation loaders read off
|
|
1953
|
+
* each parent row for `withClause`: a belongsTo's foreign key, a hasMany /
|
|
1954
|
+
* hasOne / manyToMany's reference key, and the same for every relation a
|
|
1955
|
+
* `_count` names. An unknown name contributes nothing; the loader raises its
|
|
1956
|
+
* E005.
|
|
1957
|
+
*/
|
|
1958
|
+
loaderParentKeyColumns(withClause) {
|
|
1959
|
+
const out = new Set();
|
|
1960
|
+
const add = (rel) => {
|
|
1961
|
+
const cols = rel.type === 'belongsTo' ? rel.foreignKey : rel.referenceKey;
|
|
1962
|
+
for (const c of normalizeKeyColumns(cols))
|
|
1963
|
+
out.add(c);
|
|
1964
|
+
};
|
|
1965
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
1966
|
+
if (!opt)
|
|
1967
|
+
continue;
|
|
1968
|
+
if (relName === '_count') {
|
|
1969
|
+
for (const rel of resolveCountRelations(this.meta, opt))
|
|
1970
|
+
add(rel);
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
const rel = ownLookup(this.meta.relations, relName);
|
|
1974
|
+
if (rel)
|
|
1975
|
+
add(rel);
|
|
1976
|
+
}
|
|
1977
|
+
return [...out];
|
|
1978
|
+
}
|
|
1180
1979
|
stripForcedPk(entities, forcedPk) {
|
|
1181
1980
|
if (forcedPk.length === 0 || entities.length === 0)
|
|
1182
1981
|
return;
|
|
@@ -1317,6 +2116,8 @@ export class PowqlInterface {
|
|
|
1317
2116
|
if (spec.nulls === 'first') {
|
|
1318
2117
|
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
1319
2118
|
}
|
|
2119
|
+
this.assertDecimalComparable(this.column(field), 'Ordering');
|
|
2120
|
+
this.assertEnumOrderable(this.column(field), 'Ordering');
|
|
1320
2121
|
return `${this.ref(field, alias)} ${this.dirKeyword(spec.sort, `orderBy on "${field}"`)}`;
|
|
1321
2122
|
}
|
|
1322
2123
|
// Name the actual feature in the refusal, a pick-row ordering
|
|
@@ -1333,6 +2134,8 @@ export class PowqlInterface {
|
|
|
1333
2134
|
: 'object-valued ordering';
|
|
1334
2135
|
throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
1335
2136
|
}
|
|
2137
|
+
this.assertDecimalComparable(this.column(field), 'Ordering');
|
|
2138
|
+
this.assertEnumOrderable(this.column(field), 'Ordering');
|
|
1336
2139
|
return `${this.ref(field, alias)} ${this.dirKeyword(dir, `orderBy on "${field}"`)}`;
|
|
1337
2140
|
});
|
|
1338
2141
|
return ` order ${parts.join(', ')}`;
|
|
@@ -1340,7 +2143,7 @@ export class PowqlInterface {
|
|
|
1340
2143
|
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
1341
2144
|
buildJsonPathOrder(field, spec, params, alias) {
|
|
1342
2145
|
const col = this.column(field);
|
|
1343
|
-
if (!isJsonColumn(col)) {
|
|
2146
|
+
if (!isJsonColumn(col, this.schema.enums)) {
|
|
1344
2147
|
throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
1345
2148
|
}
|
|
1346
2149
|
requireCapability(this.capabilities, 'jsonDocs', 'JSON path ordering');
|
|
@@ -1460,16 +2263,21 @@ export class PowqlInterface {
|
|
|
1460
2263
|
// `QueryInterface.executeWithMiddleware`. The option surface is the CORE
|
|
1461
2264
|
// one because these args ARE core's args; PowDB reads them, it does not
|
|
1462
2265
|
// define them.
|
|
1463
|
-
warnUnknownQueryOptions(this.table, action, args);
|
|
2266
|
+
warnUnknownQueryOptions(this.table, action, args, this.advice ?? null);
|
|
1464
2267
|
if (this.middlewares.length === 0)
|
|
1465
2268
|
return executor();
|
|
2269
|
+
// A copy, as on the SQL engines: the statement is built from `args`, not
|
|
2270
|
+
// from what middleware sees, so `next()` throws on a changed copy rather
|
|
2271
|
+
// than doing nothing.
|
|
2272
|
+
const params = readOnlyMiddlewareParams(this.table, action, args);
|
|
1466
2273
|
let index = 0;
|
|
1467
2274
|
const next = async (p) => {
|
|
2275
|
+
assertMiddlewareArgsKept(params, p);
|
|
1468
2276
|
if (index < this.middlewares.length)
|
|
1469
2277
|
return this.middlewares[index++](p, next);
|
|
1470
2278
|
return executor();
|
|
1471
2279
|
};
|
|
1472
|
-
return next(
|
|
2280
|
+
return next(params);
|
|
1473
2281
|
}
|
|
1474
2282
|
/** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
|
|
1475
2283
|
* this result (threaded from {@link execOnce}, not the pool-level capability),
|
|
@@ -1479,15 +2287,13 @@ export class PowqlInterface {
|
|
|
1479
2287
|
* coerces its string cells correctly. Defaults to the pool capability for the
|
|
1480
2288
|
* rare caller with no per-result flag (hand-built test pools). */
|
|
1481
2289
|
shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
|
|
1482
|
-
return rows.map((r) => rowToEntity(r, this.meta, native));
|
|
2290
|
+
return rows.map((r) => rowToEntity(r, this.meta, native, this.schema.enums));
|
|
1483
2291
|
}
|
|
1484
|
-
// -------------------------------------------------------------------------
|
|
1485
|
-
// Reads
|
|
1486
|
-
// -------------------------------------------------------------------------
|
|
1487
2292
|
async findMany(args = {}) {
|
|
1488
2293
|
this.assertNoForceCustomPlan(args);
|
|
2294
|
+
this.assertNoRowLock(args);
|
|
1489
2295
|
this.assertNoJsonEncoding(args);
|
|
1490
|
-
args = this.normalizeArgs(args);
|
|
2296
|
+
args = this.completePageOrder(this.normalizeArgs(args));
|
|
1491
2297
|
return this.withMiddleware('findMany', args, async () => {
|
|
1492
2298
|
// `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
|
|
1493
2299
|
// is correct on every engine version: PowDB's projection fast path returned
|
|
@@ -1503,7 +2309,7 @@ export class PowqlInterface {
|
|
|
1503
2309
|
if (linkPlans.length)
|
|
1504
2310
|
this.attachLinkRows(entities, linkPlans, native);
|
|
1505
2311
|
if (residualWith) {
|
|
1506
|
-
await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
2312
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, resolveUnsafeFlag(args.includePii, 'includePii'), args.skipGlobalFilters);
|
|
1507
2313
|
}
|
|
1508
2314
|
// LAST: every consumer of the internally-forced PK (relation stitching,
|
|
1509
2315
|
// the loaders' correlation) has finished with it by here.
|
|
@@ -1525,7 +2331,11 @@ export class PowqlInterface {
|
|
|
1525
2331
|
* to the pre-0.18 output (no alias, `.col` refs).
|
|
1526
2332
|
*/
|
|
1527
2333
|
async buildFind(args, params) {
|
|
1528
|
-
|
|
2334
|
+
// A cursor is checked with the SQL engines' rules first, so an invalid one
|
|
2335
|
+
// is E003 on every engine; a valid one is an engine gap (E017).
|
|
2336
|
+
const cursor = args.cursor;
|
|
2337
|
+
if (this.cursorCarriesValues(cursor)) {
|
|
2338
|
+
this.assertCursorPlaceable(cursor, args.orderBy);
|
|
1529
2339
|
throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
1530
2340
|
}
|
|
1531
2341
|
// `distinct` names COLUMNS: Postgres compiles it to `SELECT DISTINCT ON
|
|
@@ -1538,16 +2348,18 @@ export class PowqlInterface {
|
|
|
1538
2348
|
// dialect seam exists to prevent.
|
|
1539
2349
|
//
|
|
1540
2350
|
// Refused with the same E017 the other non-Postgres engines already raise
|
|
1541
|
-
// (query/builder.ts's `
|
|
2351
|
+
// (query/builder.ts's `distinctOn` capability gate) and in the same
|
|
1542
2352
|
// words, so `distinct` now means one thing across every engine and matches
|
|
1543
2353
|
// what the README documents. Refused BEFORE the column names are resolved,
|
|
1544
2354
|
// matching that gate's position, so `distinct: ['nope']` reports the
|
|
1545
|
-
// unsupported feature rather than the typo on every engine alike.
|
|
2355
|
+
// unsupported feature rather than the typo on every engine alike. The
|
|
2356
|
+
// element shape is checked first, as there: a non-string element is E003.
|
|
1546
2357
|
if (args.distinct?.length) {
|
|
2358
|
+
dedupeColumnList(this.meta, args.distinct);
|
|
1547
2359
|
throw new UnsupportedFeatureError('DISTINCT ON (findMany distinct)', 'PowDB', "findMany({ distinct }) requires PostgreSQL: PowQL's `distinct` is row-wide, not per-column. " +
|
|
1548
2360
|
'Group in application code, or use groupBy({ by }) for one row per combination.');
|
|
1549
2361
|
}
|
|
1550
|
-
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
2362
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
|
|
1551
2363
|
const { cols, forcedPk } = this.projectionPlan(args.select, args.omit, resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
1552
2364
|
// Partition the `with` clause: nested-projection blocks vs loader residue.
|
|
1553
2365
|
// A relation whose field name collides with a projected parent column stays
|
|
@@ -1558,6 +2370,8 @@ export class PowqlInterface {
|
|
|
1558
2370
|
// with a `distinct` present and testing for it again would be dead code
|
|
1559
2371
|
// implying the option is still accepted.
|
|
1560
2372
|
const withClause = args.with;
|
|
2373
|
+
if (withClause)
|
|
2374
|
+
this.assertCountTopLevelOnly(withClause, 0);
|
|
1561
2375
|
const nestedPlans = [];
|
|
1562
2376
|
const linkPlans = [];
|
|
1563
2377
|
let residualWith = withClause;
|
|
@@ -1579,7 +2393,7 @@ export class PowqlInterface {
|
|
|
1579
2393
|
// inside, only when a genuine candidate reaches it), else it stays on the
|
|
1580
2394
|
// loaders.
|
|
1581
2395
|
const linkPlan = rel && !cols.includes(relName)
|
|
1582
|
-
? await this.planLinkPathRelation(relName, rel, opt, resolveUnsafeFlag(args.includePii, 'includePii'), cols, linkPlans.length + 1)
|
|
2396
|
+
? await this.planLinkPathRelation(relName, rel, opt, resolveUnsafeFlag(args.includePii, 'includePii'), cols, linkPlans.length + 1, args.skipGlobalFilters)
|
|
1583
2397
|
: null;
|
|
1584
2398
|
if (linkPlan)
|
|
1585
2399
|
linkPlans.push(linkPlan);
|
|
@@ -1588,17 +2402,34 @@ export class PowqlInterface {
|
|
|
1588
2402
|
}
|
|
1589
2403
|
residualWith = Object.keys(residue).length ? residue : undefined;
|
|
1590
2404
|
}
|
|
2405
|
+
// The loaders (and the native join) read each parent's correlation key off
|
|
2406
|
+
// the fetched row, so a `select` / `omit` that leaves it out would stitch
|
|
2407
|
+
// nothing. Fetch it anyway and take it back off afterwards, as the SQL
|
|
2408
|
+
// engines' batched loader does.
|
|
2409
|
+
if (residualWith) {
|
|
2410
|
+
for (const column of this.loaderParentKeyColumns(residualWith)) {
|
|
2411
|
+
if (cols.includes(column))
|
|
2412
|
+
continue;
|
|
2413
|
+
cols.push(column);
|
|
2414
|
+
forcedPk.push(column);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
1591
2417
|
const nest = nestedPlans.length > 0 || linkPlans.length > 0;
|
|
1592
2418
|
const alias = nest ? 't0' : undefined;
|
|
1593
2419
|
let where = this.buildWhere(resolvedWhere, params, alias);
|
|
1594
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters, alias);
|
|
2420
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout), alias);
|
|
1595
2421
|
const filter = where ? ` filter ${where}` : '';
|
|
1596
2422
|
const order = this.buildOrder(args.orderBy, params, alias);
|
|
1597
2423
|
const limit = this.effectiveLimit(args);
|
|
1598
2424
|
this.assertPagination(limit, args.offset, 'findMany');
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2425
|
+
// Once per table per process, as on the SQL engines: a transaction builds
|
|
2426
|
+
// its own interfaces, so a per-interface flag repeated it in each one.
|
|
2427
|
+
if (limit === undefined && this.warnOnUnlimited && shouldWarnOnce(WARN_NS.unlimitedFindMany, this.table)) {
|
|
2428
|
+
logTo(this.logger, 'warn', `findMany on "${this.table}" has no limit: this scans the whole table.`, {
|
|
2429
|
+
code: 'unlimited-find-many',
|
|
2430
|
+
table: this.table,
|
|
2431
|
+
operation: 'findMany',
|
|
2432
|
+
});
|
|
1602
2433
|
}
|
|
1603
2434
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
1604
2435
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
@@ -1609,7 +2440,7 @@ export class PowqlInterface {
|
|
|
1609
2440
|
const aliasCtr = { n: 1 };
|
|
1610
2441
|
const parts = cols.map((c) => `${quotePowqlIdent(c)}: t0.${quotePowqlIdent(c)}`);
|
|
1611
2442
|
for (const plan of nestedPlans) {
|
|
1612
|
-
parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
|
|
2443
|
+
parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout, args.skipGlobalFilters));
|
|
1613
2444
|
}
|
|
1614
2445
|
// Scalar link-path hops (flat native-typed fields; never a JSON block).
|
|
1615
2446
|
for (const plan of linkPlans)
|
|
@@ -1648,7 +2479,7 @@ export class PowqlInterface {
|
|
|
1648
2479
|
// written with `take` / `skip` would report a plan for a DIFFERENT
|
|
1649
2480
|
// statement than the one that executes, which is the one thing a
|
|
1650
2481
|
// diagnostic must not do.
|
|
1651
|
-
args = this.normalizeArgs(args);
|
|
2482
|
+
args = this.completePageOrder(this.normalizeArgs(args));
|
|
1652
2483
|
const params = [];
|
|
1653
2484
|
const { powql } = await this.buildFind(args, params);
|
|
1654
2485
|
const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
|
|
@@ -1661,6 +2492,7 @@ export class PowqlInterface {
|
|
|
1661
2492
|
}
|
|
1662
2493
|
async findUnique(args) {
|
|
1663
2494
|
this.assertNoForceCustomPlan(args);
|
|
2495
|
+
this.assertNoRowLock(args);
|
|
1664
2496
|
this.assertNoJsonEncoding(args);
|
|
1665
2497
|
args = this.normalizeArgs(args);
|
|
1666
2498
|
// Prisma compound-unique selector → column conjunction (engine parity with
|
|
@@ -1685,17 +2517,21 @@ export class PowqlInterface {
|
|
|
1685
2517
|
if (linkPlans.length)
|
|
1686
2518
|
this.attachLinkRows(entities, linkPlans, native);
|
|
1687
2519
|
if (residualWith)
|
|
1688
|
-
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
2520
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, resolveUnsafeFlag(args.includePii, 'includePii'), args.skipGlobalFilters);
|
|
1689
2521
|
// See findMany: the strip is last, after every consumer of the key.
|
|
1690
2522
|
this.stripForcedPk(entities, forcedPk);
|
|
1691
2523
|
return entities[0];
|
|
1692
2524
|
});
|
|
1693
2525
|
}
|
|
1694
2526
|
async findFirst(args = {}) {
|
|
1695
|
-
args = this.normalizeArgs(args);
|
|
2527
|
+
args = this.normalizeArgs(args, 'findFirst');
|
|
1696
2528
|
this.assertNoForceCustomPlan(args);
|
|
2529
|
+
this.assertNoRowLock(args);
|
|
1697
2530
|
return this.withMiddleware('findFirst', args, async () => {
|
|
1698
|
-
|
|
2531
|
+
// An `offset` makes this a page of one, which gets the page tiebreaker
|
|
2532
|
+
// (the row would otherwise depend on which tied rows were skipped).
|
|
2533
|
+
const first = { ...args, limit: 1 };
|
|
2534
|
+
const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind(args.offset === undefined ? first : this.completePageOrder(first), 'findFirst');
|
|
1699
2535
|
if (!rows.length)
|
|
1700
2536
|
return null;
|
|
1701
2537
|
const entities = this.shape(rows, native);
|
|
@@ -1704,7 +2540,7 @@ export class PowqlInterface {
|
|
|
1704
2540
|
if (linkPlans.length)
|
|
1705
2541
|
this.attachLinkRows(entities, linkPlans, native);
|
|
1706
2542
|
if (residualWith)
|
|
1707
|
-
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
2543
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, resolveUnsafeFlag(args.includePii, 'includePii'), args.skipGlobalFilters);
|
|
1708
2544
|
// See findMany: the strip is last, after every consumer of the key.
|
|
1709
2545
|
this.stripForcedPk(entities, forcedPk);
|
|
1710
2546
|
return entities[0];
|
|
@@ -1717,6 +2553,7 @@ export class PowqlInterface {
|
|
|
1717
2553
|
return row;
|
|
1718
2554
|
}
|
|
1719
2555
|
async findFirstOrThrow(args = {}) {
|
|
2556
|
+
args = this.normalizeArgs(args, 'findFirstOrThrow');
|
|
1720
2557
|
const row = await this.findFirst(args);
|
|
1721
2558
|
if (!row)
|
|
1722
2559
|
throw new NotFoundError({ table: this.table, where: (args.where ?? {}) });
|
|
@@ -1736,10 +2573,30 @@ export class PowqlInterface {
|
|
|
1736
2573
|
* the default `'batched'` strategy) keeps the loaders. Output is byte-equal
|
|
1737
2574
|
* either way (the join reuses the same stitch / shape helpers).
|
|
1738
2575
|
*/
|
|
1739
|
-
async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false) {
|
|
2576
|
+
async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false, skip) {
|
|
1740
2577
|
if (depth >= 10) {
|
|
1741
2578
|
throw new ValidationError(`Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
1742
2579
|
}
|
|
2580
|
+
// Every relation name and the `_count` spec are checked BEFORE the
|
|
2581
|
+
// zero-parent return, so whether a `with` is accepted never depends on
|
|
2582
|
+
// whether the base query found rows: the SQL engines compile it either way.
|
|
2583
|
+
// Same errors as the SQL engines: E005 for an unknown relation (and for a
|
|
2584
|
+
// `_count` below the top level), E003 for a `_count` naming a to-one.
|
|
2585
|
+
const countSpec = withClause._count;
|
|
2586
|
+
if (countSpec) {
|
|
2587
|
+
if (depth > 0) {
|
|
2588
|
+
throw new RelationError(`Unknown relation "_count" on table "${this.table}". ` +
|
|
2589
|
+
`${availableClause(Object.keys(this.meta.relations), 'It has no relations.')} ` +
|
|
2590
|
+
'(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
|
|
2591
|
+
}
|
|
2592
|
+
resolveCountRelations(this.meta, countSpec);
|
|
2593
|
+
}
|
|
2594
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
2595
|
+
if (opt && relName !== '_count' && !ownLookup(this.meta.relations, relName)) {
|
|
2596
|
+
throw new RelationError(`Unknown relation "${relName}" on table "${this.table}". ` +
|
|
2597
|
+
availableClause(Object.keys(this.meta.relations), 'It has no relations.'));
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
1743
2600
|
if (!parents.length)
|
|
1744
2601
|
return;
|
|
1745
2602
|
// The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
|
|
@@ -1749,14 +2606,12 @@ export class PowqlInterface {
|
|
|
1749
2606
|
// key, …) never triggers the capability's E017.
|
|
1750
2607
|
const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
|
|
1751
2608
|
for (const [relName, opt] of Object.entries(withClause)) {
|
|
1752
|
-
if (!opt)
|
|
2609
|
+
if (!opt || relName === '_count')
|
|
1753
2610
|
continue;
|
|
1754
|
-
const rel = this.meta.relations
|
|
1755
|
-
if (!rel)
|
|
1756
|
-
throw new ValidationError(`Unknown relation "${relName}" on "${this.table}".`);
|
|
2611
|
+
const rel = ownLookup(this.meta.relations, relName);
|
|
1757
2612
|
if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args)) {
|
|
1758
2613
|
if (this.capabilities.serverJoins) {
|
|
1759
|
-
await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
|
|
2614
|
+
await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii, skip);
|
|
1760
2615
|
continue;
|
|
1761
2616
|
}
|
|
1762
2617
|
// An otherwise-eligible relation the engine cannot join: a PER-QUERY
|
|
@@ -1768,7 +2623,7 @@ export class PowqlInterface {
|
|
|
1768
2623
|
}
|
|
1769
2624
|
}
|
|
1770
2625
|
if (rel.type === 'manyToMany') {
|
|
1771
|
-
await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii);
|
|
2626
|
+
await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii, skip);
|
|
1772
2627
|
continue;
|
|
1773
2628
|
}
|
|
1774
2629
|
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
@@ -1823,7 +2678,7 @@ export class PowqlInterface {
|
|
|
1823
2678
|
if (userSelect) {
|
|
1824
2679
|
fetchOptions = {
|
|
1825
2680
|
...options,
|
|
1826
|
-
select: { ...userSelect, [childKeyField]: true },
|
|
2681
|
+
select: markStitchOnly({ ...userSelect, [childKeyField]: true }, [childKeyField]),
|
|
1827
2682
|
};
|
|
1828
2683
|
}
|
|
1829
2684
|
else if (userOmit) {
|
|
@@ -1856,6 +2711,9 @@ export class PowqlInterface {
|
|
|
1856
2711
|
// Re-entering the public findMany, so the opt-in has to be spelled
|
|
1857
2712
|
// in its PUBLIC form (the sentinel); a plain `true` is refused there.
|
|
1858
2713
|
includePii: includePii ? UNSAFE : undefined,
|
|
2714
|
+
// The query's opt-out reaches the related table, as on the SQL engines;
|
|
2715
|
+
// without one the target's own global filter applies inside findMany.
|
|
2716
|
+
...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
|
|
1859
2717
|
}));
|
|
1860
2718
|
for (const child of children) {
|
|
1861
2719
|
const k = this.joinKey(child[childKeyField]);
|
|
@@ -1884,6 +2742,175 @@ export class PowqlInterface {
|
|
|
1884
2742
|
: PowqlInterface.pageOf(matches, relLimit, relOffset);
|
|
1885
2743
|
}
|
|
1886
2744
|
}
|
|
2745
|
+
// Last, so `_count` is the final key on each row, as on the SQL engines.
|
|
2746
|
+
if (countSpec)
|
|
2747
|
+
await this.loadRelationCounts(parents, countSpec, timeout, skip);
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* Refuse a `_count` anywhere below the top-level `with`, as the SQL engines
|
|
2751
|
+
* do (E005, their message). Checked over the whole tree before anything runs,
|
|
2752
|
+
* because a loader re-enters the child table's public findMany, where a
|
|
2753
|
+
* nested `_count` would otherwise read as top level and be counted.
|
|
2754
|
+
*/
|
|
2755
|
+
assertCountTopLevelOnly(withClause, depth) {
|
|
2756
|
+
if (depth >= 10)
|
|
2757
|
+
return;
|
|
2758
|
+
if (depth > 0 && withClause._count) {
|
|
2759
|
+
throw new RelationError(`Unknown relation "_count" on table "${this.table}". ` +
|
|
2760
|
+
`${availableClause(Object.keys(this.meta.relations), 'It has no relations.')} ` +
|
|
2761
|
+
'(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
|
|
2762
|
+
}
|
|
2763
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
2764
|
+
if (!opt || opt === true || relName === '_count')
|
|
2765
|
+
continue;
|
|
2766
|
+
const nested = opt.with;
|
|
2767
|
+
const rel = ownLookup(this.meta.relations, relName);
|
|
2768
|
+
if (!rel || !nested || typeof nested !== 'object' || !this.schema.tables[rel.to])
|
|
2769
|
+
continue;
|
|
2770
|
+
new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions()).assertCountTopLevelOnly(nested, depth + 1);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* The reserved `with: { _count: ... }` key: one count per selected to-many
|
|
2775
|
+
* relation, attached as `_count: { rel: n }` (0 for a parent with no rows),
|
|
2776
|
+
* which is the SQL engines' output. The relations are selected by the shared
|
|
2777
|
+
* `resolveCountRelations`, so `true` counts every to-many relation and the
|
|
2778
|
+
* refusals match. Each count honours the counted table's global filter the
|
|
2779
|
+
* way the SQL engines' correlated `COUNT(*)` does: hasMany counts the
|
|
2780
|
+
* surviving child rows, manyToMany counts junction rows whose TARGET
|
|
2781
|
+
* survives. One grouped query per relation per key chunk.
|
|
2782
|
+
*/
|
|
2783
|
+
async loadRelationCounts(parents, countSpec, timeout, skip) {
|
|
2784
|
+
const rels = resolveCountRelations(this.meta, countSpec);
|
|
2785
|
+
const countsOf = new Map();
|
|
2786
|
+
for (const parent of parents) {
|
|
2787
|
+
const counts = {};
|
|
2788
|
+
for (const rel of rels)
|
|
2789
|
+
counts[rel.name] = 0;
|
|
2790
|
+
const row = parent;
|
|
2791
|
+
delete row._count;
|
|
2792
|
+
row._count = counts;
|
|
2793
|
+
countsOf.set(parent, counts);
|
|
2794
|
+
}
|
|
2795
|
+
for (const rel of rels) {
|
|
2796
|
+
const { parentKeyField, byKey } = rel.type === 'manyToMany'
|
|
2797
|
+
? await this.countManyToMany(parents, rel, timeout, skip)
|
|
2798
|
+
: await this.countHasMany(parents, rel, timeout, skip);
|
|
2799
|
+
for (const parent of parents) {
|
|
2800
|
+
const k = this.joinKey(parent[parentKeyField]);
|
|
2801
|
+
countsOf.get(parent)[rel.name] = (k == null ? undefined : byKey.get(k)) ?? 0;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
/** Child rows per parent key for a hasMany `_count`, through the child's own groupBy. */
|
|
2806
|
+
async countHasMany(parents, rel, timeout, skip) {
|
|
2807
|
+
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
2808
|
+
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
2809
|
+
if (fk.length > 1 || rk.length > 1) {
|
|
2810
|
+
throw new UnsupportedFeatureError('composite-key relation _count', 'PowDB', `relation "${rel.name}"`);
|
|
2811
|
+
}
|
|
2812
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
2813
|
+
if (!targetMeta)
|
|
2814
|
+
throw new ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
2815
|
+
const parentKeyField = this.meta.reverseColumnMap[rk[0]] ?? rk[0];
|
|
2816
|
+
const childKeyField = targetMeta.reverseColumnMap[fk[0]] ?? fk[0];
|
|
2817
|
+
const keys = [
|
|
2818
|
+
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
2819
|
+
];
|
|
2820
|
+
const byKey = new Map();
|
|
2821
|
+
const childQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
|
|
2822
|
+
const chunkSize = this.keyChunkSize(targetMeta, fk[0]);
|
|
2823
|
+
for (let i = 0; i < keys.length; i += chunkSize) {
|
|
2824
|
+
const groups = (await childQi.groupBy({
|
|
2825
|
+
by: [childKeyField],
|
|
2826
|
+
where: { [childKeyField]: { in: keys.slice(i, i + chunkSize) } },
|
|
2827
|
+
_count: true,
|
|
2828
|
+
timeout,
|
|
2829
|
+
// The key is only a stitch key and never returned, so a PII-tagged
|
|
2830
|
+
// foreign key does not make the count a PII read.
|
|
2831
|
+
includePii: UNSAFE,
|
|
2832
|
+
...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
|
|
2833
|
+
}));
|
|
2834
|
+
for (const g of groups) {
|
|
2835
|
+
const k = this.joinKey(g[childKeyField]);
|
|
2836
|
+
if (k != null)
|
|
2837
|
+
byKey.set(k, Number(g._count ?? 0));
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
return { parentKeyField, byKey };
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Junction rows per parent key for a manyToMany `_count`. When the target
|
|
2844
|
+
* table has a global filter in force, only junction rows whose target row
|
|
2845
|
+
* survives it are counted, as the SQL engines' `EXISTS` on the target does.
|
|
2846
|
+
*/
|
|
2847
|
+
async countManyToMany(parents, rel, timeout, skip) {
|
|
2848
|
+
const through = rel.through;
|
|
2849
|
+
if (!through)
|
|
2850
|
+
throw new ValidationError(`manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
|
|
2851
|
+
const sourceJ = normalizeKeyColumns(through.sourceKey);
|
|
2852
|
+
const targetJ = normalizeKeyColumns(through.targetKey);
|
|
2853
|
+
const sourceRef = normalizeKeyColumns(rel.referenceKey);
|
|
2854
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
2855
|
+
if (!targetMeta)
|
|
2856
|
+
throw new ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
2857
|
+
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
|
|
2858
|
+
throw new UnsupportedFeatureError('composite-key relation _count', 'PowDB', `relation "${rel.name}"`);
|
|
2859
|
+
}
|
|
2860
|
+
const parentKeyField = this.meta.reverseColumnMap[sourceRef[0]] ?? sourceRef[0];
|
|
2861
|
+
const keys = [
|
|
2862
|
+
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
2863
|
+
];
|
|
2864
|
+
// (1) Junction rows for these parents.
|
|
2865
|
+
const junctionMeta = this.schema.tables[through.table];
|
|
2866
|
+
const sourceJColMeta = junctionMeta?.columns.find((c) => c.name === sourceJ[0]);
|
|
2867
|
+
const pairs = [];
|
|
2868
|
+
const chunkSize = this.keyChunkSize(junctionMeta, sourceJ[0]);
|
|
2869
|
+
for (let i = 0; i < keys.length; i += chunkSize) {
|
|
2870
|
+
const params = [];
|
|
2871
|
+
const keyClause = this.inClause(this.colRefName(sourceJ[0]), keys.slice(i, i + chunkSize), params, sourceJColMeta);
|
|
2872
|
+
const powql = `${quotePowqlIdent(through.table)} filter ${keyClause} ` +
|
|
2873
|
+
`{ ${this.colRefName(sourceJ[0])}, ${this.colRefName(targetJ[0])} }`;
|
|
2874
|
+
const { rows } = await this.exec(powql, params, timeout, 'findMany');
|
|
2875
|
+
for (const row of rows) {
|
|
2876
|
+
const sk = this.joinKey(row[sourceJ[0]]);
|
|
2877
|
+
const tk = this.joinKey(row[targetJ[0]]);
|
|
2878
|
+
if (sk != null && tk != null)
|
|
2879
|
+
pairs.push([sk, tk, row[targetJ[0]]]);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
// (2) With a target global filter in force, keep the targets that survive it.
|
|
2883
|
+
const gf = resolveGlobalFilterFrom(this.options.globalFilters, rel.to, resolveSkipGlobalFilters(skip), this.schema.tables);
|
|
2884
|
+
let surviving = null;
|
|
2885
|
+
if (gf && pairs.length) {
|
|
2886
|
+
surviving = new Set();
|
|
2887
|
+
const pkCol = targetMeta.primaryKey[0];
|
|
2888
|
+
const pkField = targetMeta.reverseColumnMap[pkCol] ?? pkCol;
|
|
2889
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
|
|
2890
|
+
const targets = [...new Map(pairs.map(([, tk, raw]) => [tk, raw])).values()];
|
|
2891
|
+
const targetChunk = this.keyChunkSize(targetMeta, pkCol);
|
|
2892
|
+
for (let i = 0; i < targets.length; i += targetChunk) {
|
|
2893
|
+
const rows = (await targetQi.findMany({
|
|
2894
|
+
where: { [pkField]: { in: targets.slice(i, i + targetChunk) } },
|
|
2895
|
+
select: { [pkField]: true },
|
|
2896
|
+
timeout,
|
|
2897
|
+
includePii: UNSAFE,
|
|
2898
|
+
...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
|
|
2899
|
+
}));
|
|
2900
|
+
for (const row of rows) {
|
|
2901
|
+
const k = this.joinKey(row[pkField]);
|
|
2902
|
+
if (k != null)
|
|
2903
|
+
surviving.add(k);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
const byKey = new Map();
|
|
2908
|
+
for (const [sk, tk] of pairs) {
|
|
2909
|
+
if (surviving && !surviving.has(tk))
|
|
2910
|
+
continue;
|
|
2911
|
+
byKey.set(sk, (byKey.get(sk) ?? 0) + 1);
|
|
2912
|
+
}
|
|
2913
|
+
return { parentKeyField, byKey };
|
|
1887
2914
|
}
|
|
1888
2915
|
/**
|
|
1889
2916
|
* manyToMany nested read, a three-hop batched loader (no `json_agg`/join
|
|
@@ -1893,7 +2920,7 @@ export class PowqlInterface {
|
|
|
1893
2920
|
* single-key N+1 loaders; the junction's source/target columns must be single
|
|
1894
2921
|
* (composite junction keys would need PowQL tuple-`in`, which it lacks).
|
|
1895
2922
|
*/
|
|
1896
|
-
async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false) {
|
|
2923
|
+
async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false, skip) {
|
|
1897
2924
|
const through = rel.through;
|
|
1898
2925
|
if (!through)
|
|
1899
2926
|
throw new ValidationError(`manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
@@ -1980,7 +3007,7 @@ export class PowqlInterface {
|
|
|
1980
3007
|
if (userSelect) {
|
|
1981
3008
|
fetchOptions = {
|
|
1982
3009
|
...options,
|
|
1983
|
-
select: { ...userSelect, [targetPkField]: true },
|
|
3010
|
+
select: markStitchOnly({ ...userSelect, [targetPkField]: true }, [targetPkField]),
|
|
1984
3011
|
};
|
|
1985
3012
|
}
|
|
1986
3013
|
else if (userOmit) {
|
|
@@ -2013,6 +3040,7 @@ export class PowqlInterface {
|
|
|
2013
3040
|
timeout: options.timeout ?? timeout,
|
|
2014
3041
|
// Public findMany, so the sentinel form. See loadRelation above.
|
|
2015
3042
|
includePii: includePii ? UNSAFE : undefined,
|
|
3043
|
+
...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
|
|
2016
3044
|
}));
|
|
2017
3045
|
for (const t of targets) {
|
|
2018
3046
|
const pk = String(t[targetPkField]);
|
|
@@ -2150,9 +3178,9 @@ export class PowqlInterface {
|
|
|
2150
3178
|
return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
|
|
2151
3179
|
}
|
|
2152
3180
|
/** Dispatch one eligible relation to the correct native-join loader. */
|
|
2153
|
-
async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
|
|
3181
|
+
async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false, skip) {
|
|
2154
3182
|
if (rel.type === 'manyToMany') {
|
|
2155
|
-
await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
|
|
3183
|
+
await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii, skip);
|
|
2156
3184
|
return;
|
|
2157
3185
|
}
|
|
2158
3186
|
const options = (opt === true ? {} : opt);
|
|
@@ -2171,7 +3199,7 @@ export class PowqlInterface {
|
|
|
2171
3199
|
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
2172
3200
|
const params = [];
|
|
2173
3201
|
const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
|
|
2174
|
-
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
|
|
3202
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout, skip);
|
|
2175
3203
|
const order = targetQi.buildOrder(options.orderBy, params, 'c');
|
|
2176
3204
|
// No limit/offset clause: on the join they would bound the TOTAL child
|
|
2177
3205
|
// count across every parent. The relation's page is per parent (pageOf).
|
|
@@ -2197,7 +3225,7 @@ export class PowqlInterface {
|
|
|
2197
3225
|
* → the already-fetched side (alias `p`), correlating `__tpk` from the
|
|
2198
3226
|
* junction's source key. Always a list, stitched exactly like the loader.
|
|
2199
3227
|
*/
|
|
2200
|
-
async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
|
|
3228
|
+
async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false, skip) {
|
|
2201
3229
|
const through = rel.through;
|
|
2202
3230
|
if (!through)
|
|
2203
3231
|
throw new ValidationError(`manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
@@ -2213,7 +3241,7 @@ export class PowqlInterface {
|
|
|
2213
3241
|
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
2214
3242
|
const params = [];
|
|
2215
3243
|
const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
|
|
2216
|
-
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
|
|
3244
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout, skip);
|
|
2217
3245
|
const proj = this.joinProjection(childCols, `j.${quotePowqlIdent(sourceJCol)}`, 't');
|
|
2218
3246
|
const powql = `${targetQi.qt} as t ` +
|
|
2219
3247
|
`join ${quotePowqlIdent(through.table)} as j on t.${quotePowqlIdent(targetPkCol)} = j.${quotePowqlIdent(targetJCol)} ` +
|
|
@@ -2257,14 +3285,18 @@ export class PowqlInterface {
|
|
|
2257
3285
|
* to literal in-lists before the base query ran); the relation where is resolved
|
|
2258
3286
|
* on the target the same way before qualifying, so a nested relation filter in
|
|
2259
3287
|
* the relation `where` never reaches the join unresolved. Params bind in order.
|
|
3288
|
+
* Each side carries its own table's global filter under the query's `skip`,
|
|
3289
|
+
* so the join returns exactly the rows the loaders would.
|
|
2260
3290
|
*/
|
|
2261
|
-
async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
|
|
3291
|
+
async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout, skip) {
|
|
2262
3292
|
const parts = [];
|
|
2263
|
-
const
|
|
3293
|
+
const parentGf = await this.globalFilterWhere(skip, timeout);
|
|
3294
|
+
const pw = this.applyGlobalFilter(this.buildWhere(parentResolvedWhere, params, 'p'), params, parentGf, 'p');
|
|
2264
3295
|
if (pw)
|
|
2265
3296
|
parts.push(pw);
|
|
2266
|
-
const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
|
|
2267
|
-
const
|
|
3297
|
+
const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout, skip);
|
|
3298
|
+
const targetGf = await targetQi.globalFilterWhere(skip, timeout);
|
|
3299
|
+
const rw = targetQi.applyGlobalFilter(targetQi.buildWhere(relResolved, params, childAlias), params, targetGf, childAlias);
|
|
2268
3300
|
if (rw)
|
|
2269
3301
|
parts.push(rw);
|
|
2270
3302
|
return parts.length ? ` filter ${parts.join(' and ')}` : '';
|
|
@@ -2406,8 +3438,12 @@ export class PowqlInterface {
|
|
|
2406
3438
|
* Aliases share the parent statement's counter, so arbitrarily deep and
|
|
2407
3439
|
* self-referential trees stay collision-free (the same discipline as the SQL
|
|
2408
3440
|
* engine's json_agg subqueries).
|
|
3441
|
+
*
|
|
3442
|
+
* The target's global filter is ANDed into the block's filter at every level,
|
|
3443
|
+
* under the query's `skip`, exactly as the SQL engines AND it into each
|
|
3444
|
+
* relation subquery: a block that omitted it handed back another tenant's rows.
|
|
2409
3445
|
*/
|
|
2410
|
-
async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout) {
|
|
3446
|
+
async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout, skip) {
|
|
2411
3447
|
const { rel, relName, targetQi, options, single } = plan;
|
|
2412
3448
|
const alias = `t${aliasCtr.n++}`;
|
|
2413
3449
|
const fk = normalizeKeyColumns(rel.foreignKey)[0];
|
|
@@ -2418,8 +3454,9 @@ export class PowqlInterface {
|
|
|
2418
3454
|
// child where chains behind it as a parenthesized `and` group, which the
|
|
2419
3455
|
// grammar accepts even when the group itself contains `or`.
|
|
2420
3456
|
const correlation = `${alias}.${quotePowqlIdent(childKeyCol)} = ${parentAlias}.${quotePowqlIdent(parentKeyCol)}`;
|
|
2421
|
-
const resolved = await targetQi.resolveRelationFilters(options.where, timeout);
|
|
2422
|
-
const
|
|
3457
|
+
const resolved = await targetQi.resolveRelationFilters(options.where, timeout, skip);
|
|
3458
|
+
const targetGf = await targetQi.globalFilterWhere(skip, timeout);
|
|
3459
|
+
const childWhere = targetQi.applyGlobalFilter(targetQi.buildWhere(resolved, params, alias), params, targetGf, alias);
|
|
2423
3460
|
const filter = childWhere ? `${correlation} and (${childWhere})` : correlation;
|
|
2424
3461
|
const order = targetQi.buildOrder(options.orderBy, params, alias);
|
|
2425
3462
|
const limitClause = single
|
|
@@ -2430,7 +3467,7 @@ export class PowqlInterface {
|
|
|
2430
3467
|
const offsetClause = !single && options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
2431
3468
|
const parts = plan.cols.map((c) => `${quotePowqlIdent(c)}: ${alias}.${quotePowqlIdent(c)}`);
|
|
2432
3469
|
for (const child of plan.children) {
|
|
2433
|
-
parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout));
|
|
3470
|
+
parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout, skip));
|
|
2434
3471
|
}
|
|
2435
3472
|
return (`${quotePowqlIdent(relName)}: ${targetQi.qt} as ${alias} ` +
|
|
2436
3473
|
`filter ${filter}${order}${limitClause}${offsetClause} { ${parts.join(', ')} }`);
|
|
@@ -2461,7 +3498,7 @@ export class PowqlInterface {
|
|
|
2461
3498
|
}
|
|
2462
3499
|
}
|
|
2463
3500
|
const items = Array.isArray(value) ? value : [];
|
|
2464
|
-
const shaped = items.map((o) => rowToEntity(o, plan.targetQi.meta, true));
|
|
3501
|
+
const shaped = items.map((o) => rowToEntity(o, plan.targetQi.meta, true, this.schema.enums));
|
|
2465
3502
|
for (const child of shaped) {
|
|
2466
3503
|
for (const sub of plan.children)
|
|
2467
3504
|
plan.targetQi.attachOneNested(child, sub);
|
|
@@ -2545,12 +3582,13 @@ export class PowqlInterface {
|
|
|
2545
3582
|
* link path would regress a hot path for no gain. Requires: single-column
|
|
2546
3583
|
* belongsTo; no relation `with` / `where` / `distinct` / `orderBy` /
|
|
2547
3584
|
* `limit` / `offset` (a scalar path has no per-hop filter/order and cannot
|
|
2548
|
-
* reproduce those, such inputs stay on the loader for exact parity);
|
|
3585
|
+
* reproduce those, such inputs stay on the loader for exact parity); no
|
|
3586
|
+
* global filter on the target, for the same reason; a link
|
|
2549
3587
|
* name and all projected columns that are bare identifiers (a quoted segment in
|
|
2550
3588
|
* a dotted link path is outside the verified spelling, fall back); and a
|
|
2551
3589
|
* DECLARED link that verifiably matches (`findMatchingLink`).
|
|
2552
3590
|
*/
|
|
2553
|
-
async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index) {
|
|
3591
|
+
async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index, skip) {
|
|
2554
3592
|
if (!this.capabilities.linkPaths)
|
|
2555
3593
|
return null;
|
|
2556
3594
|
if (rel.type !== 'belongsTo')
|
|
@@ -2567,6 +3605,10 @@ export class PowqlInterface {
|
|
|
2567
3605
|
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
2568
3606
|
return null;
|
|
2569
3607
|
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
3608
|
+
// A link hop has no per-hop filter, so it cannot carry the target's global
|
|
3609
|
+
// filter; such a relation stays on the loaders, which apply it.
|
|
3610
|
+
if (targetQi.hasGlobalFilter(skip))
|
|
3611
|
+
return null;
|
|
2570
3612
|
// `projectionPlan`, not `projectedColumns`: the plan is what carries
|
|
2571
3613
|
// `forcedPk`, and reading the column LIST alone is what made this path's
|
|
2572
3614
|
// strip dead. See the pkProjected line below.
|
|
@@ -2644,7 +3686,7 @@ export class PowqlInterface {
|
|
|
2644
3686
|
raw[c] = row[`${plan.keyPrefix}${c}`];
|
|
2645
3687
|
delete row[`${plan.keyPrefix}${c}`];
|
|
2646
3688
|
}
|
|
2647
|
-
const child = rowToEntity(raw, plan.targetQi.meta, native);
|
|
3689
|
+
const child = rowToEntity(raw, plan.targetQi.meta, native, this.schema.enums);
|
|
2648
3690
|
if (child[pkField] == null) {
|
|
2649
3691
|
row[plan.relName] = null;
|
|
2650
3692
|
continue;
|
|
@@ -2695,6 +3737,42 @@ export class PowqlInterface {
|
|
|
2695
3737
|
}
|
|
2696
3738
|
return out;
|
|
2697
3739
|
}
|
|
3740
|
+
/**
|
|
3741
|
+
* Fill every omitted column that declares a default, since PowDB has no
|
|
3742
|
+
* column defaults of its own (its only generated value is `auto`, which is
|
|
3743
|
+
* left to the engine). Without this an omitted defaulted column was written
|
|
3744
|
+
* as NULL, silently, or failed NOT NULL where every SQL engine applied the
|
|
3745
|
+
* default.
|
|
3746
|
+
*
|
|
3747
|
+
* "Omitted" means absent or `undefined` under BOTH spellings, exactly the
|
|
3748
|
+
* columns a SQL insert leaves to the database; an explicit `null` stays
|
|
3749
|
+
* `null`. The value comes from the column's declared default expression,
|
|
3750
|
+
* evaluated per row (`now()` is the insert time). An expression this cannot
|
|
3751
|
+
* evaluate faithfully, or metadata that does not carry the expression, is
|
|
3752
|
+
* refused rather than guessed.
|
|
3753
|
+
*/
|
|
3754
|
+
applyColumnDefaults(data) {
|
|
3755
|
+
let out;
|
|
3756
|
+
for (const col of this.meta.columns) {
|
|
3757
|
+
if (!col.hasDefault || col.isGenerated || this.meta.primaryKey.includes(col.name))
|
|
3758
|
+
continue;
|
|
3759
|
+
if (data[col.field] !== undefined || data[col.name] !== undefined)
|
|
3760
|
+
continue;
|
|
3761
|
+
const expr = col.defaultValue;
|
|
3762
|
+
const value = expr == null ? NO_DEFAULT : emulateColumnDefault(expr, col, this.schema.enums);
|
|
3763
|
+
if (value === NO_DEFAULT) {
|
|
3764
|
+
throw new UnsupportedFeatureError(`The default of column "${col.name}" on "${this.table}"`, 'PowDB', (expr == null
|
|
3765
|
+
? 'The schema metadata marks the column as defaulted but does not carry the default expression'
|
|
3766
|
+
: `The default expression \`${expr}\` has no client-side equivalent`) +
|
|
3767
|
+
', and PowDB has no column defaults, so the value cannot be applied. Supply the field explicitly ' +
|
|
3768
|
+
'on create (or build the metadata with defineSchema, which records literal, now(), current_date and ' +
|
|
3769
|
+
'gen_random_uuid() defaults).');
|
|
3770
|
+
}
|
|
3771
|
+
out ??= { ...data };
|
|
3772
|
+
out[col.field] = value;
|
|
3773
|
+
}
|
|
3774
|
+
return out ?? data;
|
|
3775
|
+
}
|
|
2698
3776
|
/**
|
|
2699
3777
|
* The table name as a PowQL type reference, backtick-quoted when it is a
|
|
2700
3778
|
* reserved word (e.g. a table named `order`). Used in every emitted
|
|
@@ -2704,12 +3782,13 @@ export class PowqlInterface {
|
|
|
2704
3782
|
return quotePowqlIdent(this.table);
|
|
2705
3783
|
}
|
|
2706
3784
|
async create(args) {
|
|
3785
|
+
args = this.normalizeArgs(args);
|
|
2707
3786
|
return this.withMiddleware('create', args, async () => {
|
|
2708
3787
|
const plan = this.writeReturnPlan(args);
|
|
2709
3788
|
if (hasRelationFields(args.data, this.meta)) {
|
|
2710
3789
|
return this.nestedCreate(args);
|
|
2711
3790
|
}
|
|
2712
|
-
const data = this.applyPkDefault(args.data);
|
|
3791
|
+
const data = this.applyColumnDefaults(this.applyPkDefault(args.data));
|
|
2713
3792
|
const assigns = this.scalarData(data);
|
|
2714
3793
|
const params = [];
|
|
2715
3794
|
const body = assigns
|
|
@@ -2770,16 +3849,28 @@ export class PowqlInterface {
|
|
|
2770
3849
|
}
|
|
2771
3850
|
}
|
|
2772
3851
|
async createMany(args) {
|
|
3852
|
+
args = this.normalizeArgs(args);
|
|
2773
3853
|
return this.withMiddleware('createMany', args, async () => {
|
|
3854
|
+
if (args.onConflict !== undefined) {
|
|
3855
|
+
throw new UnsupportedFeatureError('createMany({ onConflict })', 'powdb', "PowQL's upsert statement takes one row. Call upsert() per row inside $transaction.");
|
|
3856
|
+
}
|
|
3857
|
+
// Validated as every engine validates it; it acts only on onConflict's
|
|
3858
|
+
// update, which is refused above.
|
|
3859
|
+
resolveSkipGlobalFilters(args.skipGlobalFilters);
|
|
2774
3860
|
if (args.skipDuplicates) {
|
|
2775
3861
|
// PowQL's `insert … returning` has no conflict clause, so there is no
|
|
2776
3862
|
// faithful skip-duplicates form; refuse rather than silently insert.
|
|
2777
3863
|
throw new UnsupportedFeatureError('createMany({ skipDuplicates: true })', 'powdb', 'PowQL insert has no ON CONFLICT DO NOTHING equivalent, filter duplicates before inserting.');
|
|
2778
3864
|
}
|
|
2779
|
-
|
|
2780
|
-
|
|
3865
|
+
// Resolved before the write, so a bad projection writes nothing.
|
|
3866
|
+
const plan = this.writeReturnPlan(args);
|
|
3867
|
+
const supplied = args.data.map((d) => this.applyPkDefault(d));
|
|
3868
|
+
if (!supplied.length)
|
|
2781
3869
|
return [];
|
|
2782
|
-
|
|
3870
|
+
// Uniformity is judged on what the caller supplied, as the SQL engines
|
|
3871
|
+
// judge it; defaults are then filled per row.
|
|
3872
|
+
this.assertUniformCreateManyRows(supplied);
|
|
3873
|
+
const inputs = supplied.map((d) => this.applyColumnDefaults(d));
|
|
2783
3874
|
const params = [];
|
|
2784
3875
|
const tuples = inputs.map((d) => {
|
|
2785
3876
|
const assigns = this.scalarData(d);
|
|
@@ -2787,10 +3878,11 @@ export class PowqlInterface {
|
|
|
2787
3878
|
});
|
|
2788
3879
|
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
2789
3880
|
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
2790
|
-
return this.shape(rows, native).map((r) => this.
|
|
3881
|
+
return this.shape(rows, native).map((r) => this.shapeWriteRow(r, plan));
|
|
2791
3882
|
});
|
|
2792
3883
|
}
|
|
2793
3884
|
async update(args) {
|
|
3885
|
+
args = this.normalizeArgs(args);
|
|
2794
3886
|
return this.withMiddleware('update', args, async () => {
|
|
2795
3887
|
const plan = this.writeReturnPlan(args);
|
|
2796
3888
|
if (hasRelationFields(args.data, this.meta)) {
|
|
@@ -2804,13 +3896,13 @@ export class PowqlInterface {
|
|
|
2804
3896
|
// counts as the key it is (engine parity with the SQL buildUpdate).
|
|
2805
3897
|
const userWhere = this.expandedWhere(args.where);
|
|
2806
3898
|
const params = [];
|
|
2807
|
-
const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
|
|
3899
|
+
const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout, args.skipGlobalFilters);
|
|
2808
3900
|
let where = this.buildWhere(resolvedWhere, params);
|
|
2809
3901
|
// `false` here refused an empty where even WITH the opt-in, while every
|
|
2810
3902
|
// SQL engine accepted it: verified by probe on buildUpdate/buildDelete.
|
|
2811
|
-
this.assertCompiledWhere(where, allowFullTableScan, 'update');
|
|
3903
|
+
this.assertCompiledWhere(where, allowFullTableScan, 'update', userWhere);
|
|
2812
3904
|
this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'update');
|
|
2813
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
|
|
3905
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
2814
3906
|
let setClause = this.buildUpdateAssignments(args.data, params);
|
|
2815
3907
|
// Optimistic locking, matching the SQL engines exactly: bump the version
|
|
2816
3908
|
// column in the SET, add `version = expected` to the filter, and treat
|
|
@@ -2845,12 +3937,15 @@ export class PowqlInterface {
|
|
|
2845
3937
|
});
|
|
2846
3938
|
}
|
|
2847
3939
|
async updateMany(args) {
|
|
3940
|
+
args = this.normalizeArgs(args);
|
|
3941
|
+
assertNoBulkWritePaging(this.table, 'updateMany', args);
|
|
3942
|
+
this.assertNoOptimisticLock(args, 'updateMany');
|
|
2848
3943
|
return this.withMiddleware('updateMany', args, async () => {
|
|
2849
3944
|
const params = [];
|
|
2850
|
-
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
3945
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
|
|
2851
3946
|
let where = this.buildWhere(resolvedWhere, params);
|
|
2852
|
-
this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'updateMany');
|
|
2853
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
|
|
3947
|
+
this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'updateMany', args.where);
|
|
3948
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
2854
3949
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
2855
3950
|
const filter = where ? ` filter ${where}` : '';
|
|
2856
3951
|
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
|
|
@@ -2866,25 +3961,29 @@ export class PowqlInterface {
|
|
|
2866
3961
|
if (resolveRelationDef(this.meta.relations, field)) {
|
|
2867
3962
|
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update()/upsert(), not updateMany()`);
|
|
2868
3963
|
}
|
|
3964
|
+
// PowQL update assignments take values and the atomic operators only.
|
|
3965
|
+
if (isSqlExpr(value) || isColumnRef(value)) {
|
|
3966
|
+
throw new UnsupportedFeatureError(`update expressions (expr / col() in update data, "${this.table}.${field}")`, 'powdb', 'Compute the value in JavaScript and write it as a plain value, or use the atomic operators ' +
|
|
3967
|
+
'(increment, decrement, multiply, divide).');
|
|
3968
|
+
}
|
|
2869
3969
|
const colMeta = this.column(field);
|
|
2870
3970
|
const ref = this.ref(field);
|
|
2871
3971
|
const col = quotePowqlIdent(colMeta.name);
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
else
|
|
2885
|
-
throw new ValidationError(`Unsupported update operator on "${field}".`);
|
|
3972
|
+
const op = this.setOperatorOf(colMeta, value);
|
|
3973
|
+
if (op === 'set') {
|
|
3974
|
+
parts.push(`${col} := ${this.writeRef(value.set, colMeta, params)}`);
|
|
3975
|
+
}
|
|
3976
|
+
else if (op !== null) {
|
|
3977
|
+
this.assertDecimalComparable(colMeta, `\`${op}\``);
|
|
3978
|
+
// Same rule and message as the SQL engines' buildSetClauses.
|
|
3979
|
+
const operand = value[op];
|
|
3980
|
+
if (typeof operand !== 'number' || !Number.isFinite(operand)) {
|
|
3981
|
+
throw new ValidationError(`update operator "${op}" on "${this.table}.${field}" requires a finite number, got ${typeof operand}`);
|
|
3982
|
+
}
|
|
3983
|
+
parts.push(`${col} := ${ref} ${POWQL_ARITHMETIC[op]} ${this.writeRef(operand, colMeta, params)}`);
|
|
2886
3984
|
}
|
|
2887
3985
|
else {
|
|
3986
|
+
this.assertBindableSetValue(field, colMeta, value);
|
|
2888
3987
|
parts.push(`${col} := ${this.writeRef(value, colMeta, params)}`);
|
|
2889
3988
|
}
|
|
2890
3989
|
}
|
|
@@ -2892,6 +3991,40 @@ export class PowqlInterface {
|
|
|
2892
3991
|
throw new ValidationError(`update on "${this.table}" has no fields to set.`);
|
|
2893
3992
|
return parts.join(', ');
|
|
2894
3993
|
}
|
|
3994
|
+
/**
|
|
3995
|
+
* The atomic operator an update value names, or `null` for a plain value.
|
|
3996
|
+
* The SQL engines' `setOperatorOf` rule: a plain object with EXACTLY one key
|
|
3997
|
+
* from {@link UPDATE_OPERATOR_KEYS}, and on a json column only `set`, since
|
|
3998
|
+
* an object there IS the document (`{ increment: 1 }` is the document
|
|
3999
|
+
* `{"increment":1}`, and `{ set: doc }` writes a document shaped like
|
|
4000
|
+
* `{ set: ... }`).
|
|
4001
|
+
*/
|
|
4002
|
+
setOperatorOf(colMeta, value) {
|
|
4003
|
+
if (!isUnmatchedPlainObject(value))
|
|
4004
|
+
return null;
|
|
4005
|
+
const keys = Object.keys(value);
|
|
4006
|
+
if (keys.length !== 1 || !UPDATE_OPERATOR_KEYS.has(keys[0]))
|
|
4007
|
+
return null;
|
|
4008
|
+
const op = keys[0];
|
|
4009
|
+
if (op !== 'set' && isJsonColumn(colMeta, this.schema.enums))
|
|
4010
|
+
return null;
|
|
4011
|
+
return op;
|
|
4012
|
+
}
|
|
4013
|
+
/**
|
|
4014
|
+
* A one-key plain object that is not an update operator, written to a
|
|
4015
|
+
* non-json column, is a misspelled operator (`{ incremnt: 1 }`): refused with
|
|
4016
|
+
* the SQL engines' message rather than stored as text.
|
|
4017
|
+
*/
|
|
4018
|
+
assertBindableSetValue(field, colMeta, value) {
|
|
4019
|
+
if (!isUnmatchedPlainObject(value) || isJsonColumn(colMeta, this.schema.enums))
|
|
4020
|
+
return;
|
|
4021
|
+
const keys = Object.keys(value);
|
|
4022
|
+
if (keys.length !== 1 || UPDATE_OPERATOR_KEYS.has(keys[0]))
|
|
4023
|
+
return;
|
|
4024
|
+
throw new ValidationError(`Unknown update operator "${keys[0]}" on "${this.table}.${field}". ` +
|
|
4025
|
+
`Supported: ${[...UPDATE_OPERATOR_KEYS].join(', ')}. ` +
|
|
4026
|
+
'A plain object is only written as a value on a json/jsonb column.');
|
|
4027
|
+
}
|
|
2895
4028
|
// -------------------------------------------------------------------------
|
|
2896
4029
|
// Nested writes, create/update whose `data` carries relation ops (create,
|
|
2897
4030
|
// connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
|
|
@@ -2948,7 +4081,9 @@ export class PowqlInterface {
|
|
|
2948
4081
|
this.pool);
|
|
2949
4082
|
const ctx = {
|
|
2950
4083
|
schema: this.schema,
|
|
2951
|
-
|
|
4084
|
+
// The nested engine's internal `updateMany` for `set` reaches the
|
|
4085
|
+
// privilege sentinel through the same seam the SQL engines use.
|
|
4086
|
+
tx: unlockNestedWriteTx(tx),
|
|
2952
4087
|
scopedConnect: this.options?.scopedConnect === true,
|
|
2953
4088
|
};
|
|
2954
4089
|
// Plant the single-writer re-entrancy marker for the implicit tx's
|
|
@@ -2985,19 +4120,21 @@ export class PowqlInterface {
|
|
|
2985
4120
|
const tx = {
|
|
2986
4121
|
table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
|
|
2987
4122
|
};
|
|
2988
|
-
return { schema: this.schema, tx: tx };
|
|
4123
|
+
return { schema: this.schema, tx: unlockNestedWriteTx(tx) };
|
|
2989
4124
|
}
|
|
2990
4125
|
async delete(args) {
|
|
4126
|
+
args = this.normalizeArgs(args);
|
|
4127
|
+
this.assertNoOptimisticLock(args, 'delete');
|
|
2991
4128
|
return this.withMiddleware('delete', args, async () => {
|
|
2992
4129
|
const plan = this.writeReturnPlan(args);
|
|
2993
4130
|
const allowFullTableScan = resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan');
|
|
2994
4131
|
const userWhere = this.expandedWhere(args.where);
|
|
2995
4132
|
const params = [];
|
|
2996
|
-
const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
|
|
4133
|
+
const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout, args.skipGlobalFilters);
|
|
2997
4134
|
let where = this.buildWhere(resolvedWhere, params);
|
|
2998
|
-
this.assertCompiledWhere(where, allowFullTableScan, 'delete');
|
|
4135
|
+
this.assertCompiledWhere(where, allowFullTableScan, 'delete', userWhere);
|
|
2999
4136
|
this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'delete');
|
|
3000
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
|
|
4137
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
3001
4138
|
// `returning` hands back the deleted row(s), no separate pre-image reselect needed.
|
|
3002
4139
|
const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
|
|
3003
4140
|
const row = rows.length ? this.shapeWriteRow(this.shape(rows, native)[0], plan) : null;
|
|
@@ -3007,18 +4144,23 @@ export class PowqlInterface {
|
|
|
3007
4144
|
});
|
|
3008
4145
|
}
|
|
3009
4146
|
async deleteMany(args) {
|
|
4147
|
+
args = this.normalizeArgs(args);
|
|
4148
|
+
assertNoBulkWritePaging(this.table, 'deleteMany', args);
|
|
4149
|
+
this.assertNoOptimisticLock(args, 'deleteMany');
|
|
3010
4150
|
return this.withMiddleware('deleteMany', args, async () => {
|
|
3011
4151
|
const params = [];
|
|
3012
|
-
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
4152
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
|
|
3013
4153
|
let where = this.buildWhere(resolvedWhere, params);
|
|
3014
|
-
this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'deleteMany');
|
|
3015
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
|
|
4154
|
+
this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'deleteMany', args.where);
|
|
4155
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
3016
4156
|
const filter = where ? ` filter ${where}` : '';
|
|
3017
4157
|
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
|
|
3018
4158
|
return { count: rowCount };
|
|
3019
4159
|
});
|
|
3020
4160
|
}
|
|
3021
4161
|
async upsert(args) {
|
|
4162
|
+
args = this.normalizeArgs(args);
|
|
4163
|
+
this.assertNoOptimisticLock(args, 'upsert');
|
|
3022
4164
|
return this.withMiddleware('upsert', args, async () => {
|
|
3023
4165
|
// The single-row rule for `upsert`, shared with the SQL engines through
|
|
3024
4166
|
// query/compound-unique.ts. PowqlInterface is a PARALLEL implementation,
|
|
@@ -3029,7 +4171,7 @@ export class PowqlInterface {
|
|
|
3029
4171
|
const upsertWhere = (this.expandedWhere(args.where) ?? {});
|
|
3030
4172
|
this.assertMutationIdentifiesOneRow(upsertWhere, false, 'upsert');
|
|
3031
4173
|
const plan = this.writeReturnPlan(args);
|
|
3032
|
-
const createData = this.applyPkDefault(args.create);
|
|
4174
|
+
const createData = this.applyColumnDefaults(this.applyPkDefault(args.create));
|
|
3033
4175
|
const pkCol = this.meta.primaryKey[0];
|
|
3034
4176
|
const conflictColumns = Object.keys(upsertWhere)
|
|
3035
4177
|
.filter((k) => upsertWhere[k] !== undefined)
|
|
@@ -3049,15 +4191,18 @@ export class PowqlInterface {
|
|
|
3049
4191
|
// as a NotFoundError. The lookup runs its find and its update through the
|
|
3050
4192
|
// filtered interface, so another tenant's row is invisible to it: the
|
|
3051
4193
|
// insert then collides on the key and fails, and nothing is overwritten.
|
|
3052
|
-
const filtered = this.
|
|
4194
|
+
const filtered = this.hasGlobalFilter(args.skipGlobalFilters);
|
|
4195
|
+
// The shape the SQL engines answer with ONE conflict statement (`where`
|
|
4196
|
+
// pinned to `create`, a non-empty scalar update).
|
|
4197
|
+
const conflictShape = upsertWhereMatchesCreate(this.meta, upsertWhere, (args.create ?? {})) &&
|
|
4198
|
+
!hasRelationFields(args.create, this.meta) &&
|
|
4199
|
+
!hasRelationFields(updateData, this.meta) &&
|
|
4200
|
+
Object.values(updateData).some((v) => v !== undefined);
|
|
3053
4201
|
const native = this.meta.primaryKey.length === 1 &&
|
|
3054
4202
|
pkCol !== undefined &&
|
|
3055
4203
|
conflictColumns.length === 1 &&
|
|
3056
4204
|
conflictColumns[0] === pkCol &&
|
|
3057
|
-
|
|
3058
|
-
!hasRelationFields(args.create, this.meta) &&
|
|
3059
|
-
!hasRelationFields(updateData, this.meta) &&
|
|
3060
|
-
Object.values(updateData).some((v) => v !== undefined) &&
|
|
4205
|
+
conflictShape &&
|
|
3061
4206
|
!filtered;
|
|
3062
4207
|
if (!native) {
|
|
3063
4208
|
return this.upsertLookupFirst(upsertWhere, createData, updateData, {
|
|
@@ -3065,6 +4210,9 @@ export class PowqlInterface {
|
|
|
3065
4210
|
omit: args.omit,
|
|
3066
4211
|
skipGlobalFilters: args.skipGlobalFilters,
|
|
3067
4212
|
timeout: args.timeout,
|
|
4213
|
+
// That statement carries the global filter on its conflict branch, so a
|
|
4214
|
+
// key held by a row the filter hides writes nothing there (E001).
|
|
4215
|
+
hiddenConflictIsNotFound: conflictShape && filtered,
|
|
3068
4216
|
});
|
|
3069
4217
|
}
|
|
3070
4218
|
const params = [];
|
|
@@ -3109,7 +4257,7 @@ export class PowqlInterface {
|
|
|
3109
4257
|
* is refused whether or not the row exists (the SQL engines do the same).
|
|
3110
4258
|
*/
|
|
3111
4259
|
async upsertLookupFirst(where, createData, updateData, shape) {
|
|
3112
|
-
const { skipGlobalFilters, timeout, ...projection } = shape;
|
|
4260
|
+
const { skipGlobalFilters, timeout, hiddenConflictIsNotFound, ...projection } = shape;
|
|
3113
4261
|
const skip = skipGlobalFilters === undefined ? {} : { skipGlobalFilters };
|
|
3114
4262
|
const time = timeout === undefined ? {} : { timeout };
|
|
3115
4263
|
this.scalarData(extractRelationFields(createData, this.meta).scalars);
|
|
@@ -3126,6 +4274,21 @@ export class PowqlInterface {
|
|
|
3126
4274
|
return existing;
|
|
3127
4275
|
return (await tbl.update({ where, data: updateData, ...projection, ...skip, ...time }));
|
|
3128
4276
|
}
|
|
4277
|
+
if (hiddenConflictIsNotFound) {
|
|
4278
|
+
// `where` is pinned to `create`, so the insert below collides exactly
|
|
4279
|
+
// when the global filter is what hid the row: say so, as the SQL engines do.
|
|
4280
|
+
const hidden = await tbl.findUnique({ where, skipGlobalFilters: UNSAFE, ...time });
|
|
4281
|
+
if (hidden) {
|
|
4282
|
+
throw new NotFoundError({
|
|
4283
|
+
table: this.table,
|
|
4284
|
+
where,
|
|
4285
|
+
operation: 'upsert',
|
|
4286
|
+
message: `upsert on "${this.table}" wrote nothing: the row that conflicts on the given key is excluded by the ` +
|
|
4287
|
+
`configured global filter for "${this.table}", so it was neither updated (the filter hides it) nor ` +
|
|
4288
|
+
'inserted (the key is taken). Pass `skipGlobalFilters: UNSAFE` to address it anyway.',
|
|
4289
|
+
});
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
3129
4292
|
return (await tbl.create({ data: createData, ...projection, ...time }));
|
|
3130
4293
|
};
|
|
3131
4294
|
return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
|
|
@@ -3135,11 +4298,12 @@ export class PowqlInterface {
|
|
|
3135
4298
|
// -------------------------------------------------------------------------
|
|
3136
4299
|
async count(args = {}) {
|
|
3137
4300
|
this.assertNoForceCustomPlan(args);
|
|
4301
|
+
args = this.normalizeArgs(args);
|
|
3138
4302
|
return this.withMiddleware('count', (args ?? {}), async () => {
|
|
3139
4303
|
const params = [];
|
|
3140
|
-
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
4304
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
|
|
3141
4305
|
let where = this.buildWhere(resolvedWhere, params);
|
|
3142
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
|
|
4306
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
3143
4307
|
const filter = where ? ` filter ${where}` : '';
|
|
3144
4308
|
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
|
|
3145
4309
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
@@ -3199,12 +4363,14 @@ export class PowqlInterface {
|
|
|
3199
4363
|
}
|
|
3200
4364
|
async aggregate(args) {
|
|
3201
4365
|
this.assertNoForceCustomPlan(args);
|
|
4366
|
+
this.assertNoPercentile(args);
|
|
4367
|
+
args = this.normalizeArgs(args);
|
|
3202
4368
|
return this.withMiddleware('aggregate', args, async () => {
|
|
3203
4369
|
const result = {};
|
|
3204
4370
|
const filterParams = [];
|
|
3205
|
-
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
4371
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
|
|
3206
4372
|
let where = this.buildWhere(resolvedWhere, filterParams);
|
|
3207
|
-
where = this.applyGlobalFilter(where, filterParams, args.skipGlobalFilters);
|
|
4373
|
+
where = this.applyGlobalFilter(where, filterParams, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
3208
4374
|
const filter = where ? ` filter ${where}` : '';
|
|
3209
4375
|
const plan = [];
|
|
3210
4376
|
if (args._count) {
|
|
@@ -3241,6 +4407,8 @@ export class PowqlInterface {
|
|
|
3241
4407
|
result[fn] = acc;
|
|
3242
4408
|
const powfn = fn.slice(1); // sum/avg/min/max
|
|
3243
4409
|
for (const field of Object.keys(spec).filter((f) => spec[f])) {
|
|
4410
|
+
this.assertDecimalComparable(this.column(field), `\`${fn}\``);
|
|
4411
|
+
this.assertEnumAggregate(this.column(field), fn);
|
|
3244
4412
|
// Same PII contract as the SQL engines: _min/_max return a stored cell.
|
|
3245
4413
|
if (fn === '_min' || fn === '_max') {
|
|
3246
4414
|
assertAggregatePiiOptIn(this.table, this.meta, field, this.column(field).name, `aggregate ${fn}`, resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
@@ -3249,6 +4417,8 @@ export class PowqlInterface {
|
|
|
3249
4417
|
plan.push({
|
|
3250
4418
|
scalar: `${powfn}(${this.qt}${filter} { ${ref} })`,
|
|
3251
4419
|
inner: `${powfn}(${ref})`,
|
|
4420
|
+
...(fn === '_min' || fn === '_max' ? { col: this.column(field) } : {}),
|
|
4421
|
+
...(fn === '_sum' && isInt8Column(this.column(field)) ? { exact: true } : {}),
|
|
3252
4422
|
assign: (v) => {
|
|
3253
4423
|
acc[field] = v;
|
|
3254
4424
|
},
|
|
@@ -3257,10 +4427,16 @@ export class PowqlInterface {
|
|
|
3257
4427
|
}
|
|
3258
4428
|
// A scalar cell and a grouped cell decode through the same wire decoder,
|
|
3259
4429
|
// so one coercion serves both forms.
|
|
3260
|
-
const
|
|
4430
|
+
const decode = (p, cell, native) => p.col
|
|
4431
|
+
? decodeAggregateCell(cell, p.col, native, this.schema.enums)
|
|
4432
|
+
: p.exact
|
|
4433
|
+
? exactIntegerSumCell(cell)
|
|
4434
|
+
: cell == null || cell === 'null'
|
|
4435
|
+
? null
|
|
4436
|
+
: Number(cell);
|
|
3261
4437
|
if (plan.length > 1 && this.groupsAggregatesInOneStatement) {
|
|
3262
4438
|
const proj = plan.map((p, i) => `agg_${i}: ${p.inner}`).join(', ');
|
|
3263
|
-
const { rows } = await this.exec(`${this.qt}${filter} group 1 { ${proj} }`, [...filterParams], args.timeout, 'aggregate');
|
|
4439
|
+
const { rows, native } = await this.exec(`${this.qt}${filter} group 1 { ${proj} }`, [...filterParams], args.timeout, 'aggregate');
|
|
3264
4440
|
// Zero groups means the filter matched no row. The per-field statements
|
|
3265
4441
|
// below answer that case instead of a synthesized one, because what an
|
|
3266
4442
|
// aggregate over NO rows returns is engine-versioned (`sum` answered 0
|
|
@@ -3269,19 +4445,23 @@ export class PowqlInterface {
|
|
|
3269
4445
|
const row = rows.length === 1 ? rows[0] : undefined;
|
|
3270
4446
|
if (row) {
|
|
3271
4447
|
for (const [i, p] of plan.entries())
|
|
3272
|
-
p.assign(
|
|
4448
|
+
p.assign(decode(p, row[`agg_${i}`], native));
|
|
3273
4449
|
return result;
|
|
3274
4450
|
}
|
|
3275
4451
|
}
|
|
3276
4452
|
for (const p of plan) {
|
|
3277
|
-
const { rows } = await this.exec(p.scalar, [...filterParams], args.timeout, 'aggregate');
|
|
3278
|
-
p.assign(
|
|
4453
|
+
const { rows, native } = await this.exec(p.scalar, [...filterParams], args.timeout, 'aggregate');
|
|
4454
|
+
p.assign(decode(p, rows[0]?.value, native));
|
|
3279
4455
|
}
|
|
3280
4456
|
return result;
|
|
3281
4457
|
});
|
|
3282
4458
|
}
|
|
3283
4459
|
async groupBy(args) {
|
|
4460
|
+
// The SQL engines' `by` refusals, before anything reads `by`.
|
|
4461
|
+
assertGroupByKeys(this.table, args?.by);
|
|
3284
4462
|
this.assertNoForceCustomPlan(args);
|
|
4463
|
+
this.assertNoPercentile(args);
|
|
4464
|
+
args = this.normalizeArgs(args);
|
|
3285
4465
|
return this.withMiddleware('groupBy', args, async () => {
|
|
3286
4466
|
// DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
|
|
3287
4467
|
if (args.distinctOn) {
|
|
@@ -3302,9 +4482,9 @@ export class PowqlInterface {
|
|
|
3302
4482
|
// a per-call legacy fallback on a native-capable pool still decodes right.
|
|
3303
4483
|
const emitNative = Boolean(this.capabilities.nativeRaw);
|
|
3304
4484
|
const params = [];
|
|
3305
|
-
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
4485
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
|
|
3306
4486
|
let where = this.buildWhere(resolvedWhere, params);
|
|
3307
|
-
where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
|
|
4487
|
+
where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
|
|
3308
4488
|
const filter = where ? ` filter ${where}` : '';
|
|
3309
4489
|
// Result-key namespace, mirroring the SQL builder's `claimResultKey`
|
|
3310
4490
|
// (query/builder.ts): a group-key / aggregate output-name collision (with
|
|
@@ -3326,6 +4506,8 @@ export class PowqlInterface {
|
|
|
3326
4506
|
for (const entry of args.by) {
|
|
3327
4507
|
if (typeof entry === 'string') {
|
|
3328
4508
|
const col = this.column(entry);
|
|
4509
|
+
// Text grouping would put '7.5' and '7.50' in different groups.
|
|
4510
|
+
this.assertDecimalComparable(col, 'A groupBy key');
|
|
3329
4511
|
assertAggregatePiiOptIn(this.table, this.meta, entry, col.name, 'groupBy `by` key', resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
3330
4512
|
// Keyed by the canonical FIELD, never by whichever of the column's
|
|
3331
4513
|
// two legal spellings the caller wrote (`column` accepts both), so
|
|
@@ -3342,7 +4524,7 @@ export class PowqlInterface {
|
|
|
3342
4524
|
}
|
|
3343
4525
|
else {
|
|
3344
4526
|
const col = this.column(entry.field);
|
|
3345
|
-
if (!isJsonColumn(col)) {
|
|
4527
|
+
if (!isJsonColumn(col, this.schema.enums)) {
|
|
3346
4528
|
throw new ValidationError(`groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
|
|
3347
4529
|
}
|
|
3348
4530
|
assertAggregatePiiOptIn(this.table, this.meta, entry.field, col.name, 'groupBy JSON `by` key', resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
@@ -3369,6 +4551,7 @@ export class PowqlInterface {
|
|
|
3369
4551
|
// Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
|
|
3370
4552
|
// aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
|
|
3371
4553
|
// expression by user key; `aggOrderExprs` lets orderBy reference the alias.
|
|
4554
|
+
// `col` is set for a column `_min` / `_max`: a stored cell, decoded by its column.
|
|
3372
4555
|
const aggReaders = [];
|
|
3373
4556
|
const aggOrderExprs = new Map();
|
|
3374
4557
|
const aggInner = new Map();
|
|
@@ -3434,6 +4617,8 @@ export class PowqlInterface {
|
|
|
3434
4617
|
const alias = `agg_${aggN++}`;
|
|
3435
4618
|
if (target === true) {
|
|
3436
4619
|
const col = this.column(key);
|
|
4620
|
+
this.assertDecimalComparable(col, `\`${fn}\``);
|
|
4621
|
+
this.assertEnumAggregate(col, fn);
|
|
3437
4622
|
if (fn === '_min' || fn === '_max') {
|
|
3438
4623
|
assertAggregatePiiOptIn(this.table, this.meta, key, col.name, `groupBy ${fn}`, resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
3439
4624
|
}
|
|
@@ -3442,13 +4627,19 @@ export class PowqlInterface {
|
|
|
3442
4627
|
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
3443
4628
|
// Canonical field, so the result bucket and the keys `orderBy` /
|
|
3444
4629
|
// `having` accept are the same whichever spelling was requested.
|
|
3445
|
-
aggReaders.push({
|
|
4630
|
+
aggReaders.push({
|
|
4631
|
+
alias,
|
|
4632
|
+
outKey: `${fn}:${col.field}`,
|
|
4633
|
+
numeric: fn === '_sum' || fn === '_avg',
|
|
4634
|
+
...(fn === '_min' || fn === '_max' ? { col } : {}),
|
|
4635
|
+
...(fn === '_sum' && isInt8Column(col) ? { exact: true } : {}),
|
|
4636
|
+
});
|
|
3446
4637
|
aggOrderExprs.set(`${fn}:${col.field}`, `.${alias}`);
|
|
3447
4638
|
aggInner.set(col.field, inner);
|
|
3448
4639
|
}
|
|
3449
4640
|
else {
|
|
3450
4641
|
const col = this.column(target.field);
|
|
3451
|
-
if (!isJsonColumn(col)) {
|
|
4642
|
+
if (!isJsonColumn(col, this.schema.enums)) {
|
|
3452
4643
|
throw new ValidationError(`groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
|
|
3453
4644
|
}
|
|
3454
4645
|
if (fn === '_min' || fn === '_max') {
|
|
@@ -3507,7 +4698,15 @@ export class PowqlInterface {
|
|
|
3507
4698
|
}
|
|
3508
4699
|
for (const a of aggReaders) {
|
|
3509
4700
|
const cell = raw[a.alias];
|
|
3510
|
-
const v =
|
|
4701
|
+
const v = a.col
|
|
4702
|
+
? decodeAggregateCell(cell, a.col, resultNative, this.schema.enums)
|
|
4703
|
+
: a.exact
|
|
4704
|
+
? exactIntegerSumCell(cell)
|
|
4705
|
+
: cell == null || cell === 'null'
|
|
4706
|
+
? null
|
|
4707
|
+
: a.numeric
|
|
4708
|
+
? Number(cell)
|
|
4709
|
+
: cell;
|
|
3511
4710
|
if (a.outKey === '_count')
|
|
3512
4711
|
out._count = v ?? 0;
|
|
3513
4712
|
else {
|
|
@@ -3581,6 +4780,11 @@ export class PowqlInterface {
|
|
|
3581
4780
|
`Supported: ${[...POWQL_HAVING_AGG_FNS.keys()].join(', ')}.`);
|
|
3582
4781
|
}
|
|
3583
4782
|
const inner = this.lookupGroupKey(aggInner, key) ?? this.ref(key);
|
|
4783
|
+
const column = this.meta.columns.find((c) => c.field === key || c.name === key);
|
|
4784
|
+
if (column && fn !== 'count')
|
|
4785
|
+
this.assertDecimalComparable(column, `\`${aggKey}\` in having`);
|
|
4786
|
+
if (column && fn !== 'count')
|
|
4787
|
+
this.assertEnumAggregate(column, aggKey);
|
|
3584
4788
|
conds.push(cmp(`${fn}(${inner})`, filter));
|
|
3585
4789
|
}
|
|
3586
4790
|
}
|
|
@@ -3643,6 +4847,9 @@ export class PowqlInterface {
|
|
|
3643
4847
|
if (!expr) {
|
|
3644
4848
|
throw new ValidationError(`Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
|
|
3645
4849
|
}
|
|
4850
|
+
const keyColumn = this.meta.columns.find((c) => c.field === key || c.name === key);
|
|
4851
|
+
if (keyColumn)
|
|
4852
|
+
this.assertEnumOrderable(keyColumn, 'Ordering a groupBy');
|
|
3646
4853
|
parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
|
|
3647
4854
|
}
|
|
3648
4855
|
return parts.length ? ` order ${parts.join(', ')}` : '';
|
|
@@ -3665,15 +4872,68 @@ export class PowqlInterface {
|
|
|
3665
4872
|
async *findManyStream() {
|
|
3666
4873
|
throw new UnsupportedFeatureError('cursor streaming (findManyStream)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
|
|
3667
4874
|
}
|
|
3668
|
-
// The batch-yielding sibling needs its OWN stub. `PowqlInterface` is a
|
|
3669
|
-
// parallel implementation of QueryInterface's surface rather than a subclass,
|
|
3670
|
-
// so a method that exists on one and not the other is not a type error here:
|
|
3671
|
-
// it is `undefined` at runtime, and the caller gets `TypeError: not a
|
|
3672
|
-
// function` instead of the typed E017 that tells them what to do instead.
|
|
3673
4875
|
// biome-ignore lint/correctness/useYield: intentionally throws before yielding, PowDB has no server cursor.
|
|
3674
4876
|
async *findManyStreamBatches() {
|
|
3675
4877
|
throw new UnsupportedFeatureError('cursor streaming (findManyStreamBatches)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
|
|
3676
4878
|
}
|
|
4879
|
+
// Deferred queries. `db.pipeline()` and `$transaction([...])` take a
|
|
4880
|
+
// prebuilt statement, and a PowDB read is several round trips (relation
|
|
4881
|
+
// filters resolve first, loaders run after), so there is no single statement
|
|
4882
|
+
// to hand over. Each stub throws the typed refusal when called, which is
|
|
4883
|
+
// before the pipeline or transaction starts.
|
|
4884
|
+
deferredUnsupported(method) {
|
|
4885
|
+
throw new UnsupportedFeatureError(`deferred query (${method})`, 'PowDB', 'db.pipeline() and $transaction([...]) take deferred queries; run the calls inside ' +
|
|
4886
|
+
'db.$transaction(async (tx) => ...) instead');
|
|
4887
|
+
}
|
|
4888
|
+
buildFindMany(_args) {
|
|
4889
|
+
return this.deferredUnsupported('buildFindMany');
|
|
4890
|
+
}
|
|
4891
|
+
buildFindFirst(_args) {
|
|
4892
|
+
return this.deferredUnsupported('buildFindFirst');
|
|
4893
|
+
}
|
|
4894
|
+
buildFindFirstOrThrow(_args) {
|
|
4895
|
+
return this.deferredUnsupported('buildFindFirstOrThrow');
|
|
4896
|
+
}
|
|
4897
|
+
buildFindUnique(_args) {
|
|
4898
|
+
return this.deferredUnsupported('buildFindUnique');
|
|
4899
|
+
}
|
|
4900
|
+
buildFindUniqueOrThrow(_args) {
|
|
4901
|
+
return this.deferredUnsupported('buildFindUniqueOrThrow');
|
|
4902
|
+
}
|
|
4903
|
+
buildCount(_args) {
|
|
4904
|
+
return this.deferredUnsupported('buildCount');
|
|
4905
|
+
}
|
|
4906
|
+
buildAggregate(_args) {
|
|
4907
|
+
return this.deferredUnsupported('buildAggregate');
|
|
4908
|
+
}
|
|
4909
|
+
buildGroupBy(_args) {
|
|
4910
|
+
return this.deferredUnsupported('buildGroupBy');
|
|
4911
|
+
}
|
|
4912
|
+
buildCreate(_args) {
|
|
4913
|
+
return this.deferredUnsupported('buildCreate');
|
|
4914
|
+
}
|
|
4915
|
+
buildCreateMany(_args) {
|
|
4916
|
+
return this.deferredUnsupported('buildCreateMany');
|
|
4917
|
+
}
|
|
4918
|
+
buildUpdate(_args) {
|
|
4919
|
+
return this.deferredUnsupported('buildUpdate');
|
|
4920
|
+
}
|
|
4921
|
+
buildUpdateMany(_args) {
|
|
4922
|
+
return this.deferredUnsupported('buildUpdateMany');
|
|
4923
|
+
}
|
|
4924
|
+
buildDelete(_args) {
|
|
4925
|
+
return this.deferredUnsupported('buildDelete');
|
|
4926
|
+
}
|
|
4927
|
+
buildDeleteMany(_args) {
|
|
4928
|
+
return this.deferredUnsupported('buildDeleteMany');
|
|
4929
|
+
}
|
|
4930
|
+
buildUpsert(_args) {
|
|
4931
|
+
return this.deferredUnsupported('buildUpsert');
|
|
4932
|
+
}
|
|
4933
|
+
/** SQL template cache counters. PowDB keeps no statement cache, so every counter is zero. */
|
|
4934
|
+
cacheStats() {
|
|
4935
|
+
return { hits: 0, misses: 0, hitRate: 0, size: 0 };
|
|
4936
|
+
}
|
|
3677
4937
|
// -------------------------------------------------------------------------
|
|
3678
4938
|
// Reselect helper (upsert only, PowDB's upsert has no `returning`)
|
|
3679
4939
|
// -------------------------------------------------------------------------
|
|
@@ -3690,15 +4950,6 @@ export class PowqlInterface {
|
|
|
3690
4950
|
});
|
|
3691
4951
|
return rows.length ? this.shape(rows, native)[0] : null;
|
|
3692
4952
|
}
|
|
3693
|
-
/**
|
|
3694
|
-
* Empty-where guard, blocks accidental whole-table writes. Mirrors the SQL
|
|
3695
|
-
* path's `assertMutationHasPredicate` (query/builder.ts): it gates on the
|
|
3696
|
-
* *compiled* PowQL filter fragment, NOT the shape of the `where` object. A
|
|
3697
|
-
* `where` whose conditions all evaporate during compilation, `{}`,
|
|
3698
|
-
* `{ id: undefined }`, `{ OR: [] }`, `{ AND: [] }`, `{ NOT: {} }`,
|
|
3699
|
-
* `{ OR: [{ f: undefined }] }`, compiles to the empty string and is refused,
|
|
3700
|
-
* because emitting a filter-less write would hit every row.
|
|
3701
|
-
*/
|
|
3702
4953
|
/**
|
|
3703
4954
|
* AND this table's configured global filter (soft-delete / multi-tenancy)
|
|
3704
4955
|
* onto an ALREADY-COMPILED user filter, pushing its params after the user's.
|
|
@@ -3718,8 +4969,7 @@ export class PowqlInterface {
|
|
|
3718
4969
|
* `alias` is forwarded so the nested/native-join paths qualify the filter's
|
|
3719
4970
|
* column references the same way they qualify the user's.
|
|
3720
4971
|
*/
|
|
3721
|
-
applyGlobalFilter(compiledUserWhere, params,
|
|
3722
|
-
const gf = resolveGlobalFilterFrom(this.options.globalFilters, this.table, resolveSkipGlobalFilters(skip));
|
|
4972
|
+
applyGlobalFilter(compiledUserWhere, params, gf, alias) {
|
|
3723
4973
|
if (!gf)
|
|
3724
4974
|
return compiledUserWhere;
|
|
3725
4975
|
const gfWhere = this.buildWhere(gf, params, alias);
|
|
@@ -3729,16 +4979,311 @@ export class PowqlInterface {
|
|
|
3729
4979
|
return gfWhere;
|
|
3730
4980
|
return `(${compiledUserWhere}) and (${gfWhere})`;
|
|
3731
4981
|
}
|
|
3732
|
-
|
|
4982
|
+
/**
|
|
4983
|
+
* Tables whose global filter is being resolved by an operation above this
|
|
4984
|
+
* interface. A relation filter inside a global filter runs its own query on
|
|
4985
|
+
* the related table, which applies THAT table's filter, so two filters that
|
|
4986
|
+
* reach each other through relations would resolve forever; each table's
|
|
4987
|
+
* filter applies once per chain instead.
|
|
4988
|
+
*/
|
|
4989
|
+
get globalFilterStack() {
|
|
4990
|
+
return this.options._globalFilterStack ?? [];
|
|
4991
|
+
}
|
|
4992
|
+
/** Whether this table's global filter constrains an operation under `skip`. */
|
|
4993
|
+
hasGlobalFilter(skip) {
|
|
4994
|
+
if (this.globalFilterStack.includes(this.table))
|
|
4995
|
+
return false;
|
|
4996
|
+
const gf = resolveGlobalFilterFrom(this.options.globalFilters, this.table, resolveSkipGlobalFilters(skip), this.schema.tables);
|
|
4997
|
+
return gf != null && !this.compilesToNothing(gf);
|
|
4998
|
+
}
|
|
4999
|
+
/**
|
|
5000
|
+
* This table's global filter for one operation, ready for
|
|
5001
|
+
* {@link applyGlobalFilter}: read under SQL three-valued logic, with every
|
|
5002
|
+
* relation filter in it (`posts: { some: ... }`, `user: { is: ... }`)
|
|
5003
|
+
* resolved to a literal key list by the same client-side resolution a
|
|
5004
|
+
* caller's `where` gets. The related table's own global filter bounds those
|
|
5005
|
+
* lookups, as on the SQL engines, and `skip` reaches them too.
|
|
5006
|
+
*/
|
|
5007
|
+
async globalFilterWhere(skip, timeout) {
|
|
5008
|
+
const stack = this.globalFilterStack;
|
|
5009
|
+
if (stack.includes(this.table))
|
|
5010
|
+
return undefined;
|
|
5011
|
+
const gf = resolveGlobalFilterFrom(this.options.globalFilters, this.table, resolveSkipGlobalFilters(skip), this.schema.tables);
|
|
5012
|
+
if (!gf)
|
|
5013
|
+
return undefined;
|
|
5014
|
+
if (!this.namesRelation(gf))
|
|
5015
|
+
return this.sqlTrueWhere(gf);
|
|
5016
|
+
const scoped = new PowqlInterface(this.pool, this.table, this.schema, [], {
|
|
5017
|
+
...this.loaderChildOptions(),
|
|
5018
|
+
_globalFilterStack: [...stack, this.table],
|
|
5019
|
+
});
|
|
5020
|
+
return scoped.resolveRelationFilters(gf, timeout, skip);
|
|
5021
|
+
}
|
|
5022
|
+
/** Whether `where` holds a relation filter at any combinator depth. */
|
|
5023
|
+
namesRelation(where) {
|
|
5024
|
+
if (where === null || typeof where !== 'object' || Array.isArray(where))
|
|
5025
|
+
return false;
|
|
5026
|
+
for (const [key, value] of Object.entries(where)) {
|
|
5027
|
+
if (value === undefined)
|
|
5028
|
+
continue;
|
|
5029
|
+
if (isCombinatorKey(key)) {
|
|
5030
|
+
const branches = combinatorBranches(key, value, this.table);
|
|
5031
|
+
if (branches?.some((w) => this.namesRelation(w)))
|
|
5032
|
+
return true;
|
|
5033
|
+
}
|
|
5034
|
+
else if (resolveRelationDef(this.meta.relations, key)) {
|
|
5035
|
+
return true;
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
return false;
|
|
5039
|
+
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Empty-where guard, blocks accidental whole-table writes. Mirrors the SQL
|
|
5042
|
+
* path's `assertMutationHasPredicate` (query/where.ts): it gates on the
|
|
5043
|
+
* *compiled* PowQL filter fragment, NOT the shape of the `where` object, so a
|
|
5044
|
+
* `where` whose conditions all evaporate during compilation (`{}`,
|
|
5045
|
+
* `{ id: undefined }`, `{ AND: [] }`, `{ NOT: {} }`) is refused. A where that
|
|
5046
|
+
* compiles but is TRUE for every row (`NOT: { OR: [] }`, `id: { notIn: [] }`)
|
|
5047
|
+
* writes every row just the same, so `userWhere`, the caller's where before
|
|
5048
|
+
* relation filters were resolved, is folded by the SQL engines' rule
|
|
5049
|
+
* (`foldWhereFor`) and refused too.
|
|
5050
|
+
*
|
|
5051
|
+
* The fold also reads the schema's NOT NULL columns, as the SQL guard does:
|
|
5052
|
+
* `{ name: { not: null } }` or `NOT: { name: null }` on a NOT NULL column is
|
|
5053
|
+
* TRUE for every row (PowDB's `required` holds the same guarantee). It does
|
|
5054
|
+
* NOT trust foreign keys: PowDB declares none, so an orphan row is possible
|
|
5055
|
+
* and a belongsTo `is: {}` stays a condition.
|
|
5056
|
+
*/
|
|
5057
|
+
assertCompiledWhere(compiledWhere, allow, action, userWhere) {
|
|
3733
5058
|
if (allow === true)
|
|
3734
5059
|
return;
|
|
3735
|
-
if (compiledWhere.length > 0
|
|
5060
|
+
if (compiledWhere.length > 0 &&
|
|
5061
|
+
(userWhere === undefined ||
|
|
5062
|
+
foldWhereFor(this.schema.tables, this.table, userWhere, POWDB_GUARD_FOLD) !== 'true')) {
|
|
3736
5063
|
return;
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
'or check that your filter values are defined.');
|
|
5064
|
+
}
|
|
5065
|
+
throw new ValidationError(emptyMutationWhereMessage(action, this.table));
|
|
3740
5066
|
}
|
|
3741
5067
|
}
|
|
5068
|
+
/**
|
|
5069
|
+
* The mass-mutation guard's fold options on PowDB (see `assertCompiledWhere`):
|
|
5070
|
+
* NOT NULL columns count, foreign keys do not (PowDB enforces none).
|
|
5071
|
+
*/
|
|
5072
|
+
const POWDB_GUARD_FOLD = {
|
|
5073
|
+
notNull: { foreignKeys: false, hasGlobalFilter: () => true },
|
|
5074
|
+
};
|
|
5075
|
+
/** Marks a default expression {@link emulateColumnDefault} cannot evaluate. */
|
|
5076
|
+
const NO_DEFAULT = Symbol('turbine.powdb.noDefault');
|
|
5077
|
+
/**
|
|
5078
|
+
* Evaluate a column's SQL default expression client-side, for one insert.
|
|
5079
|
+
*
|
|
5080
|
+
* Covers what `defineSchema` accepts (NULL, TRUE / FALSE, numeric and quoted
|
|
5081
|
+
* string literals, now() / current_timestamp, current_date, gen_random_uuid())
|
|
5082
|
+
* plus the trailing `::type` cast and wrapping parentheses introspection adds.
|
|
5083
|
+
* Anything else returns {@link NO_DEFAULT}: a guessed value would be a silent
|
|
5084
|
+
* divergence from the engine the schema was written for.
|
|
5085
|
+
*/
|
|
5086
|
+
function emulateColumnDefault(expr, col, enums) {
|
|
5087
|
+
let e = expr.trim();
|
|
5088
|
+
for (;;) {
|
|
5089
|
+
const unwrapped = e.replace(/::[a-z_][a-z0-9_ ]*(\(\d+(,\s*\d+)?\))?(\[\])?$/i, '').trim();
|
|
5090
|
+
const stripped = /^\((.*)\)$/s.test(unwrapped) ? unwrapped.slice(1, -1).trim() : unwrapped;
|
|
5091
|
+
if (stripped === e)
|
|
5092
|
+
break;
|
|
5093
|
+
e = stripped;
|
|
5094
|
+
}
|
|
5095
|
+
const ts = baseTsType(col.tsType);
|
|
5096
|
+
const upper = e.toUpperCase();
|
|
5097
|
+
if (upper === 'NULL')
|
|
5098
|
+
return null;
|
|
5099
|
+
if (upper === 'TRUE' || upper === 'FALSE')
|
|
5100
|
+
return ts === 'boolean' ? upper === 'TRUE' : NO_DEFAULT;
|
|
5101
|
+
if (['NOW()', 'CURRENT_TIMESTAMP', 'LOCALTIMESTAMP', 'TRANSACTION_TIMESTAMP()', 'STATEMENT_TIMESTAMP()'].includes(upper)) {
|
|
5102
|
+
return ts === 'Date' ? new Date() : NO_DEFAULT;
|
|
5103
|
+
}
|
|
5104
|
+
if (upper === 'CURRENT_DATE') {
|
|
5105
|
+
if (ts !== 'Date')
|
|
5106
|
+
return NO_DEFAULT;
|
|
5107
|
+
const now = new Date();
|
|
5108
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
5109
|
+
}
|
|
5110
|
+
if (upper === 'GEN_RANDOM_UUID()' || upper === 'UUID_GENERATE_V4()')
|
|
5111
|
+
return ts === 'string' ? randomUUID() : NO_DEFAULT;
|
|
5112
|
+
const quoted = /^'((?:[^']|'')*)'$/s.exec(e);
|
|
5113
|
+
const literal = quoted ? quoted[1].replace(/''/g, "'") : /^-?\d+(\.\d+)?$/.test(e) ? e : undefined;
|
|
5114
|
+
if (literal === undefined)
|
|
5115
|
+
return NO_DEFAULT;
|
|
5116
|
+
// An enum default is one of its labels, stored as the label text.
|
|
5117
|
+
const labels = powdbEnumLabels(col, enums);
|
|
5118
|
+
if (labels)
|
|
5119
|
+
return labels.includes(literal) ? literal : NO_DEFAULT;
|
|
5120
|
+
if (isJsonColumn(col, enums)) {
|
|
5121
|
+
try {
|
|
5122
|
+
return JSON.parse(literal);
|
|
5123
|
+
}
|
|
5124
|
+
catch {
|
|
5125
|
+
return NO_DEFAULT;
|
|
5126
|
+
}
|
|
5127
|
+
}
|
|
5128
|
+
switch (ts) {
|
|
5129
|
+
case 'string':
|
|
5130
|
+
return literal;
|
|
5131
|
+
case 'number': {
|
|
5132
|
+
const n = Number(literal);
|
|
5133
|
+
if (literal.trim() === '' || !Number.isFinite(n))
|
|
5134
|
+
return NO_DEFAULT;
|
|
5135
|
+
// An int8 default past 2^53 keeps every digit, as the column does.
|
|
5136
|
+
return isInt8Column(col) && /^-?\d+$/.test(literal) && !Number.isSafeInteger(n) ? BigInt(literal) : n;
|
|
5137
|
+
}
|
|
5138
|
+
case 'bigint':
|
|
5139
|
+
return /^-?\d+$/.test(literal) ? BigInt(literal) : NO_DEFAULT;
|
|
5140
|
+
case 'boolean': {
|
|
5141
|
+
const b = literal.toLowerCase();
|
|
5142
|
+
if (['t', 'true', 'y', 'yes', 'on', '1'].includes(b))
|
|
5143
|
+
return true;
|
|
5144
|
+
if (['f', 'false', 'n', 'no', 'off', '0'].includes(b))
|
|
5145
|
+
return false;
|
|
5146
|
+
return NO_DEFAULT;
|
|
5147
|
+
}
|
|
5148
|
+
case 'Date': {
|
|
5149
|
+
// A zone-less literal is UTC, as Turbine reads a zone-less timestamp.
|
|
5150
|
+
const zoned = /^\d{4}-\d\d-\d\d$/.test(literal) || /([zZ]|[+-]\d\d(:?\d\d)?)$/.test(literal);
|
|
5151
|
+
const d = new Date(zoned ? literal : `${literal.replace(' ', 'T')}Z`);
|
|
5152
|
+
return quoted && !Number.isNaN(d.getTime()) ? d : NO_DEFAULT;
|
|
5153
|
+
}
|
|
5154
|
+
default:
|
|
5155
|
+
return NO_DEFAULT;
|
|
5156
|
+
}
|
|
5157
|
+
}
|
|
5158
|
+
/**
|
|
5159
|
+
* Decode a `_min` / `_max` cell, which is a stored value of `col`, through the
|
|
5160
|
+
* same per-column coercion a row read uses: a string stays a string, a Date
|
|
5161
|
+
* column's micros become a Date, a bigint keeps its digits. Reading it as a
|
|
5162
|
+
* number turned every text minimum into null and every date into micros.
|
|
5163
|
+
*/
|
|
5164
|
+
/**
|
|
5165
|
+
* Whether `col` is a 64-bit integer column (`int8` / `bigint`), the type whose
|
|
5166
|
+
* `SUM` PostgreSQL widens to `numeric` and the SQL engines return as exact
|
|
5167
|
+
* decimal text rather than a JS number (query/aggregates.ts,
|
|
5168
|
+
* `isExactNumericType`). A column read from a live PowDB catalog reports the
|
|
5169
|
+
* engine's `int` and is not recognised, so its sum stays a number.
|
|
5170
|
+
*/
|
|
5171
|
+
function isInt8Column(col) {
|
|
5172
|
+
const pgType = col.pgType ?? '';
|
|
5173
|
+
const paren = pgType.indexOf('(');
|
|
5174
|
+
const base = (paren === -1 ? pgType : pgType.slice(0, paren)).trim().toLowerCase();
|
|
5175
|
+
return base === 'int8' || base === 'bigint';
|
|
5176
|
+
}
|
|
5177
|
+
/**
|
|
5178
|
+
* A `_sum` over an int8 column, as PostgreSQL returns it: the exact decimal
|
|
5179
|
+
* text, so a total past 2^53 keeps every digit. The native wire delivers the
|
|
5180
|
+
* engine's i64 as a `bigint` and the legacy wire as text; both are exact.
|
|
5181
|
+
*/
|
|
5182
|
+
function exactIntegerSumCell(cell) {
|
|
5183
|
+
if (cell === undefined || cell === null || cell === 'null')
|
|
5184
|
+
return null;
|
|
5185
|
+
if (typeof cell === 'bigint')
|
|
5186
|
+
return cell.toString();
|
|
5187
|
+
if (typeof cell === 'number' && Number.isInteger(cell))
|
|
5188
|
+
return BigInt(cell).toString();
|
|
5189
|
+
return String(cell);
|
|
5190
|
+
}
|
|
5191
|
+
/**
|
|
5192
|
+
* Whole-document equality on a json column (a bare object or array, or
|
|
5193
|
+
* `equals` with no `path`): PowQL has no document comparison, so it is an
|
|
5194
|
+
* engine gap (E017) on every spelling, never a value bound as data.
|
|
5195
|
+
*/
|
|
5196
|
+
function jsonDocumentEqualityUnsupported(col) {
|
|
5197
|
+
return new UnsupportedFeatureError('JSON document equality (equals without path)', 'PowDB', `column "${col.name}": pass a \`path\` to compare a specific json value; PowQL has no whole-document comparison`);
|
|
5198
|
+
}
|
|
5199
|
+
const INT64_MIN = -(2n ** 63n);
|
|
5200
|
+
const INT64_MAX = 2n ** 63n - 1n;
|
|
5201
|
+
/**
|
|
5202
|
+
* The value integer text names for an integer column: a number when it is a
|
|
5203
|
+
* safe integer, else a bigint, so an int8 past 2^53 keeps every digit. Text
|
|
5204
|
+
* that is not an integer is refused (E003), as PostgreSQL refuses it.
|
|
5205
|
+
*/
|
|
5206
|
+
function integerTextValue(text, col, table) {
|
|
5207
|
+
const trimmed = text.trim();
|
|
5208
|
+
const shown = JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}...` : text);
|
|
5209
|
+
if (!/^[+-]?\d+$/.test(trimmed)) {
|
|
5210
|
+
throw new ValidationError(`Invalid value ${shown} for the integer column "${col.name}" on "${table}": expected an integer. ` +
|
|
5211
|
+
"Pass a number, a bigint, or integer text such as '42'.");
|
|
5212
|
+
}
|
|
5213
|
+
const big = BigInt(trimmed);
|
|
5214
|
+
if (big < INT64_MIN || big > INT64_MAX) {
|
|
5215
|
+
throw new ValidationError(`Invalid value ${shown} for the integer column "${col.name}" on "${table}": it is outside the 64-bit range.`);
|
|
5216
|
+
}
|
|
5217
|
+
const n = Number(big);
|
|
5218
|
+
return Number.isSafeInteger(n) ? n : big;
|
|
5219
|
+
}
|
|
5220
|
+
/** ISO 8601 date or date-time text: date, optional time and fraction, optional zone. */
|
|
5221
|
+
const ISO_DATE_TEXT = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
|
|
5222
|
+
/**
|
|
5223
|
+
* The epoch micros a Date column stores for `text`, read the way PostgreSQL
|
|
5224
|
+
* reads the same text against a `date` / `timestamp` column, so
|
|
5225
|
+
* `{ birthday: '2000-02-29' }` matches what `{ birthday: new Date(...) }` does.
|
|
5226
|
+
*
|
|
5227
|
+
* - A `date` column takes the calendar day and ignores any time or zone
|
|
5228
|
+
* (PostgreSQL's date input does the same), at UTC midnight, which is how
|
|
5229
|
+
* Turbine reads and writes a date.
|
|
5230
|
+
* - Any other Date column takes the full instant; text with no zone is UTC,
|
|
5231
|
+
* Turbine's zone-less convention. Up to microsecond precision is kept.
|
|
5232
|
+
*
|
|
5233
|
+
* Only ISO 8601 text is read. PostgreSQL also accepts words (`now`,
|
|
5234
|
+
* `infinity`) and other layouts; those are refused (E003) rather than guessed.
|
|
5235
|
+
*/
|
|
5236
|
+
function isoTextMicros(text, col, table) {
|
|
5237
|
+
const refuse = (why) => new ValidationError(`Invalid date text ${JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}...` : text)} for the Date ` +
|
|
5238
|
+
`column "${col.name}" on "${table}": ${why}. PowDB stores the column as epoch microseconds, so text is ` +
|
|
5239
|
+
'read as ISO 8601 (YYYY-MM-DD, optionally a time and a zone). Pass a Date for anything else.');
|
|
5240
|
+
const m = ISO_DATE_TEXT.exec(text.trim());
|
|
5241
|
+
if (!m)
|
|
5242
|
+
throw refuse('it is not ISO 8601');
|
|
5243
|
+
const [, y, mo, d, hh, mi, ss, frac, zone] = m;
|
|
5244
|
+
const isDate = (col.pgType ?? '').trim().toLowerCase() === 'date';
|
|
5245
|
+
const day = new Date(0);
|
|
5246
|
+
day.setUTCFullYear(Number(y), Number(mo) - 1, Number(d));
|
|
5247
|
+
if (day.getUTCFullYear() !== Number(y) || day.getUTCMonth() !== Number(mo) - 1 || day.getUTCDate() !== Number(d)) {
|
|
5248
|
+
throw refuse('it names no calendar day');
|
|
5249
|
+
}
|
|
5250
|
+
if (isDate)
|
|
5251
|
+
return BigInt(day.getTime()) * 1000n;
|
|
5252
|
+
// PostgreSQL reads zone-less text against timestamptz in the SESSION time
|
|
5253
|
+
// zone, a server setting PowDB has no counterpart for, so no single reading
|
|
5254
|
+
// matches it; a plain `timestamp` has no zone and is read as UTC.
|
|
5255
|
+
if (!zone && /^timestamptz$|with time zone/i.test((col.pgType ?? '').trim())) {
|
|
5256
|
+
throw refuse('it has no zone, and PostgreSQL would read it in the session time zone; add Z or an offset');
|
|
5257
|
+
}
|
|
5258
|
+
const hours = Number(hh ?? 0);
|
|
5259
|
+
const minutes = Number(mi ?? 0);
|
|
5260
|
+
const seconds = Number(ss ?? 0);
|
|
5261
|
+
if (hours > 23 || minutes > 59 || seconds > 59)
|
|
5262
|
+
throw refuse('its time is out of range');
|
|
5263
|
+
let offsetMinutes = 0;
|
|
5264
|
+
if (zone && zone.toUpperCase() !== 'Z') {
|
|
5265
|
+
const digits = zone.slice(1).replace(':', '');
|
|
5266
|
+
const oh = Number(digits.slice(0, 2));
|
|
5267
|
+
const om = Number(digits.slice(2) || 0);
|
|
5268
|
+
if (oh > 15 || om > 59)
|
|
5269
|
+
throw refuse('its zone offset is out of range');
|
|
5270
|
+
offsetMinutes = (zone[0] === '-' ? -1 : 1) * (oh * 60 + om);
|
|
5271
|
+
}
|
|
5272
|
+
const ms = day.getTime() + ((hours * 60 + minutes - offsetMinutes) * 60 + seconds) * 1000;
|
|
5273
|
+
// Microseconds past the second, from the first six fraction digits (PostgreSQL rounds the rest).
|
|
5274
|
+
const fracMicros = frac ? Math.round(Number(`0.${frac}`) * 1e6) : 0;
|
|
5275
|
+
return BigInt(ms) * 1000n + BigInt(fracMicros);
|
|
5276
|
+
}
|
|
5277
|
+
function decodeAggregateCell(cell, col, native, enums) {
|
|
5278
|
+
if (cell === undefined || cell === null)
|
|
5279
|
+
return null;
|
|
5280
|
+
if (native)
|
|
5281
|
+
return coerceNativeValue(cell, col);
|
|
5282
|
+
// Legacy string wire: an aggregate over no rows renders the bareword `null`.
|
|
5283
|
+
if (typeof cell !== 'string')
|
|
5284
|
+
return cell;
|
|
5285
|
+
return cell === 'null' ? null : coerceValue(cell, col, enums);
|
|
5286
|
+
}
|
|
3742
5287
|
/** Coerce a group-key scalar string by the column's TS type (numbers/bools). */
|
|
3743
5288
|
function coerceScalar(raw, tsType) {
|
|
3744
5289
|
const ts = baseTsType(tsType);
|