@quereus/store 4.5.0 → 4.7.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 +34 -6
- package/dist/src/common/encoding.d.ts +4 -3
- package/dist/src/common/encoding.d.ts.map +1 -1
- package/dist/src/common/encoding.js +17 -13
- package/dist/src/common/encoding.js.map +1 -1
- package/dist/src/common/events.d.ts +31 -25
- package/dist/src/common/events.d.ts.map +1 -1
- package/dist/src/common/events.js +38 -39
- package/dist/src/common/events.js.map +1 -1
- package/dist/src/common/implicit-unique-index.d.ts +80 -0
- package/dist/src/common/implicit-unique-index.d.ts.map +1 -0
- package/dist/src/common/implicit-unique-index.js +148 -0
- package/dist/src/common/implicit-unique-index.js.map +1 -0
- package/dist/src/common/index.d.ts +7 -3
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +5 -2
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/json-key.d.ts +30 -2
- package/dist/src/common/json-key.d.ts.map +1 -1
- package/dist/src/common/json-key.js +49 -2
- package/dist/src/common/json-key.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +40 -20
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +31 -23
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +10 -0
- package/dist/src/common/kv-store.d.ts.map +1 -1
- package/dist/src/common/pk-key-resolution.d.ts +348 -0
- package/dist/src/common/pk-key-resolution.d.ts.map +1 -0
- package/dist/src/common/pk-key-resolution.js +481 -0
- package/dist/src/common/pk-key-resolution.js.map +1 -0
- package/dist/src/common/store-module-access-plan.d.ts +32 -0
- package/dist/src/common/store-module-access-plan.d.ts.map +1 -0
- package/dist/src/common/store-module-access-plan.js +428 -0
- package/dist/src/common/store-module-access-plan.js.map +1 -0
- package/dist/src/common/store-module-alter-column.d.ts +23 -0
- package/dist/src/common/store-module-alter-column.d.ts.map +1 -0
- package/dist/src/common/store-module-alter-column.js +389 -0
- package/dist/src/common/store-module-alter-column.js.map +1 -0
- package/dist/src/common/store-module-alter.d.ts +55 -0
- package/dist/src/common/store-module-alter.d.ts.map +1 -0
- package/dist/src/common/store-module-alter.js +504 -0
- package/dist/src/common/store-module-alter.js.map +1 -0
- package/dist/src/common/store-module-base.d.ts +268 -0
- package/dist/src/common/store-module-base.d.ts.map +1 -0
- package/dist/src/common/store-module-base.js +362 -0
- package/dist/src/common/store-module-base.js.map +1 -0
- package/dist/src/common/store-module-catalog.d.ts +269 -0
- package/dist/src/common/store-module-catalog.d.ts.map +1 -0
- package/dist/src/common/store-module-catalog.js +462 -0
- package/dist/src/common/store-module-catalog.js.map +1 -0
- package/dist/src/common/store-module-index-build.d.ts +88 -0
- package/dist/src/common/store-module-index-build.d.ts.map +1 -0
- package/dist/src/common/store-module-index-build.js +280 -0
- package/dist/src/common/store-module-index-build.js.map +1 -0
- package/dist/src/common/store-module-index.d.ts +116 -0
- package/dist/src/common/store-module-index.d.ts.map +1 -0
- package/dist/src/common/store-module-index.js +373 -0
- package/dist/src/common/store-module-index.js.map +1 -0
- package/dist/src/common/store-module-rename.d.ts +49 -0
- package/dist/src/common/store-module-rename.d.ts.map +1 -0
- package/dist/src/common/store-module-rename.js +268 -0
- package/dist/src/common/store-module-rename.js.map +1 -0
- package/dist/src/common/store-module-schema-rewrite.d.ts +57 -0
- package/dist/src/common/store-module-schema-rewrite.d.ts.map +1 -0
- package/dist/src/common/store-module-schema-rewrite.js +140 -0
- package/dist/src/common/store-module-schema-rewrite.js.map +1 -0
- package/dist/src/common/store-module-schema-sync.d.ts +232 -0
- package/dist/src/common/store-module-schema-sync.d.ts.map +1 -0
- package/dist/src/common/store-module-schema-sync.js +529 -0
- package/dist/src/common/store-module-schema-sync.js.map +1 -0
- package/dist/src/common/store-module.d.ts +43 -909
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +184 -3276
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table-base.d.ts +539 -0
- package/dist/src/common/store-table-base.d.ts.map +1 -0
- package/dist/src/common/store-table-base.js +900 -0
- package/dist/src/common/store-table-base.js.map +1 -0
- package/dist/src/common/store-table-constraints.d.ts +237 -0
- package/dist/src/common/store-table-constraints.d.ts.map +1 -0
- package/dist/src/common/store-table-constraints.js +624 -0
- package/dist/src/common/store-table-constraints.js.map +1 -0
- package/dist/src/common/store-table-scan.d.ts +452 -0
- package/dist/src/common/store-table-scan.d.ts.map +1 -0
- package/dist/src/common/store-table-scan.js +901 -0
- package/dist/src/common/store-table-scan.js.map +1 -0
- package/dist/src/common/store-table.d.ts +71 -1144
- package/dist/src/common/store-table.d.ts.map +1 -1
- package/dist/src/common/store-table.js +168 -2385
- package/dist/src/common/store-table.js.map +1 -1
- package/dist/src/testing/index.d.ts +12 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +12 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/kv-assert.d.ts +19 -0
- package/dist/src/testing/kv-assert.d.ts.map +1 -0
- package/dist/src/testing/kv-assert.js +25 -0
- package/dist/src/testing/kv-assert.js.map +1 -0
- package/dist/src/testing/kv-conformance.d.ts.map +1 -1
- package/dist/src/testing/kv-conformance.js +1 -15
- package/dist/src/testing/kv-conformance.js.map +1 -1
- package/dist/src/testing/kv-provider-conformance.d.ts +54 -0
- package/dist/src/testing/kv-provider-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-provider-conformance.js +183 -0
- package/dist/src/testing/kv-provider-conformance.js.map +1 -0
- package/package.json +8 -8
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read path of the KVStore-backed virtual table: turning a pushed predicate
|
|
3
|
+
* into a byte window over the data store or a secondary index, iterating that
|
|
4
|
+
* window, and re-checking every pushed constraint under the column's real
|
|
5
|
+
* collation.
|
|
6
|
+
*
|
|
7
|
+
* Second layer of the store-table chain:
|
|
8
|
+
* StoreTableBase -> StoreTableScan -> StoreTableConstraints -> StoreTable
|
|
9
|
+
*/
|
|
10
|
+
import { IndexConstraintOp, QuereusError, StatusCode, compareSqlValuesFast, createTypedComparator, hasSemanticOrdering, BINARY_COLLATION, decodeIdxStr, planKindFromCode, } from '@quereus/quereus';
|
|
11
|
+
import { bytesToHex, compareBytes } from './bytes.js';
|
|
12
|
+
import { buildIndexPrefixBounds, buildFullScanBounds, } from './key-builder.js';
|
|
13
|
+
import { deserializeRow, } from './serialization.js';
|
|
14
|
+
import { indexLeadingRangeIsOrderSafe, indexPrefixSeekIsCollationExact, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolveIndexKeyTransforms, semanticProbeIsKeyFaithful } from './pk-key-resolution.js';
|
|
15
|
+
import { StoreTableBase } from './store-table-base.js';
|
|
16
|
+
/**
|
|
17
|
+
* Read-path layer of the generic KVStore-backed virtual table.
|
|
18
|
+
*
|
|
19
|
+
* Every arm below reads the EFFECTIVE row state (see
|
|
20
|
+
* {@link StoreTableBase.iterateEffective}), so a byte window narrowed here still
|
|
21
|
+
* honors read-your-own-writes.
|
|
22
|
+
*/
|
|
23
|
+
export class StoreTableScan extends StoreTableBase {
|
|
24
|
+
/**
|
|
25
|
+
* Query the table with optional filters.
|
|
26
|
+
*
|
|
27
|
+
* All three access arms read the EFFECTIVE row state: the committed store
|
|
28
|
+
* merged with this table's coordinator's pending ops when a transaction is
|
|
29
|
+
* active (read-your-own-writes — see {@link iterateEffective} /
|
|
30
|
+
* {@link readLiveRowByPk}). Merged emission stays in encoded-PK-key order,
|
|
31
|
+
* preserving the module's `providesOrdering` / `monotonicOn` advertisements.
|
|
32
|
+
*/
|
|
33
|
+
async *query(filterInfo) {
|
|
34
|
+
const store = await this.ensureStore();
|
|
35
|
+
// Multi-seek (`plan=5`, an IN-list served as N point seeks) dispatches FIRST —
|
|
36
|
+
// before analyzePKAccess: the FilterInfo carries N*W EQ constraints, so on a
|
|
37
|
+
// table whose PK column is also the leading column of the chosen index,
|
|
38
|
+
// analyzePKAccess would match the FIRST equality and answer a single-value
|
|
39
|
+
// point lookup — one list value's rows, a silent wrong result.
|
|
40
|
+
const idxSpec = decodeIdxStr(filterInfo.idxStr);
|
|
41
|
+
if (idxSpec && planKindFromCode(idxSpec.plan) === 'multiSeek') {
|
|
42
|
+
yield* this.scanMultiSeek(idxSpec, filterInfo);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const pkAccess = this.analyzePKAccess(filterInfo);
|
|
46
|
+
if (pkAccess.type === 'point') {
|
|
47
|
+
const row = await this.readLiveRowByPk(pkAccess.values);
|
|
48
|
+
if (row && this.matchesFilters(row, filterInfo, this.resolveFilterCollations(filterInfo))) {
|
|
49
|
+
yield row;
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (pkAccess.type === 'range') {
|
|
54
|
+
yield* this.scanPKRange(store, pkAccess, filterInfo);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Secondary-index scan arm — reached only when the predicate did NOT resolve
|
|
58
|
+
// to a PK point/range (PK access is cheaper and already handled above). When
|
|
59
|
+
// the planner chose a secondary index (idxStr carries `idx=<name>(…)`), derive
|
|
60
|
+
// its byte window and iterate it instead of full-scanning.
|
|
61
|
+
const indexAccess = this.analyzeIndexAccess(filterInfo);
|
|
62
|
+
if (indexAccess) {
|
|
63
|
+
const indexStore = await this.ensureIndexStore(indexAccess.index.name);
|
|
64
|
+
yield* this.scanIndex(indexStore, indexAccess, filterInfo);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Full table scan
|
|
68
|
+
const collations = this.resolveFilterCollations(filterInfo);
|
|
69
|
+
const bounds = buildFullScanBounds();
|
|
70
|
+
for await (const entry of this.iterateEffective(store, bounds)) {
|
|
71
|
+
const row = deserializeRow(entry.value);
|
|
72
|
+
if (this.matchesFilters(row, filterInfo, collations)) {
|
|
73
|
+
yield row;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* True when a byte window over the LEADING PK column reproduces the comparator's order
|
|
79
|
+
* — the precondition {@link buildPKRangeBounds} needs, and the exact condition
|
|
80
|
+
* `computeBestAccessPlan` (store-module-access-plan.ts) uses before claiming the range filters handled.
|
|
81
|
+
* When false, the range arm degrades to a full scan + `matchesFilters` residual.
|
|
82
|
+
*/
|
|
83
|
+
leadingPkRangeIsOrderSafe() {
|
|
84
|
+
return pkOrderPreservingPrefixLength(this.db, this.tableSchema, this.pkKeyCollations, this.encodeOptions.collation ?? 'NOCASE') >= 1;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* True when some PK member's logical type carries semantic ordering (TIMESPAN,
|
|
88
|
+
* JSON) — the gate {@link scanMultiSeekPrimary} declines on.
|
|
89
|
+
*
|
|
90
|
+
* A primary-key multi-seek merges N byte windows with the residual Filter already
|
|
91
|
+
* dropped, and has no degradation available: a window that under-fetches loses rows
|
|
92
|
+
* outright, and falling back to the scan arm would AND N mutually-exclusive
|
|
93
|
+
* equalities to zero rows. So it fails loudly instead. Re-opening it is backlog
|
|
94
|
+
* `feat-store-semantic-key-multiseek`.
|
|
95
|
+
*
|
|
96
|
+
* NOT a gate on the single-value point arm any more: both types' key transforms (see
|
|
97
|
+
* {@link storeSemanticKeyTransform}) make byte equality exactly the type's equality
|
|
98
|
+
* ('PT1H' and 'PT60M' land on one key), so {@link analyzePKAccess} admits a point
|
|
99
|
+
* window per PROBE ({@link semanticProbeIsKeyFaithful}) rather than declining per
|
|
100
|
+
* schema. A single point window CAN degrade to the scan arm; N merged ones cannot.
|
|
101
|
+
*/
|
|
102
|
+
pkHasSemanticOrderingMember() {
|
|
103
|
+
const schema = this.tableSchema;
|
|
104
|
+
return schema.primaryKeyDefinition.some(def => hasSemanticOrdering(schema.columns[def.index]?.logicalType));
|
|
105
|
+
}
|
|
106
|
+
/** Analyze filter info to determine PK access pattern. */
|
|
107
|
+
analyzePKAccess(filterInfo) {
|
|
108
|
+
const schema = this.tableSchema;
|
|
109
|
+
const pkColumns = schema.primaryKeyDefinition.map(pk => pk.index);
|
|
110
|
+
if (pkColumns.length === 0) {
|
|
111
|
+
return { type: 'scan' };
|
|
112
|
+
}
|
|
113
|
+
// Check for equality on all PK columns
|
|
114
|
+
const eqValues = new Array(pkColumns.length);
|
|
115
|
+
let allEq = true;
|
|
116
|
+
for (let i = 0; i < pkColumns.length; i++) {
|
|
117
|
+
const pkColIdx = pkColumns[i];
|
|
118
|
+
const eqConstraintEntry = filterInfo.constraints?.find(c => c.constraint.iColumn === pkColIdx && c.constraint.op === IndexConstraintOp.EQ);
|
|
119
|
+
if (eqConstraintEntry && eqConstraintEntry.argvIndex > 0) {
|
|
120
|
+
eqValues[i] = filterInfo.args[eqConstraintEntry.argvIndex - 1];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
allEq = false;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// A full-PK equality is a point window, provided every probe has a faithful byte
|
|
128
|
+
// position under its column's type — an unfaithful one declines the WHOLE arm here
|
|
129
|
+
// rather than widening ({@link semanticProbeIsKeyFaithful} states why, per arm).
|
|
130
|
+
//
|
|
131
|
+
// Declining at RUNTIME is safe even though `computeBestAccessPlan`'s
|
|
132
|
+
// full-PK-equality arm (store-module-access-plan.ts) already claimed the filters
|
|
133
|
+
// handled and the engine dropped the residual Filter: falling through to
|
|
134
|
+
// `{ type: 'scan' }` still applies {@link matchesFilters}, which ANDs each pushed
|
|
135
|
+
// constraint under the column's real comparator. The plan's claim is about which
|
|
136
|
+
// FILTERS the module honours, not about which physical arm serves them.
|
|
137
|
+
//
|
|
138
|
+
// NOTE: for a TIMESPAN member the gate parses the probe (`groupKey`) and
|
|
139
|
+
// `encodeDataKey`'s transform then parses it again — two duration parses per SEEK,
|
|
140
|
+
// not per scanned row. `query()` runs once per seek, so a nested-loop join driving
|
|
141
|
+
// N point lookups pays it N times. Fine now; if a point-lookup-heavy TIMESPAN-keyed
|
|
142
|
+
// workload ever shows it, have the gate hand its parsed value back so the encode
|
|
143
|
+
// can reuse it.
|
|
144
|
+
if (allEq && eqValues.every((v, i) => semanticProbeIsKeyFaithful(schema.columns[pkColumns[i]]?.logicalType, v))) {
|
|
145
|
+
return { type: 'point', values: eqValues };
|
|
146
|
+
}
|
|
147
|
+
// Check for range constraints on first PK column
|
|
148
|
+
const firstPkCol = pkColumns[0];
|
|
149
|
+
const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
|
|
150
|
+
const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && rangeOps.includes(c.constraint.op)) || [];
|
|
151
|
+
// A range window is only sound when the leading PK column's key bytes order the way
|
|
152
|
+
// its comparator does; otherwise fall through to the full scan, where matchesFilters
|
|
153
|
+
// applies the range under the real comparator.
|
|
154
|
+
if (rangeConstraints.length > 0 && this.leadingPkRangeIsOrderSafe()) {
|
|
155
|
+
return {
|
|
156
|
+
type: 'range',
|
|
157
|
+
columnIndex: firstPkCol,
|
|
158
|
+
constraints: rangeConstraints.map(c => ({
|
|
159
|
+
columnIndex: c.constraint.iColumn,
|
|
160
|
+
op: c.constraint.op,
|
|
161
|
+
value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { type: 'scan' };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Convert a leading-PK-column range access into one seek + early-terminate
|
|
169
|
+
* iterate window.
|
|
170
|
+
*
|
|
171
|
+
* Each LT/LE/GT/GE constraint's bound value is encoded under the SAME
|
|
172
|
+
* per-column DESC direction and key collation as the data keys (via
|
|
173
|
+
* {@link encodePkPrefixBounds}), giving the byte region `[lo, hi)` whose
|
|
174
|
+
* leading column equals that value (`lo = encode([x])`,
|
|
175
|
+
* `hi = incrementLastByte(lo)`). The op maps that region's endpoints onto a
|
|
176
|
+
* `gte`/`lt` window; because a DESC column bit-inverts its bytes (larger value
|
|
177
|
+
* ⇒ smaller bytes), the lower/upper assignment swaps with direction:
|
|
178
|
+
*
|
|
179
|
+
* | op | ASC | DESC |
|
|
180
|
+
* |----|----------|----------|
|
|
181
|
+
* | GE | gte = lo | lt = hi |
|
|
182
|
+
* | GT | gte = hi | lt = lo |
|
|
183
|
+
* | LE | lt = hi | gte = lo |
|
|
184
|
+
* | LT | lt = lo | gte = hi |
|
|
185
|
+
*
|
|
186
|
+
* Across constraints (BETWEEN ⇒ one lower + one upper; a redundant same-side
|
|
187
|
+
* pair ⇒ the tighter wins) we keep the MAX lower candidate for `gte` and the
|
|
188
|
+
* MIN upper candidate for `lt`. A candidate that resolves to `undefined` (an
|
|
189
|
+
* `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
|
|
190
|
+
* SUPERSET, since {@link matchesFilters} stays the authoritative collation-aware
|
|
191
|
+
* row filter. A NULL/missing bound value is likewise skipped (the planner never
|
|
192
|
+
* pushes `= NULL`, and a range op against NULL rejects every row in matchesFilters).
|
|
193
|
+
* A bound over a semantic-ordering leading column whose VALUE has no faithful byte
|
|
194
|
+
* position ({@link semanticProbeIsKeyFaithful} — a numeric or unparseable TIMESPAN
|
|
195
|
+
* probe, a blob/bigint JSON probe) is skipped the same way: dropping a RANGE bound
|
|
196
|
+
* only WIDENS the window (worst case to the pre-existing full scan), and the plan
|
|
197
|
+
* claimed the range handled from the schema alone, so the predicate the engine's
|
|
198
|
+
* dropped Filter carried is reproduced by {@link matchesFilters} under the type's
|
|
199
|
+
* own compare. The EQUALITY arms cannot widen this way and degrade differently —
|
|
200
|
+
* see {@link semanticProbeIsKeyFaithful}'s per-arm table.
|
|
201
|
+
*
|
|
202
|
+
* NOTE: a range window over a text PK column is sound only when the column's key
|
|
203
|
+
* normalizer is ORDER-preserving with respect to its comparator — i.e. the comparator
|
|
204
|
+
* orders two strings the way memcmp orders their normalized bytes. That is NOT implied
|
|
205
|
+
* by `db.registerCollation`'s normalizer contract, which promises only an equality
|
|
206
|
+
* partition, and a built-in NAME may be re-registered with a comparator + normalizer
|
|
207
|
+
* pair that preserves equality while inverting order. This method is therefore only
|
|
208
|
+
* ever reached under {@link leadingPkRangeIsOrderSafe} — enforced by
|
|
209
|
+
* {@link analyzePKAccess} here and mirrored by `computeBestAccessPlan` (store-module-access-plan.ts)
|
|
210
|
+
* before it claims the range filters handled. A collation without the
|
|
211
|
+
* `orderPreserving` assertion costs the seek, never a row.
|
|
212
|
+
*/
|
|
213
|
+
buildPKRangeBounds(access) {
|
|
214
|
+
const full = buildFullScanBounds();
|
|
215
|
+
const constraints = access.constraints;
|
|
216
|
+
if (!constraints || constraints.length === 0)
|
|
217
|
+
return full;
|
|
218
|
+
const dir = this.pkDirections[0];
|
|
219
|
+
const leadingType = this.tableSchema.columns[access.columnIndex]?.logicalType;
|
|
220
|
+
let gte = full.gte;
|
|
221
|
+
let lt;
|
|
222
|
+
for (const c of constraints) {
|
|
223
|
+
if (c.value === undefined || c.value === null)
|
|
224
|
+
continue;
|
|
225
|
+
// Widen, don't decline: see the doc comment above.
|
|
226
|
+
if (!semanticProbeIsKeyFaithful(leadingType, c.value))
|
|
227
|
+
continue;
|
|
228
|
+
const { gte: lo, lt: hi } = this.encodePkPrefixBounds([c.value]);
|
|
229
|
+
const lower = !dir
|
|
230
|
+
? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
|
|
231
|
+
: (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
|
|
232
|
+
const upper = !dir
|
|
233
|
+
? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
|
|
234
|
+
: (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
|
|
235
|
+
if (lower && compareBytes(lower, gte) > 0)
|
|
236
|
+
gte = lower;
|
|
237
|
+
if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
|
|
238
|
+
lt = upper;
|
|
239
|
+
}
|
|
240
|
+
return lt === undefined ? { gte } : { gte, lt };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Scan a leading-PK-column range, seeking to the window start and
|
|
244
|
+
* early-terminating at its end.
|
|
245
|
+
*
|
|
246
|
+
* {@link buildPKRangeBounds} converts the LT/LE/GT/GE constraints into one
|
|
247
|
+
* encoded-byte `gte`/`lt` window under the same per-column DESC directions and
|
|
248
|
+
* key collations the data keys use, so the iterate visits a SUPERSET of the
|
|
249
|
+
* qualifying rows (a collation widening or the bound-byte increment can
|
|
250
|
+
* over-fetch, never under-fetch). {@link matchesFilters} stays the authoritative
|
|
251
|
+
* collation-aware row filter. {@link iterateEffective} restricts the pending
|
|
252
|
+
* merge to the same `bounds`, so read-your-own-writes holds on the narrowed window.
|
|
253
|
+
*/
|
|
254
|
+
async *scanPKRange(store, access, filterInfo) {
|
|
255
|
+
const collations = this.resolveFilterCollations(filterInfo);
|
|
256
|
+
const bounds = this.buildPKRangeBounds(access);
|
|
257
|
+
for await (const entry of this.iterateEffective(store, bounds)) {
|
|
258
|
+
const row = deserializeRow(entry.value);
|
|
259
|
+
if (this.matchesFilters(row, filterInfo, collations)) {
|
|
260
|
+
yield row;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the secondary index chosen by the planner from `filterInfo.idxStr`.
|
|
266
|
+
*
|
|
267
|
+
* The planner emits `idx=<name>(<n>);plan=…` when its access plan set both an
|
|
268
|
+
* `indexName` and `seekColumnIndexes` (see `getBestAccessPlan` and
|
|
269
|
+
* rule-select-access-path.ts). Decoding goes through the engine's shared
|
|
270
|
+
* `decodeIdxStr`, so the store, the in-memory vtab, and the isolation overlay all
|
|
271
|
+
* read one format. Returns null for the PK/scan sentinels (`_primary_`, `fullscan`,
|
|
272
|
+
* `empty`), an idxStr that names no index, or a name absent from `schema.indexes` —
|
|
273
|
+
* every one of which routes back to a PK/full-scan arm.
|
|
274
|
+
*/
|
|
275
|
+
resolveIndexFromIdxStr(idxStr) {
|
|
276
|
+
const spec = decodeIdxStr(idxStr);
|
|
277
|
+
if (!spec)
|
|
278
|
+
return null;
|
|
279
|
+
const name = spec.indexName;
|
|
280
|
+
if (!name || name === '_primary_')
|
|
281
|
+
return null;
|
|
282
|
+
const indexes = this.tableSchema?.indexes ?? [];
|
|
283
|
+
return indexes.find(i => i.name.toLowerCase() === name.toLowerCase()) ?? null;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Analyze filter info to determine a secondary-index access pattern, mirroring
|
|
287
|
+
* {@link analyzePKAccess} but over the index chosen in `idxStr`.
|
|
288
|
+
*
|
|
289
|
+
* A contiguous leading-prefix EQ on the index columns yields a `point` window
|
|
290
|
+
* (the prefix covers every entry sharing those leading values — an index seek
|
|
291
|
+
* is a PREFIX scan, not a single row, since the index need not be unique and
|
|
292
|
+
* the PK suffix varies); otherwise a range (LT/LE/GT/GE) on the LEADING index
|
|
293
|
+
* column yields a `range` window. Returns null when neither applies or when the
|
|
294
|
+
* index is unresolved. {@link matchesFilters} stays the authoritative row filter,
|
|
295
|
+
* so the window need only be a SUPERSET.
|
|
296
|
+
*
|
|
297
|
+
* Index-column bytes are encoded under each column's own key collation
|
|
298
|
+
* ({@link indexKeyCollations}); {@link matchesFilters} re-checks a fetched row under the
|
|
299
|
+
* index column's `COLLATE` else the table column's declared collation. The EQ/prefix
|
|
300
|
+
* window is EXACTLY the qualifying set when those two agree, which
|
|
301
|
+
* {@link indexPrefixSeekIsCollationExact} decides; the RANGE window additionally equates
|
|
302
|
+
* memcmp of the key bytes with the residual's comparison ORDER, which
|
|
303
|
+
* {@link indexRangeIsOrderSafe} decides. `tryIndexAccessPlan`
|
|
304
|
+
* (store-module-access-plan.ts) consults the SAME two predicates before claiming the
|
|
305
|
+
* filters handled, so the two decisions cannot disagree. Either failing returns null and
|
|
306
|
+
* the caller full-scans, where the residual is authoritative.
|
|
307
|
+
*/
|
|
308
|
+
analyzeIndexAccess(filterInfo) {
|
|
309
|
+
const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
|
|
310
|
+
if (!index)
|
|
311
|
+
return null;
|
|
312
|
+
const indexCols = index.columns.map(c => c.index);
|
|
313
|
+
const indexDirections = index.columns.map(c => !!c.desc);
|
|
314
|
+
const indexCollations = this.indexKeyCollations(index);
|
|
315
|
+
// Contiguous leading-prefix EQ → point/prefix window. A member whose PROBE has no
|
|
316
|
+
// faithful byte position under its logical type ({@link semanticProbeIsKeyFaithful}
|
|
317
|
+
// — a numeric or unparseable TIMESPAN probe, a blob/bigint JSON probe) STOPS the
|
|
318
|
+
// prefix here, the widening that arm can afford; the shorter window is a strict
|
|
319
|
+
// SUPERSET and {@link matchesFilters} re-checks the dropped column under the type's
|
|
320
|
+
// own compare. Stopping at position 0 leaves `eqValues` empty and falls through to
|
|
321
|
+
// the range arm below.
|
|
322
|
+
const eqValues = [];
|
|
323
|
+
for (let i = 0; i < indexCols.length; i++) {
|
|
324
|
+
const eq = filterInfo.constraints?.find(c => c.constraint.iColumn === indexCols[i]
|
|
325
|
+
&& c.constraint.op === IndexConstraintOp.EQ
|
|
326
|
+
&& c.argvIndex > 0);
|
|
327
|
+
if (!eq)
|
|
328
|
+
break;
|
|
329
|
+
const value = filterInfo.args[eq.argvIndex - 1];
|
|
330
|
+
if (!semanticProbeIsKeyFaithful(this.tableSchema.columns[indexCols[i]]?.logicalType, value))
|
|
331
|
+
break;
|
|
332
|
+
eqValues.push(value);
|
|
333
|
+
}
|
|
334
|
+
if (eqValues.length > 0) {
|
|
335
|
+
// Decline rather than fall through to the range arm: an EQ prefix whose key
|
|
336
|
+
// collation differs from the residual's comparison collation would under-fetch,
|
|
337
|
+
// and the planner declined the same shape, so the residual Filter is still there.
|
|
338
|
+
if (!indexPrefixSeekIsCollationExact(this.tableSchema.columns, index, indexCollations, eqValues.length)) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const bounds = buildIndexPrefixBounds(eqValues, this.encodeOptions, indexDirections.slice(0, eqValues.length), indexCollations.slice(0, eqValues.length), this.indexKeyTransforms(index).slice(0, eqValues.length));
|
|
342
|
+
return { index, type: 'point', bounds };
|
|
343
|
+
}
|
|
344
|
+
// Else a range on the LEADING index column.
|
|
345
|
+
const leadingCol = indexCols[0];
|
|
346
|
+
const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
|
|
347
|
+
const rangeConstraints = (filterInfo.constraints ?? []).filter(c => c.constraint.iColumn === leadingCol && rangeOps.includes(c.constraint.op));
|
|
348
|
+
if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index)) {
|
|
349
|
+
const bounds = this.buildIndexRangeBounds(rangeConstraints.map(c => ({
|
|
350
|
+
op: c.constraint.op,
|
|
351
|
+
value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
|
|
352
|
+
})), indexDirections[0], indexCollations[0], this.tableSchema.columns[leadingCol]?.logicalType, this.indexKeyTransforms(index)[0]);
|
|
353
|
+
return { index, type: 'range', bounds };
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Lazy cache behind {@link indexKeyCollations}: one resolved array per index-schema
|
|
359
|
+
* object, invalidated when the column array it was resolved against is replaced (an
|
|
360
|
+
* ALTER can retype a column without minting new index objects, so the index identity
|
|
361
|
+
* alone is not a safe key).
|
|
362
|
+
*/
|
|
363
|
+
indexKeyCollationsCache = new WeakMap();
|
|
364
|
+
/**
|
|
365
|
+
* Per-column KEY collation for `index`'s own columns, memoized — see
|
|
366
|
+
* {@link resolveIndexKeyCollations}. The one resolution every scan-side encode site
|
|
367
|
+
* ({@link analyzeIndexAccess}, {@link buildIndexRangeBounds}, {@link scanMultiSeek})
|
|
368
|
+
* threads into `buildIndexPrefixBounds`, so a scan window can never address
|
|
369
|
+
* different bytes than the maintenance writes.
|
|
370
|
+
*/
|
|
371
|
+
indexKeyCollations(index) {
|
|
372
|
+
const columns = this.tableSchema.columns;
|
|
373
|
+
const cached = this.indexKeyCollationsCache.get(index);
|
|
374
|
+
if (cached && cached.columns === columns)
|
|
375
|
+
return cached.collations;
|
|
376
|
+
const collations = resolveIndexKeyCollations(index, columns);
|
|
377
|
+
this.indexKeyCollationsCache.set(index, { columns, collations });
|
|
378
|
+
return collations;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Lazy cache behind {@link indexKeyTransforms} — the transform twin of
|
|
382
|
+
* {@link indexKeyCollationsCache}, with the same columns-identity invalidation rule
|
|
383
|
+
* (an ALTER can retype a column without minting new index objects).
|
|
384
|
+
*/
|
|
385
|
+
indexKeyTransformsCache = new WeakMap();
|
|
386
|
+
/**
|
|
387
|
+
* Per-column key VALUE TRANSFORM for `index`'s own columns, memoized — see
|
|
388
|
+
* {@link resolveIndexKeyTransforms}. Threaded, like {@link indexKeyCollations},
|
|
389
|
+
* into every scan-side `buildIndexPrefixBounds` call ({@link analyzeIndexAccess},
|
|
390
|
+
* {@link buildIndexRangeBounds}, {@link scanMultiSeek}) so a scan window addresses
|
|
391
|
+
* the same TRANSFORMED bytes `buildIndexKey` wrote for a semantic-ordering column.
|
|
392
|
+
* The point/multi-seek callers only ever see `undefined` entries today (their arms
|
|
393
|
+
* still decline semantic-ordering prefixes), but threading all three uniformly is
|
|
394
|
+
* the point — an arm re-opened later cannot silently address raw-value bytes.
|
|
395
|
+
*/
|
|
396
|
+
indexKeyTransforms(index) {
|
|
397
|
+
const columns = this.tableSchema.columns;
|
|
398
|
+
const cached = this.indexKeyTransformsCache.get(index);
|
|
399
|
+
if (cached && cached.columns === columns)
|
|
400
|
+
return cached.transforms;
|
|
401
|
+
const transforms = resolveIndexKeyTransforms(index, columns);
|
|
402
|
+
this.indexKeyTransformsCache.set(index, { columns, transforms });
|
|
403
|
+
return transforms;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Per-column comparators stating the store's actual index-key BYTE order for
|
|
407
|
+
* `indexName` — the `VirtualTable.getIndexComparator` isolation hook, mirroring
|
|
408
|
+
* `MemoryTable.getIndexComparator`. The isolation layer merges the overlay's pending
|
|
409
|
+
* rows against this table's index scan by `(indexKey, PK)` sort key and prefers these
|
|
410
|
+
* over its descriptor-derived fallback, so each column's comparator must reproduce
|
|
411
|
+
* the order {@link scanIndex} emits in:
|
|
412
|
+
* - the column's KEY collation ({@link indexKeyCollations}: index COLLATE, else the
|
|
413
|
+
* table column's declared collation, else BINARY) for text;
|
|
414
|
+
* - the logical type's `compare` for a semantic-ordering column (its key bytes
|
|
415
|
+
* encode through an order-preserving transform — see `storeSemanticKeyTransform`);
|
|
416
|
+
* - negated for a DESC column (its key bytes are bit-inverted).
|
|
417
|
+
* Resolved against the MATERIALIZED schema so a hidden `_uc_*` name resolves too.
|
|
418
|
+
*
|
|
419
|
+
* NOTE: for a text column this states the COMPARATOR's order, which is the byte order
|
|
420
|
+
* only while the collation's key normalizer preserves order (`_isCollationOrderPreserving`
|
|
421
|
+
* — asserted by the built-ins, opt-in for a custom one; see keyOrderMatchesCollation).
|
|
422
|
+
* Under an equality-only custom collation the emitted byte order and this comparator can
|
|
423
|
+
* disagree, misplacing an overlay row within an index-key-equal group. Same exposure the
|
|
424
|
+
* isolation layer's descriptor fallback carried before this method existed, so nothing
|
|
425
|
+
* regressed; if a custom equality-only collation on an INDEX column ever ships, gate this
|
|
426
|
+
* on the assertion (returning undefined declines to the fallback, which is no better —
|
|
427
|
+
* the real fix is to make the store emit in comparator order or the merge tolerate it).
|
|
428
|
+
*/
|
|
429
|
+
getIndexComparator(indexName) {
|
|
430
|
+
const schema = this.materializedSchema;
|
|
431
|
+
const index = schema.indexes?.find(ix => ix.name.toLowerCase() === indexName.toLowerCase());
|
|
432
|
+
if (!index)
|
|
433
|
+
return undefined;
|
|
434
|
+
const keyCollations = this.indexKeyCollations(index);
|
|
435
|
+
return index.columns.map((col, i) => {
|
|
436
|
+
const columnSchema = schema.columns[col.index];
|
|
437
|
+
const name = keyCollations[i];
|
|
438
|
+
const collationFunc = name ? this.collationResolver(name) : undefined;
|
|
439
|
+
const typedComparator = createTypedComparator(columnSchema.logicalType, collationFunc);
|
|
440
|
+
return col.desc
|
|
441
|
+
? (a, b) => -typedComparator(a, b)
|
|
442
|
+
: typedComparator;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* True when a byte window over the LEADING column of `index` reproduces the order the
|
|
447
|
+
* residual comparison uses. Delegates to {@link indexLeadingRangeIsOrderSafe} — the SAME
|
|
448
|
+
* call the range arm of `tryIndexAccessPlan` (store-module-access-plan.ts) makes before
|
|
449
|
+
* it marks the range filters handled, so the "build a window" and "mark handled"
|
|
450
|
+
* decisions cannot disagree. The memoized {@link indexKeyCollations} is passed as the
|
|
451
|
+
* key side, so the predicate judges the bytes this scan actually addresses.
|
|
452
|
+
*/
|
|
453
|
+
indexRangeIsOrderSafe(index) {
|
|
454
|
+
return indexLeadingRangeIsOrderSafe(this.db, this.tableSchema.columns, index, this.indexKeyCollations(index));
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Convert leading-index-column LT/LE/GT/GE constraints into one encoded-byte
|
|
458
|
+
* `gte`/`lt` window — the secondary-index analogue of {@link buildPKRangeBounds}.
|
|
459
|
+
*
|
|
460
|
+
* Each bound value is encoded under the leading index column's own key `collation`
|
|
461
|
+
* ({@link indexKeyCollations}) and DESC `dir` — exactly as {@link buildIndexKey}
|
|
462
|
+
* encodes that column — via {@link buildIndexPrefixBounds}, giving the byte region
|
|
463
|
+
* `[lo, hi)` whose leading column equals that value. The op maps that region's
|
|
464
|
+
* endpoints onto `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path:
|
|
465
|
+
*
|
|
466
|
+
* | op | ASC | DESC |
|
|
467
|
+
* |----|----------|----------|
|
|
468
|
+
* | GE | gte = lo | lt = hi |
|
|
469
|
+
* | GT | gte = hi | lt = lo |
|
|
470
|
+
* | LE | lt = hi | gte = lo |
|
|
471
|
+
* | LT | lt = lo | gte = hi |
|
|
472
|
+
*
|
|
473
|
+
* Across constraints keep the MAX lower and MIN upper. An `undefined` upper (an
|
|
474
|
+
* `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
|
|
475
|
+
* SUPERSET. A NULL/missing bound value is skipped (the planner never pushes
|
|
476
|
+
* `= NULL`, and a range op against NULL rejects every row in matchesFilters).
|
|
477
|
+
* A bound over a semantic-ordering leading column (`logicalType`) whose VALUE has
|
|
478
|
+
* no faithful byte position ({@link semanticProbeIsKeyFaithful}) is skipped the
|
|
479
|
+
* same way — a dropped RANGE bound only widens the window, and matchesFilters
|
|
480
|
+
* reproduces the predicate under the type's compare; see the widen-vs-decline
|
|
481
|
+
* note on {@link buildPKRangeBounds}. `transform` is the leading column's key
|
|
482
|
+
* value transform ({@link indexKeyTransforms}), so a surviving bound addresses
|
|
483
|
+
* the same transformed bytes the index store holds.
|
|
484
|
+
*/
|
|
485
|
+
buildIndexRangeBounds(constraints, dir, collation, logicalType, transform) {
|
|
486
|
+
const full = buildFullScanBounds();
|
|
487
|
+
let gte = full.gte;
|
|
488
|
+
let lt;
|
|
489
|
+
for (const c of constraints) {
|
|
490
|
+
if (c.value === undefined || c.value === null)
|
|
491
|
+
continue;
|
|
492
|
+
// Widen, don't decline — see the doc comment above.
|
|
493
|
+
if (!semanticProbeIsKeyFaithful(logicalType, c.value))
|
|
494
|
+
continue;
|
|
495
|
+
const { gte: lo, lt: hi } = buildIndexPrefixBounds([c.value], this.encodeOptions, [dir], [collation], [transform]);
|
|
496
|
+
const lower = !dir
|
|
497
|
+
? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
|
|
498
|
+
: (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
|
|
499
|
+
const upper = !dir
|
|
500
|
+
? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
|
|
501
|
+
: (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
|
|
502
|
+
if (lower && compareBytes(lower, gte) > 0)
|
|
503
|
+
gte = lower;
|
|
504
|
+
if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
|
|
505
|
+
lt = upper;
|
|
506
|
+
}
|
|
507
|
+
return lt === undefined ? { gte } : { gte, lt };
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Scan a secondary index over `access.bounds`, resolving each index entry to its
|
|
511
|
+
* base row and re-filtering.
|
|
512
|
+
*
|
|
513
|
+
* {@link iterateEffective} yields the committed index entries merged with this
|
|
514
|
+
* transaction's pending index puts/deletes (read-your-own-writes over the
|
|
515
|
+
* index), in index-key byte order — the order the isolation overlay merge relies
|
|
516
|
+
* on (`isolated-table.ts` § buildSortKey). We resolve each entry to its row via
|
|
517
|
+
* its stored data-key value WITHOUT reordering, so index-key order is preserved.
|
|
518
|
+
*
|
|
519
|
+
* Defense in depth mirroring the memory layer's live-recheck: a resolved-null
|
|
520
|
+
* row (the entry's row was deleted — a pending index delete would normally
|
|
521
|
+
* suppress the entry, but a committed entry can lag) is skipped, and every
|
|
522
|
+
* resolved row is re-checked by {@link matchesFilters} (the byte window is only
|
|
523
|
+
* a superset, and a stale entry whose indexed column no longer matches is
|
|
524
|
+
* dropped).
|
|
525
|
+
*/
|
|
526
|
+
async *scanIndex(indexStore, access, filterInfo, multi) {
|
|
527
|
+
// Re-check each resolved row under the INDEX's per-column collation (see
|
|
528
|
+
// matchesFilters): the planner dropped the residual based on the index
|
|
529
|
+
// column's collation, which an explicit index `COLLATE` can make differ from
|
|
530
|
+
// the table column's declared collation.
|
|
531
|
+
const indexCollations = multi?.collations
|
|
532
|
+
?? this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
|
|
533
|
+
for await (const entry of this.iterateEffective(indexStore, access.bounds)) {
|
|
534
|
+
// NOTE: a legacy index store (written before index values carried the data
|
|
535
|
+
// key) holds EMPTY values; a zero-length data key is not a row key, so skip
|
|
536
|
+
// it rather than resolve it to the wrong row. Because the access plan marked
|
|
537
|
+
// the filter handled and dropped the residual, an indexed query over such a
|
|
538
|
+
// store returns NOTHING rather than the matching rows — a silent wrong
|
|
539
|
+
// result, not an error. Backwards compatibility is waived project-wide
|
|
540
|
+
// (AGENTS.md) and no test provider carries on-disk data, so nothing exercises
|
|
541
|
+
// this today. If real persisted stores predating this format come into play,
|
|
542
|
+
// their indexes must be dropped + recreated (or the table rebuilt); the
|
|
543
|
+
// durable fix is to version-stamp the index store and rebuild on open, or to
|
|
544
|
+
// fall back to a full scan the first time an empty value is seen.
|
|
545
|
+
if (entry.value.length === 0)
|
|
546
|
+
continue;
|
|
547
|
+
// Cross-window dedup for a multi-seek: a data key an earlier window already
|
|
548
|
+
// YIELDED is skipped before the data-store read, so a duplicate costs no
|
|
549
|
+
// extra `get`. The seen-set is only ever ADDED to on a yield (below) — adding
|
|
550
|
+
// at visit time would let a stale index entry that fails its residual poison
|
|
551
|
+
// the set and suppress the row's live entry in a later window.
|
|
552
|
+
const dataKeyHex = multi ? bytesToHex(entry.value) : undefined;
|
|
553
|
+
if (dataKeyHex !== undefined && multi.seen.has(dataKeyHex))
|
|
554
|
+
continue;
|
|
555
|
+
// NOTE: one extra data-store `get` per matched index entry — the row lives
|
|
556
|
+
// in the data store, not the index (the index value carries only the data
|
|
557
|
+
// key, no covering payload). Fine now; if index-covered scans ever dominate
|
|
558
|
+
// a profile, consider storing the serialized row as a covering index value,
|
|
559
|
+
// at the cost of an index rewrite on EVERY column change (not just indexed
|
|
560
|
+
// columns) — deliberately not done here.
|
|
561
|
+
const row = await this.readEffectiveRowByKey(entry.value);
|
|
562
|
+
if (!row)
|
|
563
|
+
continue;
|
|
564
|
+
if (this.matchesFilters(row, filterInfo, indexCollations)
|
|
565
|
+
|| (multi !== undefined && multi.extraTuples.some(fi => this.matchesFilters(row, fi, indexCollations)))) {
|
|
566
|
+
if (dataKeyHex !== undefined)
|
|
567
|
+
multi.seen.add(dataKeyHex);
|
|
568
|
+
yield row;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Fail loudly on a multi-seek FilterInfo this table cannot serve. The plan already
|
|
574
|
+
* dropped the residual Filter, and {@link matchesFilters} ANDs every pushed
|
|
575
|
+
* constraint — so falling through to the scan arm would AND N mutually-exclusive
|
|
576
|
+
* equalities and return zero rows: a silent wrong answer, not a slow one.
|
|
577
|
+
*/
|
|
578
|
+
multiSeekMalformed(filterInfo, why) {
|
|
579
|
+
throw new QuereusError(`Malformed multi-seek FilterInfo (idxStr '${filterInfo.idxStr}') on ${this.schemaName}.${this.tableName}: ${why}`, StatusCode.INTERNAL);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Decode a multi-seek FilterInfo into its N seek tuples of width W. Tuple i pairs
|
|
583
|
+
* `args[i*W … i*W+W-1]` with the same slice of `constraints` (argvIndex runs
|
|
584
|
+
* 1…N*W in order — see rule-select-access-path's multiSeek emission). A tuple with
|
|
585
|
+
* a NULL/undefined component is dropped: IN is set membership and a NULL component
|
|
586
|
+
* matches nothing. The planner pre-reduces literal lists, but a parameter-bound /
|
|
587
|
+
* mixed-binding list reaches here unreduced, so this skip is the only line of
|
|
588
|
+
* defense for those.
|
|
589
|
+
*/
|
|
590
|
+
decodeMultiSeekTuples(spec, filterInfo) {
|
|
591
|
+
const seekWidth = Number.parseInt(spec.params.get('seekWidth') ?? '1', 10);
|
|
592
|
+
const inCount = Number.parseInt(spec.params.get('inCount') ?? '0', 10);
|
|
593
|
+
if (!Number.isInteger(seekWidth) || seekWidth < 1) {
|
|
594
|
+
this.multiSeekMalformed(filterInfo, `seekWidth=${spec.params.get('seekWidth')}`);
|
|
595
|
+
}
|
|
596
|
+
if (!Number.isInteger(inCount) || inCount < 1) {
|
|
597
|
+
this.multiSeekMalformed(filterInfo, `inCount=${spec.params.get('inCount')}`);
|
|
598
|
+
}
|
|
599
|
+
const constraints = filterInfo.constraints ?? [];
|
|
600
|
+
if (constraints.length < inCount * seekWidth) {
|
|
601
|
+
this.multiSeekMalformed(filterInfo, `${constraints.length} constraints for ${inCount}×${seekWidth} seek keys`);
|
|
602
|
+
}
|
|
603
|
+
if (filterInfo.args.length < inCount * seekWidth) {
|
|
604
|
+
this.multiSeekMalformed(filterInfo, `${filterInfo.args.length} args for ${inCount}×${seekWidth} seek keys`);
|
|
605
|
+
}
|
|
606
|
+
const tuples = [];
|
|
607
|
+
for (let t = 0; t < inCount; t++) {
|
|
608
|
+
const entries = constraints.slice(t * seekWidth, (t + 1) * seekWidth);
|
|
609
|
+
if (entries.some(e => e.constraint.op !== IndexConstraintOp.EQ || e.argvIndex <= 0)) {
|
|
610
|
+
this.multiSeekMalformed(filterInfo, `seek tuple ${t} carries a non-EQ or unbound constraint`);
|
|
611
|
+
}
|
|
612
|
+
const values = entries.map(e => filterInfo.args[e.argvIndex - 1]);
|
|
613
|
+
if (values.some(v => v === null || v === undefined))
|
|
614
|
+
continue;
|
|
615
|
+
tuples.push({ entries, values });
|
|
616
|
+
}
|
|
617
|
+
return { tuples, seekWidth };
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Order one tuple's values to match `keyCols` (the leading index columns, or the
|
|
621
|
+
* PK definition) using each constraint entry's column. The plan's seek columns and
|
|
622
|
+
* the constraint list must agree; a tuple that does not cover exactly those
|
|
623
|
+
* columns is malformed.
|
|
624
|
+
*/
|
|
625
|
+
orderTupleValues(tuple, keyCols, filterInfo) {
|
|
626
|
+
const byCol = new Map();
|
|
627
|
+
tuple.entries.forEach((e, i) => byCol.set(e.constraint.iColumn, tuple.values[i]));
|
|
628
|
+
if (byCol.size !== keyCols.length) {
|
|
629
|
+
this.multiSeekMalformed(filterInfo, `seek tuple covers ${byCol.size} columns, expected ${keyCols.length}`);
|
|
630
|
+
}
|
|
631
|
+
return keyCols.map(colIdx => {
|
|
632
|
+
if (!byCol.has(colIdx))
|
|
633
|
+
this.multiSeekMalformed(filterInfo, `seek tuple missing column ${colIdx}`);
|
|
634
|
+
return byCol.get(colIdx);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Serve a multi-seek (`plan=5`) FilterInfo: one point window per distinct
|
|
639
|
+
* IN-list tuple, scanned in ascending encoded-key order.
|
|
640
|
+
*
|
|
641
|
+
* Window order is not cosmetic. Encoded-byte order IS index-key order (per-column
|
|
642
|
+
* DESC inversion is baked into the bytes), and the isolation overlay merges an
|
|
643
|
+
* index scan with its pending rows by (indexKey, PK) — an out-of-order underlying
|
|
644
|
+
* stream misplaces overlay rows in the output. Two overlap hazards are folded
|
|
645
|
+
* away before scanning:
|
|
646
|
+
* - Tuples whose encoded prefix byte-matches (duplicate bound parameters, or
|
|
647
|
+
* case variants under a NOCASE key collation C) share ONE window, each kept
|
|
648
|
+
* as a residual alternative — see {@link MultiSeekWindowContext}.
|
|
649
|
+
* - A window with no finite upper bound (all-0xff prefix — see
|
|
650
|
+
* buildIndexPrefixBounds) contains every later-sorting window outright (any
|
|
651
|
+
* key ≥ an all-0xff prefix necessarily starts with it), so those windows fold
|
|
652
|
+
* into it instead of re-scanning an overlapping range out of order.
|
|
653
|
+
*
|
|
654
|
+
* Emission is lazy per window — `… in (…) limit 1` stops after the first yielded
|
|
655
|
+
* row without materializing the remaining windows. Each window goes through
|
|
656
|
+
* {@link scanIndex} → {@link iterateEffective}, so read-your-own-writes and the
|
|
657
|
+
* stale-entry / deleted-row defenses hold per seek key.
|
|
658
|
+
*/
|
|
659
|
+
async *scanMultiSeek(spec, filterInfo) {
|
|
660
|
+
const { tuples, seekWidth } = this.decodeMultiSeekTuples(spec, filterInfo);
|
|
661
|
+
if (spec.indexName === '_primary_') {
|
|
662
|
+
yield* this.scanMultiSeekPrimary(tuples, seekWidth, filterInfo);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
|
|
666
|
+
if (!index)
|
|
667
|
+
this.multiSeekMalformed(filterInfo, `no index named '${spec.indexName}'`);
|
|
668
|
+
const indexCols = index.columns.map(c => c.index);
|
|
669
|
+
const indexDirections = index.columns.map(c => !!c.desc);
|
|
670
|
+
if (seekWidth > indexCols.length) {
|
|
671
|
+
this.multiSeekMalformed(filterInfo, `seekWidth ${seekWidth} exceeds ${index.name}'s ${indexCols.length} columns`);
|
|
672
|
+
}
|
|
673
|
+
const seekCols = indexCols.slice(0, seekWidth);
|
|
674
|
+
// The merged byte windows below ARE the whole access — no residual can resurrect a
|
|
675
|
+
// row they skip, and a single unfaithful probe has nowhere to degrade to. A
|
|
676
|
+
// single-window arm can decline to the full scan ({@link analyzePKAccess}) or stop
|
|
677
|
+
// its prefix short ({@link analyzeIndexAccess}); neither move exists here, and an
|
|
678
|
+
// unfaithful probe would either silently drop its tuple's rows or raise out of the
|
|
679
|
+
// key encoder (`jsonStructuralKey` throws INTERNAL for a blob probe). So a
|
|
680
|
+
// semantic-ordering seek column is refused outright.
|
|
681
|
+
// `tryIndexAccessPlan` (store-module-access-plan.ts) declines such plans; one
|
|
682
|
+
// arriving anyway is malformed. Re-opening is backlog
|
|
683
|
+
// `feat-store-semantic-key-multiseek`.
|
|
684
|
+
if (seekCols.some(colIdx => hasSemanticOrdering(this.tableSchema.columns[colIdx]?.logicalType))) {
|
|
685
|
+
this.multiSeekMalformed(filterInfo, 'semantic-ordering seek column');
|
|
686
|
+
}
|
|
687
|
+
// Same rationale for the collation gate: the merged windows drop any tuple the
|
|
688
|
+
// bytes exclude, so a prefix whose key collation differs from the one the residual
|
|
689
|
+
// re-compares under would under-fetch with nothing left to repair it.
|
|
690
|
+
// `tryIndexAccessPlan` declines such plans through this same predicate; one
|
|
691
|
+
// arriving anyway is malformed. {@link analyzeIndexAccess} makes the point-window
|
|
692
|
+
// twin of this check.
|
|
693
|
+
const indexKeyCollations = this.indexKeyCollations(index);
|
|
694
|
+
if (!indexPrefixSeekIsCollationExact(this.tableSchema.columns, index, indexKeyCollations, seekWidth)) {
|
|
695
|
+
this.multiSeekMalformed(filterInfo, 'index key collation differs from the comparison collation');
|
|
696
|
+
}
|
|
697
|
+
// One window per distinct encoded tuple prefix (each column under its own key
|
|
698
|
+
// collation C — see indexKeyCollations); C-equal tuples merge into one window,
|
|
699
|
+
// each kept as a residual alternative.
|
|
700
|
+
const seekCollations = indexKeyCollations.slice(0, seekWidth);
|
|
701
|
+
const seekTransforms = this.indexKeyTransforms(index).slice(0, seekWidth);
|
|
702
|
+
const windows = new Map();
|
|
703
|
+
for (const tuple of tuples) {
|
|
704
|
+
const ordered = this.orderTupleValues(tuple, seekCols, filterInfo);
|
|
705
|
+
const bounds = buildIndexPrefixBounds(ordered, this.encodeOptions, indexDirections.slice(0, seekWidth), seekCollations, seekTransforms);
|
|
706
|
+
const hex = bytesToHex(bounds.gte);
|
|
707
|
+
const info = { ...filterInfo, constraints: tuple.entries };
|
|
708
|
+
const existing = windows.get(hex);
|
|
709
|
+
if (existing)
|
|
710
|
+
existing.infos.push(info);
|
|
711
|
+
else
|
|
712
|
+
windows.set(hex, { bounds, infos: [info] });
|
|
713
|
+
}
|
|
714
|
+
if (windows.size === 0)
|
|
715
|
+
return; // every tuple had a NULL component — nothing matches
|
|
716
|
+
const sorted = [...windows.values()].sort((a, b) => compareBytes(a.bounds.gte, b.bounds.gte));
|
|
717
|
+
const disjoint = [];
|
|
718
|
+
for (const w of sorted) {
|
|
719
|
+
const last = disjoint[disjoint.length - 1];
|
|
720
|
+
if (last && last.bounds.lt === undefined)
|
|
721
|
+
last.infos.push(...w.infos);
|
|
722
|
+
else
|
|
723
|
+
disjoint.push(w);
|
|
724
|
+
}
|
|
725
|
+
const indexStore = await this.ensureIndexStore(index.name);
|
|
726
|
+
// Identical for every window (resolveFilterCollations dedups by column), so
|
|
727
|
+
// resolve once and thread it through.
|
|
728
|
+
const collations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(index));
|
|
729
|
+
const seen = new Set();
|
|
730
|
+
for (const w of disjoint) {
|
|
731
|
+
yield* this.scanIndex(indexStore, { index, type: 'point', bounds: w.bounds }, w.infos[0], { seen, collations, extraTuples: w.infos.slice(1) });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Multi-seek over the PRIMARY key: each tuple is a full PK, resolved by a point
|
|
736
|
+
* read in ascending encoded-data-key order (the table's native emission order).
|
|
737
|
+
*
|
|
738
|
+
* NOT reachable from this module's own plans today: `computeBestAccessPlan` (store-module-access-plan.ts)
|
|
739
|
+
* claims IN-list filters only for secondary indexes (see its EQ_OPS vs EQ_OR_IN_OPS
|
|
740
|
+
* split and tickets/backlog/feat-store-pk-in-list-multiseek). The branch exists so
|
|
741
|
+
* a `_primary_` multi-seek arriving from a future plan gets a correct answer rather
|
|
742
|
+
* than the scan arm's silent zero rows, and it is what PK-IN enablement will build on.
|
|
743
|
+
*/
|
|
744
|
+
async *scanMultiSeekPrimary(tuples, seekWidth, filterInfo) {
|
|
745
|
+
const pkColumns = this.tableSchema.primaryKeyDefinition.map(pk => pk.index);
|
|
746
|
+
if (pkColumns.length === 0) {
|
|
747
|
+
this.multiSeekMalformed(filterInfo, 'primary-key multi-seek on a table with no primary key');
|
|
748
|
+
}
|
|
749
|
+
if (seekWidth !== pkColumns.length) {
|
|
750
|
+
this.multiSeekMalformed(filterInfo, `seekWidth ${seekWidth} does not cover the ${pkColumns.length}-column primary key`);
|
|
751
|
+
}
|
|
752
|
+
// Same rule as the secondary-index branch (see scanMultiSeek): with the residual
|
|
753
|
+
// gone and N windows merged there is no scan to degrade to and no prefix to stop
|
|
754
|
+
// short, so fail loudly rather than risk an under-fetch. `analyzePKAccess`'s
|
|
755
|
+
// SINGLE-value point arm has that escape and no longer declines — see
|
|
756
|
+
// pkHasSemanticOrderingMember.
|
|
757
|
+
if (this.pkHasSemanticOrderingMember()) {
|
|
758
|
+
this.multiSeekMalformed(filterInfo, 'semantic-ordering primary-key member');
|
|
759
|
+
}
|
|
760
|
+
// Dedup by encoded data key (collapses K-equal tuples), keeping every merged
|
|
761
|
+
// tuple's residual as an alternative — same rationale as the index branch.
|
|
762
|
+
const points = new Map();
|
|
763
|
+
for (const tuple of tuples) {
|
|
764
|
+
const ordered = this.orderTupleValues(tuple, pkColumns, filterInfo);
|
|
765
|
+
const key = this.encodeDataKey(ordered);
|
|
766
|
+
const hex = bytesToHex(key);
|
|
767
|
+
const info = { ...filterInfo, constraints: tuple.entries };
|
|
768
|
+
const existing = points.get(hex);
|
|
769
|
+
if (existing)
|
|
770
|
+
existing.infos.push(info);
|
|
771
|
+
else
|
|
772
|
+
points.set(hex, { key, infos: [info] });
|
|
773
|
+
}
|
|
774
|
+
const collations = this.resolveFilterCollations(filterInfo);
|
|
775
|
+
for (const p of [...points.values()].sort((a, b) => compareBytes(a.key, b.key))) {
|
|
776
|
+
const row = await this.readEffectiveRowByKey(p.key);
|
|
777
|
+
if (row && p.infos.some(fi => this.matchesFilters(row, fi, collations))) {
|
|
778
|
+
yield row;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Check if a row matches the filter constraints.
|
|
784
|
+
*
|
|
785
|
+
* `collations` maps a constrained column index to the comparison function that
|
|
786
|
+
* column must be re-checked under; every caller builds it once per scan with
|
|
787
|
+
* {@link resolveFilterCollations}, whose doc comment explains how the name is
|
|
788
|
+
* chosen. A column absent from the map compares BINARY — the same result
|
|
789
|
+
* {@link resolveFilterCollations} produces for an undeclared collation, and the
|
|
790
|
+
* only reachable absence, since both walk the constraint list under identical
|
|
791
|
+
* skip conditions.
|
|
792
|
+
*/
|
|
793
|
+
matchesFilters(row, filterInfo, collations) {
|
|
794
|
+
if (!filterInfo.constraints || filterInfo.constraints.length === 0) {
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
for (const constraintEntry of filterInfo.constraints) {
|
|
798
|
+
const { constraint, argvIndex } = constraintEntry;
|
|
799
|
+
if (constraint.iColumn < 0 || argvIndex <= 0) {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const rowValue = row[constraint.iColumn];
|
|
803
|
+
const filterValue = filterInfo.args[argvIndex - 1];
|
|
804
|
+
const collation = collations.get(constraint.iColumn) ?? BINARY_COLLATION;
|
|
805
|
+
// A semantic-ordering column (TIMESPAN, JSON) must filter under the type's
|
|
806
|
+
// compare — the same order the engine's operators and Sort use — or a pushed
|
|
807
|
+
// `d > 'PT90M'` would text-compare and drop rows the predicate admits. The
|
|
808
|
+
// comparator is built per row only on this rare column kind; if a profile
|
|
809
|
+
// ever shows it, resolve it once per scan alongside the collations.
|
|
810
|
+
const logicalType = this.tableSchema.columns[constraint.iColumn]?.logicalType;
|
|
811
|
+
const compare = hasSemanticOrdering(logicalType)
|
|
812
|
+
? createTypedComparator(logicalType, collation)
|
|
813
|
+
: undefined;
|
|
814
|
+
if (!this.compareValues(rowValue, constraint.op, filterValue, collation, compare)) {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Resolve, ONCE per scan, the comparison collation function for every pushed
|
|
822
|
+
* constraint column, keyed by column index.
|
|
823
|
+
*
|
|
824
|
+
* The name for a column is its DECLARED collation (absent ⇒ BINARY) — the same
|
|
825
|
+
* resolution the access path's collation-cover analysis uses
|
|
826
|
+
* (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides a
|
|
827
|
+
* pushed constraint is fully covered, and the same source `StoreTableConstraints`'
|
|
828
|
+
* UNIQUE checks compare under. On a collation MATCH the planner drops the residual
|
|
829
|
+
* Filter, so {@link matchesFilters} alone must reproduce the predicate.
|
|
830
|
+
*
|
|
831
|
+
* `indexCollationNames` (from {@link indexColumnCollations}) overrides the
|
|
832
|
+
* declared name for the secondary-index scan arm: the planner's MATCH there is
|
|
833
|
+
* against the INDEX column's collation, which an explicit `COLLATE` on the index
|
|
834
|
+
* can make differ from the table column's.
|
|
835
|
+
*
|
|
836
|
+
* Names resolve against {@link collationResolver}, so an unregistered collation
|
|
837
|
+
* raises `no such collation sequence` at scan setup rather than byte-ordering
|
|
838
|
+
* every row.
|
|
839
|
+
*
|
|
840
|
+
* NOTE: rebuilt on every `query()` / `scanPKRange()` / `scanIndex()` call — one
|
|
841
|
+
* registry lookup per distinct constrained column, dwarfed by the scan's I/O. If a
|
|
842
|
+
* point-lookup-heavy profile ever shows it, memoize on the `FilterInfo`.
|
|
843
|
+
*/
|
|
844
|
+
resolveFilterCollations(filterInfo, indexCollationNames) {
|
|
845
|
+
const resolved = new Map();
|
|
846
|
+
if (!filterInfo.constraints)
|
|
847
|
+
return resolved;
|
|
848
|
+
for (const { constraint, argvIndex } of filterInfo.constraints) {
|
|
849
|
+
if (constraint.iColumn < 0 || argvIndex <= 0)
|
|
850
|
+
continue;
|
|
851
|
+
if (resolved.has(constraint.iColumn))
|
|
852
|
+
continue;
|
|
853
|
+
const name = indexCollationNames?.has(constraint.iColumn)
|
|
854
|
+
? indexCollationNames.get(constraint.iColumn)
|
|
855
|
+
: this.tableSchema.columns[constraint.iColumn]?.collation;
|
|
856
|
+
resolved.set(constraint.iColumn, name ? this.collationResolver(name) : BINARY_COLLATION);
|
|
857
|
+
}
|
|
858
|
+
return resolved;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Effective per-column comparison collation for a secondary index's columns:
|
|
862
|
+
* the index column's own `COLLATE` when present, else the underlying table
|
|
863
|
+
* column's declared collation. Mirrors the resolution `StoreModule`'s
|
|
864
|
+
* index-maintenance UNIQUE dedup uses (`indexCol.collation ?? tableColumn`), so
|
|
865
|
+
* a re-checked index-scan row compares under the same collation the planner used
|
|
866
|
+
* to justify dropping (or keeping) the residual Filter.
|
|
867
|
+
*/
|
|
868
|
+
indexColumnCollations(index) {
|
|
869
|
+
const cols = this.tableSchema.columns;
|
|
870
|
+
const map = new Map();
|
|
871
|
+
for (const c of index.columns) {
|
|
872
|
+
map.set(c.index, c.collation ?? cols[c.index]?.collation);
|
|
873
|
+
}
|
|
874
|
+
return map;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Compare two values according to an operator, under `collationFunc` (already
|
|
878
|
+
* resolved by {@link resolveFilterCollations} against this database's collation
|
|
879
|
+
* registry). So the LT/LE/GT/GE range bounds honour a NOCASE/RTRIM/custom column
|
|
880
|
+
* collation rather than a raw BINARY JS comparison — the capability
|
|
881
|
+
* `StoreModule.getBestAccessPlan` advertises via `honorsCollatedRangeBounds`.
|
|
882
|
+
* NULL on either side fails every operator except EQ-with-both-NULL (the
|
|
883
|
+
* internal point-lookup convention; the planner never pushes `= NULL`).
|
|
884
|
+
*/
|
|
885
|
+
compareValues(a, op, b, collationFunc, semanticCompare) {
|
|
886
|
+
if (a === null || b === null) {
|
|
887
|
+
return op === IndexConstraintOp.EQ ? a === b : false;
|
|
888
|
+
}
|
|
889
|
+
const cmp = semanticCompare ? semanticCompare(a, b) : compareSqlValuesFast(a, b, collationFunc);
|
|
890
|
+
switch (op) {
|
|
891
|
+
case IndexConstraintOp.EQ: return cmp === 0;
|
|
892
|
+
case IndexConstraintOp.NE: return cmp !== 0;
|
|
893
|
+
case IndexConstraintOp.LT: return cmp < 0;
|
|
894
|
+
case IndexConstraintOp.LE: return cmp <= 0;
|
|
895
|
+
case IndexConstraintOp.GT: return cmp > 0;
|
|
896
|
+
case IndexConstraintOp.GE: return cmp >= 0;
|
|
897
|
+
default: return true;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
//# sourceMappingURL=store-table-scan.js.map
|