@quereus/store 4.3.1 → 4.4.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 +486 -436
- package/dist/src/common/bytes.d.ts +4 -1
- package/dist/src/common/bytes.d.ts.map +1 -1
- package/dist/src/common/bytes.js +23 -2
- package/dist/src/common/bytes.js.map +1 -1
- package/dist/src/common/cached-kv-store.d.ts.map +1 -1
- package/dist/src/common/cached-kv-store.js +6 -13
- package/dist/src/common/cached-kv-store.js.map +1 -1
- package/dist/src/common/encoding.d.ts +69 -26
- package/dist/src/common/encoding.d.ts.map +1 -1
- package/dist/src/common/encoding.js +292 -230
- package/dist/src/common/encoding.js.map +1 -1
- package/dist/src/common/index.d.ts +1 -1
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +1 -1
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +5 -1
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +30 -2
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +1 -1
- package/dist/src/common/memory-store.d.ts.map +1 -1
- package/dist/src/common/memory-store.js +21 -6
- package/dist/src/common/memory-store.js.map +1 -1
- package/dist/src/common/serialization.d.ts.map +1 -1
- package/dist/src/common/serialization.js +6 -3
- package/dist/src/common/serialization.js.map +1 -1
- package/dist/src/common/store-module.d.ts +293 -26
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +1543 -612
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table.d.ts +515 -36
- package/dist/src/common/store-table.d.ts.map +1 -1
- package/dist/src/common/store-table.js +873 -100
- package/dist/src/common/store-table.js.map +1 -1
- package/dist/src/common/transaction.d.ts +8 -5
- package/dist/src/common/transaction.d.ts.map +1 -1
- package/dist/src/common/transaction.js +32 -5
- package/dist/src/common/transaction.js.map +1 -1
- package/dist/src/testing/kv-conformance.d.ts +47 -0
- package/dist/src/testing/kv-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-conformance.js +418 -0
- package/dist/src/testing/kv-conformance.js.map +1 -0
- package/package.json +17 -5
|
@@ -9,12 +9,11 @@
|
|
|
9
9
|
* - Index stores: {schema}.{table}_idx_{name} - one per secondary index
|
|
10
10
|
* - Stats store: __stats__ - unified store for all table statistics, keyed by {schema}.{table}
|
|
11
11
|
*/
|
|
12
|
-
import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, rowsValueIdentical, validateAndParse, compilePredicate, maintainedTableUniqueViolationError, uniqueEnforcementCollations, } from '@quereus/quereus';
|
|
12
|
+
import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, compareSqlValuesFast, resolveUniqueEnforcementCollations, BINARY_COLLATION, rowsValueIdentical, validateAndParse, compilePredicate, decodeIdxStr, maintainedTableUniqueViolationError, uniqueEnforcementCollations, logicalTypeCanHoldText, pkKeyCollationName, } from '@quereus/quereus';
|
|
13
13
|
import { bytesEqual, bytesToHex, compareBytes } from './bytes.js';
|
|
14
14
|
import { StoreConnection } from './store-connection.js';
|
|
15
|
-
import { buildDataKey, buildIndexKey, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
|
|
15
|
+
import { buildDataKey, buildIndexKey, buildIndexPrefixBounds, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
|
|
16
16
|
import { serializeRow, deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
|
|
17
|
-
import { getCollationEncoder } from './encoding.js';
|
|
18
17
|
/** Number of mutations before persisting statistics. */
|
|
19
18
|
const STATS_FLUSH_INTERVAL = 100;
|
|
20
19
|
/** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
|
|
@@ -52,31 +51,200 @@ function resolvePkDefaultConflict(schema) {
|
|
|
52
51
|
/**
|
|
53
52
|
* Resolve the per-column KEY collation for each primary-key column.
|
|
54
53
|
*
|
|
55
|
-
* The store encodes PRIMARY KEY uniqueness/ordering PHYSICALLY in the key bytes,
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* - text member → its declared `collation` (normalized upper-case), or
|
|
54
|
+
* The store encodes PRIMARY KEY uniqueness/ordering PHYSICALLY in the key bytes, so each
|
|
55
|
+
* text-capable PK column's key must be encoded under the collation the engine actually
|
|
56
|
+
* COMPARES that column under. Returns one entry per PK member, in `pkDef` order:
|
|
57
|
+
* - `isTextual` member (`text`) → its declared `collation` (normalized upper-case), or
|
|
60
58
|
* `fallback` (the table key collation K) when the column carries none.
|
|
61
|
-
* -
|
|
59
|
+
* - text-capable but not `isTextual` member — `any`, `json`, and the temporal types
|
|
60
|
+
* (`date` / `time` / `datetime` / `timespan`) → hard-coded `'BINARY'`, see below.
|
|
61
|
+
* - never-text member → `undefined`: collation is meaningless for
|
|
62
62
|
* integer/real/blob keys (they encode type-natively), so the encoder ignores
|
|
63
63
|
* it and the data/index key bytes are identical regardless.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* Note that `undefined` does NOT mean "encode type-natively" to `encodeValue` — it means
|
|
66
|
+
* "fall back to `options.collation`", the table key collation K. Only a never-text column
|
|
67
|
+
* may be left `undefined`, because only it never reaches the collation branch.
|
|
68
|
+
*
|
|
69
|
+
* Every name returned here is encoded through the key-normalizer resolver carried
|
|
70
|
+
* in `EncodeOptions.normalizers` (`db.getKeyNormalizerResolver()`), so a collation
|
|
71
|
+
* registered or overridden with `db.registerCollation` produces key bytes that agree
|
|
72
|
+
* with the comparator the store's UNIQUE enforcement uses. A collation that cannot
|
|
73
|
+
* key — unregistered, or registered with a comparator but no normalizer — is rejected
|
|
74
|
+
* at DDL time by {@link StoreTable.validateKeyCollations}, so no encode call ever has
|
|
75
|
+
* to fall back. Shared by {@link StoreTable} (data-key + index-maintenance) and
|
|
69
76
|
* `StoreModule.buildIndexEntries` (index rebuild) so the PK suffix encoding can
|
|
70
77
|
* never drift between the two.
|
|
71
78
|
*/
|
|
72
79
|
export function resolvePkKeyCollations(pkDef, columns, fallback) {
|
|
73
80
|
return pkDef.map(def => {
|
|
74
81
|
const col = columns[def.index];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// Delegates the isTextual/text-capable/never-text branch to the engine's
|
|
83
|
+
// `pkKeyCollationName` — the same decision `quereus-isolation`'s modified-PK set
|
|
84
|
+
// makes for its own key normalizers, so the two can never drift apart again. Only
|
|
85
|
+
// the fallback-to-K and uppercase-normalization are store-specific.
|
|
86
|
+
const name = pkKeyCollationName(col);
|
|
87
|
+
return name === undefined ? undefined : (name || fallback).toUpperCase();
|
|
78
88
|
});
|
|
79
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* True when `col` can produce a TEXT value at runtime, and so when its physical
|
|
92
|
+
* key bytes are produced by a collation normalizer rather than a type-native
|
|
93
|
+
* encoding. The `ColumnSchema`-shaped wrapper over the engine's
|
|
94
|
+
* {@link logicalTypeCanHoldText}; both collation-safety guards over the store's
|
|
95
|
+
* secondary indexes — the write-side {@link StoreTable.indexSeekHonorsEnforcementCollation}
|
|
96
|
+
* and the read-side `StoreModule.tryIndexAccessPlan` — exempt a never-text column
|
|
97
|
+
* from their K-vs-C comparison, so a false "non-text" answer is a silent
|
|
98
|
+
* wrong-result (a seek under the wrong collation, with the residual dropped).
|
|
99
|
+
*/
|
|
100
|
+
export function columnCanHoldText(col) {
|
|
101
|
+
return logicalTypeCanHoldText(col?.logicalType);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* True when a byte window — or a byte-order advertisement — over `column` is SOUND.
|
|
105
|
+
*
|
|
106
|
+
* The store physically orders rows by memcmp of each column's key bytes, which for a
|
|
107
|
+
* text-capable column are produced by `keyCollation`'s normalizer. A range seek (and the
|
|
108
|
+
* `providesOrdering` / `monotonicOn` advertisement) equates that byte order with the order
|
|
109
|
+
* `compareCollation`'s comparator defines. Two things must hold for that equation:
|
|
110
|
+
*
|
|
111
|
+
* - **Same collation on both sides** (`compareCollation === keyCollation`). A `keyCollation`
|
|
112
|
+
* merely COARSER than `compareCollation` — the relaxation the EQUALITY seek is allowed to
|
|
113
|
+
* make, see `StoreModule.tryIndexAccessPlan` — is not enough here: it would need the key
|
|
114
|
+
* normalizer to be monotone with respect to the OTHER collation's order. It generally is
|
|
115
|
+
* not, even for built-ins: with `keyCollation = NOCASE` and `compareCollation = BINARY`,
|
|
116
|
+
* 'K' (U+212A KELVIN SIGN) is `> 'z'` under BINARY, yet its key bytes are
|
|
117
|
+
* `toLowerCase('K') = 'k'`, which sorts BEFORE 'z' — the row falls outside a `> 'z'`
|
|
118
|
+
* window and is silently dropped.
|
|
119
|
+
* - **The normalizer preserves order** (`db._isCollationOrderPreserving`). `registerCollation`
|
|
120
|
+
* promises only that a normalizer partitions strings the way the comparator calls them
|
|
121
|
+
* equal; a custom pair may agree on equality and disagree on order.
|
|
122
|
+
*
|
|
123
|
+
* A never-text column is exempt: its key bytes are type-native and collation-independent.
|
|
124
|
+
*
|
|
125
|
+
* Returning `false` costs an optimization (the caller full-scans and lets the
|
|
126
|
+
* collation-aware `matchesFilters` residual decide); returning `true` wrongly is a silent
|
|
127
|
+
* wrong-result. Shared by `StoreTable`'s read arms and `StoreModule`'s access planner so the
|
|
128
|
+
* "mark handled" and "build a window" decisions can never disagree.
|
|
129
|
+
*/
|
|
130
|
+
export function keyOrderMatchesCollation(db, column, keyCollation, compareCollation) {
|
|
131
|
+
if (!columnCanHoldText(column))
|
|
132
|
+
return true;
|
|
133
|
+
if (compareCollation.toUpperCase() !== keyCollation.toUpperCase())
|
|
134
|
+
return false;
|
|
135
|
+
return db._isCollationOrderPreserving(keyCollation);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* How many LEADING primary-key members have key bytes whose memcmp order matches their
|
|
139
|
+
* declared collation's comparator order, per {@link keyOrderMatchesCollation}.
|
|
140
|
+
*
|
|
141
|
+
* `pkKeyCollations` is {@link resolvePkKeyCollations}' output for this table; an `undefined`
|
|
142
|
+
* entry belongs to a never-text member, which {@link keyOrderMatchesCollation} exempts
|
|
143
|
+
* outright — `tableKeyCollation` is passed only so the exempt branch has a name to ignore.
|
|
144
|
+
* The comparison collation is the column's declared one, which `reconcilePkCollations` has
|
|
145
|
+
* already aligned with the key collation for every *textual* PK member.
|
|
146
|
+
*
|
|
147
|
+
* What the advertisement is measured against is the order the planner's `Sort` would have
|
|
148
|
+
* produced, and `Sort` (like every scalar comparison) orders under the operand's COLLATION via
|
|
149
|
+
* `compareSqlValuesFast` — never through `logicalType.compare`. So a collation match is the
|
|
150
|
+
* whole question, even for a member whose type declares a `compare` that orders differently
|
|
151
|
+
* (`TIMESPAN.compare` ranks by `Temporal.Duration` total, `JSON_TYPE.compare` structurally).
|
|
152
|
+
* `MemoryTable`, whose primary-key BTree *is* keyed by `createTypedComparator`, advertises the
|
|
153
|
+
* type's order instead and so disagrees with its own `Sort` — tracked as
|
|
154
|
+
* `bug-memory-pk-btree-orders-by-logical-type-compare`, not something the store should copy.
|
|
155
|
+
*
|
|
156
|
+
* `0` ⇒ even the leading member is unsafe: no range seek, no PK-order advertisement.
|
|
157
|
+
* A prefix shorter than the PK truncates the ordering advertisement rather than voiding it.
|
|
158
|
+
*/
|
|
159
|
+
export function pkOrderPreservingPrefixLength(db, schema, pkKeyCollations, tableKeyCollation) {
|
|
160
|
+
const pk = schema.primaryKeyDefinition;
|
|
161
|
+
let n = 0;
|
|
162
|
+
while (n < pk.length) {
|
|
163
|
+
const col = schema.columns[pk[n].index];
|
|
164
|
+
const keyCollation = pkKeyCollations[n] ?? tableKeyCollation;
|
|
165
|
+
// NOTE: an explicit `k any collate nocase` PK declines its seek here — the key
|
|
166
|
+
// collation is BINARY (what ANY_TYPE.compare actually uses) while `col.collation` is
|
|
167
|
+
// the declared-but-ignored NOCASE, so the two sides differ and the gate closes. Both
|
|
168
|
+
// sides genuinely compare under BINARY, so the decline is conservative: it costs the
|
|
169
|
+
// seek, never a row. If that declaration ever shows up in practice, resolve the
|
|
170
|
+
// comparison collation the way `resolvePkKeyCollations` resolves the key collation
|
|
171
|
+
// rather than widening the gate.
|
|
172
|
+
if (!keyOrderMatchesCollation(db, col, keyCollation, col?.collation ?? 'BINARY'))
|
|
173
|
+
break;
|
|
174
|
+
n++;
|
|
175
|
+
}
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Deterministic name of the implicit covering index materialized for a
|
|
180
|
+
* NON-DERIVED UNIQUE constraint `uc`: the constraint's own name when it has one,
|
|
181
|
+
* else the auto-name `_uc_<colNames>`. This MUST equal the engine's
|
|
182
|
+
* `catalog.ts` `implicitIndexName`, so a name that ever reaches the catalog
|
|
183
|
+
* bundle generator is recognized as hidden / exposed-implicit rather than a user
|
|
184
|
+
* index.
|
|
185
|
+
*/
|
|
186
|
+
export function implicitUniqueIndexName(schema, uc) {
|
|
187
|
+
return uc.name ?? `_uc_${uc.columns.map(i => schema.columns[i]?.name ?? String(i)).join('_')}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Return `schema` with a synthetic secondary index materialized into
|
|
191
|
+
* `schema.indexes` for every NON-DERIVED UNIQUE constraint that lacks one — the
|
|
192
|
+
* store analogue of `MemoryTableManager.ensureUniqueConstraintIndexes`.
|
|
193
|
+
*
|
|
194
|
+
* This is what lets a plain column- or table-level `UNIQUE` be enforced by a
|
|
195
|
+
* bounded index point-seek ({@link StoreTable.findUniqueConflictViaIndex})
|
|
196
|
+
* instead of an O(rows) full scan: DML maintenance
|
|
197
|
+
* ({@link StoreTable.updateSecondaryIndexes}) and the UNIQUE check both iterate
|
|
198
|
+
* `schema.indexes`, so once the `_uc_*` entry is present the physical index is
|
|
199
|
+
* maintained and seeked with no new code path.
|
|
200
|
+
*
|
|
201
|
+
* The result is held ONLY in {@link StoreTable.materializedSchema} (the
|
|
202
|
+
* enforcement copy), never in the engine-facing `tableSchema` the store returns
|
|
203
|
+
* from `getSchema()` / registers with the engine. So the read-query planner and
|
|
204
|
+
* the catalog bundle never see `_uc_*` (it is fully derived on open from
|
|
205
|
+
* `uniqueConstraints`); like the memory backend, a plain UNIQUE gets no read-side
|
|
206
|
+
* plan from `_uc_*`, only enforcement / maintenance.
|
|
207
|
+
*
|
|
208
|
+
* Idempotent — a name already present is left untouched — and returns a frozen
|
|
209
|
+
* schema (or the input unchanged when there is nothing to add). A
|
|
210
|
+
* `derivedFromIndex` UC is skipped (its `CREATE UNIQUE INDEX` is already in
|
|
211
|
+
* `schema.indexes`). A partial UNIQUE's synthetic index carries the SAME
|
|
212
|
+
* `uc.predicate` object reference, which
|
|
213
|
+
* {@link StoreTable.findIndexForUniqueConstraint} matches on identity.
|
|
214
|
+
*/
|
|
215
|
+
export function withImplicitUniqueIndexes(schema) {
|
|
216
|
+
const ucs = schema.uniqueConstraints;
|
|
217
|
+
if (!ucs || ucs.length === 0)
|
|
218
|
+
return schema;
|
|
219
|
+
const existing = schema.indexes ?? [];
|
|
220
|
+
const present = new Set(existing.map(idx => idx.name.toLowerCase()));
|
|
221
|
+
const additions = [];
|
|
222
|
+
for (const uc of ucs) {
|
|
223
|
+
if (uc.derivedFromIndex)
|
|
224
|
+
continue;
|
|
225
|
+
const name = implicitUniqueIndexName(schema, uc);
|
|
226
|
+
if (present.has(name.toLowerCase()))
|
|
227
|
+
continue;
|
|
228
|
+
present.add(name.toLowerCase());
|
|
229
|
+
additions.push({
|
|
230
|
+
name,
|
|
231
|
+
columns: uc.columns.map(idx => ({ index: idx, collation: schema.columns[idx]?.collation })),
|
|
232
|
+
// SAME reference as `uc.predicate` (undefined for a full UNIQUE) — load-bearing:
|
|
233
|
+
// findIndexForUniqueConstraint admits a partial `_uc_*` by `ix.predicate === uc.predicate`.
|
|
234
|
+
predicate: uc.predicate,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (additions.length === 0)
|
|
238
|
+
return schema;
|
|
239
|
+
return Object.freeze({ ...schema, indexes: Object.freeze([...existing, ...additions]) });
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Returned by {@link StoreTable.findUniqueConflictViaIndex} when the index
|
|
243
|
+
* cannot soundly answer the check and the caller must fall back to the full
|
|
244
|
+
* data-store scan. Distinct from `null`, which means "the index answered: no
|
|
245
|
+
* conflict".
|
|
246
|
+
*/
|
|
247
|
+
const INDEX_UNUSABLE = Symbol('store.uniqueIndexUnusable');
|
|
80
248
|
/**
|
|
81
249
|
* Generic KVStore-backed virtual table.
|
|
82
250
|
*
|
|
@@ -111,6 +279,22 @@ export class StoreTable extends VirtualTable {
|
|
|
111
279
|
* {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
|
|
112
280
|
*/
|
|
113
281
|
pkKeyCollations;
|
|
282
|
+
/**
|
|
283
|
+
* `db.getCollationResolver()`, bound once at construction. Every collation-aware
|
|
284
|
+
* VALUE comparison this table makes — pushed-constraint re-check, UNIQUE conflict
|
|
285
|
+
* detection — resolves names through it, so a collation registered with
|
|
286
|
+
* `db.registerCollation` on *this* connection is honored rather than silently
|
|
287
|
+
* degraded to BINARY by the process-global built-in registry.
|
|
288
|
+
*
|
|
289
|
+
* Only the resolver closure is cached: it reads the live registry, so a collation
|
|
290
|
+
* registered after connect is still visible. Resolved *functions* are hoisted no
|
|
291
|
+
* further than the comparator or constraint check that resolved them.
|
|
292
|
+
*
|
|
293
|
+
* Its key-bytes counterpart, `db.getKeyNormalizerResolver()`, is bound into
|
|
294
|
+
* {@link encodeOptions} so the names in {@link pkKeyCollations} resolve to the same
|
|
295
|
+
* per-connection registry.
|
|
296
|
+
*/
|
|
297
|
+
collationResolver;
|
|
114
298
|
ddlSaved = false;
|
|
115
299
|
// Statistics tracking
|
|
116
300
|
cachedStats = null;
|
|
@@ -127,31 +311,126 @@ export class StoreTable extends VirtualTable {
|
|
|
127
311
|
// object, so the WeakMap reclaims retired entries). Mirrors predicateCache but
|
|
128
312
|
// for secondary-index maintenance rather than UNIQUE enforcement.
|
|
129
313
|
indexPredicateCache = new WeakMap();
|
|
314
|
+
/**
|
|
315
|
+
* {@link tableSchema} with a hidden `_uc_*` secondary index materialized per
|
|
316
|
+
* non-derived UNIQUE constraint (see {@link withImplicitUniqueIndexes}) — the
|
|
317
|
+
* schema that drives UNIQUE ENFORCEMENT ({@link findIndexForUniqueConstraint})
|
|
318
|
+
* and physical index MAINTENANCE ({@link updateSecondaryIndexes}).
|
|
319
|
+
*
|
|
320
|
+
* Deliberately SEPARATE from the engine-facing {@link tableSchema}: the engine
|
|
321
|
+
* reads `tableInstance.tableSchema` at CREATE (`SchemaManager.finalizeCreatedTableSchema`)
|
|
322
|
+
* and the read-query planner reads the registered schema, and NEITHER may see
|
|
323
|
+
* `_uc_*` (mirrors the memory backend keeping its materialized schema
|
|
324
|
+
* manager-local; a plain UNIQUE gets no read-side plan from `_uc_*`, only
|
|
325
|
+
* enforcement). Recomputed alongside `tableSchema` in the constructor and
|
|
326
|
+
* {@link updateSchema}.
|
|
327
|
+
*/
|
|
328
|
+
materializedSchema;
|
|
130
329
|
constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
|
|
131
330
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
331
|
super(db, storeModule, tableSchema.schemaName, tableSchema.name);
|
|
133
332
|
this.storeModule = storeModule;
|
|
333
|
+
// The engine-facing schema stays NON-materialized (the engine registers it and the
|
|
334
|
+
// read-query planner reads it — neither may see `_uc_*`); the hidden `_uc_*`
|
|
335
|
+
// per-UNIQUE index that lets plain UNIQUE enforce via a point-seek lives only in
|
|
336
|
+
// {@link materializedSchema}.
|
|
134
337
|
this.tableSchema = tableSchema;
|
|
338
|
+
this.materializedSchema = withImplicitUniqueIndexes(tableSchema);
|
|
135
339
|
this.config = config;
|
|
136
340
|
this.eventEmitter = eventEmitter;
|
|
137
|
-
this.
|
|
341
|
+
this.collationResolver = db.getCollationResolver();
|
|
342
|
+
this.encodeOptions = {
|
|
343
|
+
collation: config.collation || 'NOCASE',
|
|
344
|
+
normalizers: db.getKeyNormalizerResolver(),
|
|
345
|
+
};
|
|
138
346
|
this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
139
347
|
this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
|
|
348
|
+
// Validate the MATERIALIZED schema, so a `_uc_*` over a text column that needs the
|
|
349
|
+
// table key collation K is rejected up front just like an explicit index would be.
|
|
350
|
+
this.validateKeyCollations(this.materializedSchema, this.pkKeyCollations);
|
|
140
351
|
this.ddlSaved = isConnected;
|
|
141
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Reject, at DDL time, any collation this table's key encoding would need but cannot
|
|
355
|
+
* use — before a single row is written under bytes the connection cannot reproduce.
|
|
356
|
+
*
|
|
357
|
+
* A collation keys a persisted structure only if it carries a KEY NORMALIZER (the
|
|
358
|
+
* `(s) => string` whose output equality partitions strings exactly as its comparator
|
|
359
|
+
* does). `db.registerCollation(name, cmp)` with no `{ normalizer }` gives a collation
|
|
360
|
+
* that can order rows but not key them; an unregistered name gives nothing at all.
|
|
361
|
+
* Both raise here rather than at the first insert.
|
|
362
|
+
*
|
|
363
|
+
* Checked over exactly the collations the key encoding actually uses:
|
|
364
|
+
* - every defined `pkKeyCollations` entry (text-capable PK columns);
|
|
365
|
+
* - the table key collation K, but only when it is reachable — a secondary index
|
|
366
|
+
* encodes a TEXT-CAPABLE index column's bytes under K (`buildIndexKey`).
|
|
367
|
+
*
|
|
368
|
+
* So neither a table with an integer PK and no secondary index, nor one whose every
|
|
369
|
+
* index column is type-natively keyed, is made unopenable by a K it never encodes with.
|
|
370
|
+
*
|
|
371
|
+
* Blast radius: this also fires on catalog rehydration. Reopening a persisted database
|
|
372
|
+
* from a connection that has not re-registered its custom collation now throws at
|
|
373
|
+
* CREATE-TABLE-from-catalog rather than silently reading rows under a key layout it
|
|
374
|
+
* cannot reproduce. See `docs/plugins.md`.
|
|
375
|
+
*
|
|
376
|
+
* Takes `pkKeyCollations` rather than reading the field so `updateSchema` can validate
|
|
377
|
+
* the incoming schema BEFORE it overwrites its own state with it.
|
|
378
|
+
*/
|
|
379
|
+
validateKeyCollations(schema, pkKeyCollations) {
|
|
380
|
+
const names = new Set();
|
|
381
|
+
for (const collation of pkKeyCollations) {
|
|
382
|
+
if (collation !== undefined)
|
|
383
|
+
names.add(collation);
|
|
384
|
+
}
|
|
385
|
+
const indexKeysText = (schema.indexes ?? []).some(index => index.columns.some(col => columnCanHoldText(schema.columns[col.index])));
|
|
386
|
+
if (indexKeysText) {
|
|
387
|
+
names.add((this.encodeOptions.collation ?? 'NOCASE').toUpperCase());
|
|
388
|
+
}
|
|
389
|
+
const dbInternal = this.db;
|
|
390
|
+
for (const name of names) {
|
|
391
|
+
if (dbInternal._getCollationNormalizer(name))
|
|
392
|
+
continue;
|
|
393
|
+
// Unregistered names raise `no such collation sequence: X` from the resolver;
|
|
394
|
+
// reaching past it means the collation exists but is comparator-only.
|
|
395
|
+
this.collationResolver(name);
|
|
396
|
+
throw new QuereusError(`collation ${name} cannot key a persisted structure: no key normalizer registered `
|
|
397
|
+
+ `— pass { normalizer } to registerCollation`, StatusCode.ERROR);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
142
400
|
/** Get the table configuration. */
|
|
143
401
|
getConfig() {
|
|
144
402
|
return this.config;
|
|
145
403
|
}
|
|
146
|
-
/** Get the table schema. */
|
|
404
|
+
/** Get the table schema (engine-facing, WITHOUT the hidden `_uc_*` indexes). */
|
|
147
405
|
getSchema() {
|
|
148
406
|
return this.tableSchema;
|
|
149
407
|
}
|
|
150
|
-
/**
|
|
408
|
+
/**
|
|
409
|
+
* Get the enforcement schema — {@link getSchema} plus the hidden `_uc_*`
|
|
410
|
+
* index materialized per non-derived UNIQUE. Used by the module to resolve the
|
|
411
|
+
* physical index store to build/tear down during ALTER; never registered with
|
|
412
|
+
* the engine.
|
|
413
|
+
*/
|
|
414
|
+
getMaterializedSchema() {
|
|
415
|
+
return this.materializedSchema;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Update the table schema after an ALTER TABLE / CREATE INDEX operation.
|
|
419
|
+
*
|
|
420
|
+
* Validates the incoming schema's key collations before adopting any of it, so a
|
|
421
|
+
* rejection leaves this table on its previous, consistent schema.
|
|
422
|
+
*/
|
|
151
423
|
updateSchema(newSchema) {
|
|
424
|
+
// `newSchema` is the engine-facing (non-materialized) schema; recompute the
|
|
425
|
+
// enforcement copy alongside it. Validate the MATERIALIZED copy so a `_uc_*` over a
|
|
426
|
+
// text column that needs the table key collation K is caught before adoption.
|
|
427
|
+
const materialized = withImplicitUniqueIndexes(newSchema);
|
|
428
|
+
const pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
|
|
429
|
+
this.validateKeyCollations(materialized, pkKeyCollations);
|
|
152
430
|
this.tableSchema = newSchema;
|
|
431
|
+
this.materializedSchema = materialized;
|
|
153
432
|
this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
154
|
-
this.pkKeyCollations =
|
|
433
|
+
this.pkKeyCollations = pkKeyCollations;
|
|
155
434
|
}
|
|
156
435
|
/**
|
|
157
436
|
* Mark the table's DDL as already persisted to the catalog, so the lazy
|
|
@@ -174,34 +453,59 @@ export class StoreTable extends VirtualTable {
|
|
|
174
453
|
catch { /* close is best-effort */ }
|
|
175
454
|
}
|
|
176
455
|
/**
|
|
177
|
-
* Returns true if the table has at least one
|
|
456
|
+
* Returns true if the table has at least one LIVE row — the open transaction's
|
|
457
|
+
* pending overlay over the committed store. Stops after the first hit.
|
|
458
|
+
*
|
|
459
|
+
* Effective (not committed-only) because its one caller, `ADD COLUMN`'s NOT
|
|
460
|
+
* NULL-without-DEFAULT rejection, must see rows the same transaction inserted:
|
|
461
|
+
* they are migrated too (the arm DDL-commits before `migrateRows`), and a
|
|
462
|
+
* committed-only probe would wave them through only to give them a NULL in a
|
|
463
|
+
* NOT NULL column. Conversely, a pending delete of the last row makes the table
|
|
464
|
+
* legitimately empty. Reading effectively keeps the check throw-only and
|
|
465
|
+
* BEFORE the DDL-commit, so a rejection leaves the transaction intact.
|
|
178
466
|
*/
|
|
179
467
|
async hasAnyRows() {
|
|
180
|
-
const
|
|
181
|
-
const bounds = buildFullScanBounds();
|
|
182
|
-
for await (const _entry of store.iterate(bounds)) {
|
|
468
|
+
for await (const _entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
|
|
183
469
|
return true;
|
|
184
470
|
}
|
|
185
471
|
return false;
|
|
186
472
|
}
|
|
187
473
|
/**
|
|
188
|
-
*
|
|
474
|
+
* Yield the LIVE value of `colIndex` for every row — the open transaction's
|
|
475
|
+
* pending overlay over the committed store. For throw-only ALTER COLUMN probes
|
|
476
|
+
* that must run BEFORE the arm's DDL-commit (see `StoreModule.ddlCommitPendingOps`):
|
|
477
|
+
* they have to see rows this transaction wrote, because those rows are rewritten
|
|
478
|
+
* too, and a rejection here must leave the transaction intact.
|
|
479
|
+
*/
|
|
480
|
+
async *iterateEffectiveValuesAtIndex(colIndex) {
|
|
481
|
+
for await (const entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
|
|
482
|
+
yield deserializeRow(entry.value)[colIndex];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Count the LIVE rows whose column at `colIndex` holds NULL (see
|
|
487
|
+
* {@link iterateEffectiveValuesAtIndex} for why this reads effectively).
|
|
189
488
|
* Used by ALTER COLUMN SET NOT NULL to decide whether the tightening is safe.
|
|
190
489
|
*/
|
|
191
490
|
async rowsWithNullAtIndex(colIndex) {
|
|
192
|
-
const store = await this.ensureStore();
|
|
193
|
-
const bounds = buildFullScanBounds();
|
|
194
491
|
let count = 0;
|
|
195
|
-
for await (const
|
|
196
|
-
|
|
197
|
-
if (row[colIndex] === null)
|
|
492
|
+
for await (const value of this.iterateEffectiveValuesAtIndex(colIndex)) {
|
|
493
|
+
if (value === null)
|
|
198
494
|
count++;
|
|
199
495
|
}
|
|
200
496
|
return count;
|
|
201
497
|
}
|
|
202
498
|
/**
|
|
203
499
|
* Apply a per-row mapping function to every stored row, in place (re-writing
|
|
204
|
-
* the same key). The mapper may throw QuereusError — propagated to the caller
|
|
500
|
+
* the same key). The mapper may throw QuereusError — propagated to the caller;
|
|
501
|
+
* the batch is written only after every row maps, so a throw leaves the store
|
|
502
|
+
* untouched.
|
|
503
|
+
*
|
|
504
|
+
* NOTE: reads and writes the COMMITTED store, outside the coordinator. Sound
|
|
505
|
+
* only because every caller calls `StoreModule.ddlCommitPendingOps` first, so
|
|
506
|
+
* "committed" is "everything live". A caller that skips that flush would leave
|
|
507
|
+
* its transaction's pending rows unmapped, and they would replay unconverted
|
|
508
|
+
* over the rewritten store at commit.
|
|
205
509
|
*/
|
|
206
510
|
async mapRowsAtIndex(colIndex, mapper) {
|
|
207
511
|
const store = await this.ensureStore();
|
|
@@ -222,10 +526,26 @@ export class StoreTable extends VirtualTable {
|
|
|
222
526
|
/**
|
|
223
527
|
* Re-key every stored row under a new primary-key definition.
|
|
224
528
|
*
|
|
225
|
-
* Two-pass: the first pass
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
529
|
+
* Two-pass, signatures-only pass 1: the first pass computes each row's new data
|
|
530
|
+
* key and retains only a `Set` of key SIGNATURES (hex of the key bytes) to detect
|
|
531
|
+
* collisions — two distinct old keys collapsing to one new key under a coarser
|
|
532
|
+
* collation or a narrower PK. On collision we throw `CONSTRAINT` without touching
|
|
533
|
+
* the store. The second pass RE-SCANS the same committed store, recomputes each
|
|
534
|
+
* new key, and batches deletes of displaced old keys + puts of new (key, row)
|
|
535
|
+
* pairs into ONE atomic batch. Rows whose new key matches the old key are no-ops.
|
|
536
|
+
*
|
|
537
|
+
* Holding signatures instead of whole rows halves peak memory: the prior design
|
|
538
|
+
* retained the entire table in a map AND again in the batch. The re-scan trades
|
|
539
|
+
* O(rows) CPU (a second iterate + newKey recompute) for not buffering the table
|
|
540
|
+
* twice. Pass 1 and pass 2 iterate the SAME bounds over the SAME committed store
|
|
541
|
+
* and see identical rows: nothing writes between them — we are single-threaded
|
|
542
|
+
* within the ALTER, outside the coordinator, and every caller ran
|
|
543
|
+
* `StoreModule.ddlCommitPendingOps` first so "committed" is "everything live".
|
|
544
|
+
*
|
|
545
|
+
* The final single `batch.write()` is the ONLY thing making the re-key
|
|
546
|
+
* all-or-nothing — do not chunk-flush it. Its residual peak (the batch still holds
|
|
547
|
+
* every changed row) is irreducible without breaking atomicity; tracked separately
|
|
548
|
+
* in `debt-store-atomic-batch-bounded-memory`.
|
|
229
549
|
*
|
|
230
550
|
* Only the data store is rewritten — secondary indexes are rebuilt by the
|
|
231
551
|
* caller (the keys embed the PK suffix, so they must be rebuilt whenever
|
|
@@ -244,23 +564,35 @@ export class StoreTable extends VirtualTable {
|
|
|
244
564
|
async rekeyRows(newPkDef, newColumns = this.tableSchema.columns) {
|
|
245
565
|
const store = await this.ensureStore();
|
|
246
566
|
const bounds = buildFullScanBounds();
|
|
247
|
-
const pending = new Map();
|
|
248
567
|
const newPkDirections = newPkDef.map(pk => !!pk.desc);
|
|
249
568
|
const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
|
|
569
|
+
// Both passes key rows through this one helper, so a collision judged in pass 1
|
|
570
|
+
// is byte-identical to the key pass 2 writes.
|
|
571
|
+
const computeNewKey = (row) => buildDataKey(newPkDef.map(pk => row[pk.index]), this.encodeOptions, newPkDirections, newPkCollations);
|
|
572
|
+
// Pass 1 — collision detection only. Hold one hex signature per new key, never
|
|
573
|
+
// the row or old key. On a repeat, reject before any write; the store is
|
|
574
|
+
// untouched on rejection.
|
|
575
|
+
const seen = new Set();
|
|
250
576
|
for await (const entry of store.iterate(bounds)) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
const newKey = buildDataKey(newPkValues, this.encodeOptions, newPkDirections, newPkCollations);
|
|
254
|
-
const hex = bytesToHex(newKey);
|
|
255
|
-
if (pending.has(hex)) {
|
|
577
|
+
const hex = bytesToHex(computeNewKey(deserializeRow(entry.value)));
|
|
578
|
+
if (seen.has(hex)) {
|
|
256
579
|
throw new QuereusError(`UNIQUE constraint failed: duplicate primary key on rekey of '${this.schemaName}.${this.tableName}'`, StatusCode.CONSTRAINT);
|
|
257
580
|
}
|
|
258
|
-
|
|
581
|
+
seen.add(hex);
|
|
259
582
|
}
|
|
583
|
+
// Pass 2 — re-scan and build the single atomic batch. Recompute each new key and
|
|
584
|
+
// only rewrite rows whose key actually moves (`newKey !== oldKey`).
|
|
260
585
|
const batch = store.batch();
|
|
261
|
-
for (const
|
|
262
|
-
|
|
263
|
-
|
|
586
|
+
for await (const entry of store.iterate(bounds)) {
|
|
587
|
+
const row = deserializeRow(entry.value);
|
|
588
|
+
const newKey = computeNewKey(row);
|
|
589
|
+
if (!bytesEqual(entry.key, newKey)) {
|
|
590
|
+
batch.delete(entry.key);
|
|
591
|
+
// NOTE: the row VALUE is unchanged, so `serializeRow(row)` reproduces
|
|
592
|
+
// `entry.value` byte-for-byte. We re-serialize rather than reuse
|
|
593
|
+
// `entry.value` to avoid retaining an iterator-owned buffer in the batch.
|
|
594
|
+
// If re-key CPU ever shows up hot, reuse `entry.value` where the backend
|
|
595
|
+
// guarantees the buffer is not reused across iteration.
|
|
264
596
|
batch.put(newKey, serializeRow(row));
|
|
265
597
|
}
|
|
266
598
|
}
|
|
@@ -320,7 +652,7 @@ export class StoreTable extends VirtualTable {
|
|
|
320
652
|
async initializeStore() {
|
|
321
653
|
const tableKey = `${this.schemaName}.${this.tableName}`.toLowerCase();
|
|
322
654
|
try {
|
|
323
|
-
this.store = await this.storeModule.getStore(
|
|
655
|
+
this.store = await this.storeModule.getStore(this.schemaName, this.tableName, this.config);
|
|
324
656
|
if (!this.store) {
|
|
325
657
|
throw new Error(`getStore returned null/undefined for ${tableKey}`);
|
|
326
658
|
}
|
|
@@ -471,7 +803,7 @@ export class StoreTable extends VirtualTable {
|
|
|
471
803
|
const pkAccess = this.analyzePKAccess(filterInfo);
|
|
472
804
|
if (pkAccess.type === 'point') {
|
|
473
805
|
const row = await this.readLiveRowByPk(pkAccess.values);
|
|
474
|
-
if (row && this.matchesFilters(row, filterInfo)) {
|
|
806
|
+
if (row && this.matchesFilters(row, filterInfo, this.resolveFilterCollations(filterInfo))) {
|
|
475
807
|
yield row;
|
|
476
808
|
}
|
|
477
809
|
return;
|
|
@@ -480,11 +812,22 @@ export class StoreTable extends VirtualTable {
|
|
|
480
812
|
yield* this.scanPKRange(store, pkAccess, filterInfo);
|
|
481
813
|
return;
|
|
482
814
|
}
|
|
815
|
+
// Secondary-index scan arm — reached only when the predicate did NOT resolve
|
|
816
|
+
// to a PK point/range (PK access is cheaper and already handled above). When
|
|
817
|
+
// the planner chose a secondary index (idxStr carries `idx=<name>(…)`), derive
|
|
818
|
+
// its byte window and iterate it instead of full-scanning.
|
|
819
|
+
const indexAccess = this.analyzeIndexAccess(filterInfo);
|
|
820
|
+
if (indexAccess) {
|
|
821
|
+
const indexStore = await this.ensureIndexStore(indexAccess.index.name);
|
|
822
|
+
yield* this.scanIndex(indexStore, indexAccess, filterInfo);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
483
825
|
// Full table scan
|
|
826
|
+
const collations = this.resolveFilterCollations(filterInfo);
|
|
484
827
|
const bounds = buildFullScanBounds();
|
|
485
828
|
for await (const entry of this.iterateEffective(store, bounds)) {
|
|
486
829
|
const row = deserializeRow(entry.value);
|
|
487
|
-
if (this.matchesFilters(row, filterInfo)) {
|
|
830
|
+
if (this.matchesFilters(row, filterInfo, collations)) {
|
|
488
831
|
yield row;
|
|
489
832
|
}
|
|
490
833
|
}
|
|
@@ -539,6 +882,15 @@ export class StoreTable extends VirtualTable {
|
|
|
539
882
|
yield puts[putIdx++];
|
|
540
883
|
}
|
|
541
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* True when a byte window over the LEADING PK column reproduces the comparator's order
|
|
887
|
+
* — the precondition {@link buildPKRangeBounds} needs, and the exact condition
|
|
888
|
+
* `StoreModule.computeBestAccessPlan` uses before claiming the range filters handled.
|
|
889
|
+
* When false, the range arm degrades to a full scan + `matchesFilters` residual.
|
|
890
|
+
*/
|
|
891
|
+
leadingPkRangeIsOrderSafe() {
|
|
892
|
+
return pkOrderPreservingPrefixLength(this.db, this.tableSchema, this.pkKeyCollations, this.encodeOptions.collation ?? 'NOCASE') >= 1;
|
|
893
|
+
}
|
|
542
894
|
/** Analyze filter info to determine PK access pattern. */
|
|
543
895
|
analyzePKAccess(filterInfo) {
|
|
544
896
|
const schema = this.tableSchema;
|
|
@@ -567,7 +919,10 @@ export class StoreTable extends VirtualTable {
|
|
|
567
919
|
const firstPkCol = pkColumns[0];
|
|
568
920
|
const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
|
|
569
921
|
const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && rangeOps.includes(c.constraint.op)) || [];
|
|
570
|
-
|
|
922
|
+
// A range window is only sound when the leading PK column's key bytes order the way
|
|
923
|
+
// its comparator does; otherwise fall through to the full scan, where matchesFilters
|
|
924
|
+
// applies the range under the real comparator.
|
|
925
|
+
if (rangeConstraints.length > 0 && this.leadingPkRangeIsOrderSafe()) {
|
|
571
926
|
return {
|
|
572
927
|
type: 'range',
|
|
573
928
|
columnIndex: firstPkCol,
|
|
@@ -607,11 +962,16 @@ export class StoreTable extends VirtualTable {
|
|
|
607
962
|
* row filter. A NULL/missing bound value is likewise skipped (the planner never
|
|
608
963
|
* pushes `= NULL`, and a range op against NULL rejects every row in matchesFilters).
|
|
609
964
|
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
965
|
+
* NOTE: a range window over a text PK column is sound only when the column's key
|
|
966
|
+
* normalizer is ORDER-preserving with respect to its comparator — i.e. the comparator
|
|
967
|
+
* orders two strings the way memcmp orders their normalized bytes. That is NOT implied
|
|
968
|
+
* by `db.registerCollation`'s normalizer contract, which promises only an equality
|
|
969
|
+
* partition, and a built-in NAME may be re-registered with a comparator + normalizer
|
|
970
|
+
* pair that preserves equality while inverting order. This method is therefore only
|
|
971
|
+
* ever reached under {@link leadingPkRangeIsOrderSafe} — enforced by
|
|
972
|
+
* {@link analyzePKAccess} here and mirrored by `StoreModule.computeBestAccessPlan`
|
|
973
|
+
* before it claims the range filters handled. A collation without the
|
|
974
|
+
* `orderPreserving` assertion costs the seek, never a row.
|
|
615
975
|
*/
|
|
616
976
|
buildPKRangeBounds(access) {
|
|
617
977
|
const full = buildFullScanBounds();
|
|
@@ -619,12 +979,6 @@ export class StoreTable extends VirtualTable {
|
|
|
619
979
|
if (!constraints || constraints.length === 0)
|
|
620
980
|
return full;
|
|
621
981
|
const dir = this.pkDirections[0];
|
|
622
|
-
const coll = this.pkKeyCollations[0];
|
|
623
|
-
// A comparator-only collation (declared on the column, but with no registered
|
|
624
|
-
// byte encoder) is not seekable: its key bytes fall back to NOCASE and do not
|
|
625
|
-
// track the column's logical order. Stay full-scan; matchesFilters is authoritative.
|
|
626
|
-
if (coll !== undefined && getCollationEncoder(coll) === undefined)
|
|
627
|
-
return full;
|
|
628
982
|
let gte = full.gte;
|
|
629
983
|
let lt;
|
|
630
984
|
for (const c of constraints) {
|
|
@@ -657,16 +1011,209 @@ export class StoreTable extends VirtualTable {
|
|
|
657
1011
|
* merge to the same `bounds`, so read-your-own-writes holds on the narrowed window.
|
|
658
1012
|
*/
|
|
659
1013
|
async *scanPKRange(store, access, filterInfo) {
|
|
1014
|
+
const collations = this.resolveFilterCollations(filterInfo);
|
|
660
1015
|
const bounds = this.buildPKRangeBounds(access);
|
|
661
1016
|
for await (const entry of this.iterateEffective(store, bounds)) {
|
|
662
1017
|
const row = deserializeRow(entry.value);
|
|
663
|
-
if (this.matchesFilters(row, filterInfo)) {
|
|
1018
|
+
if (this.matchesFilters(row, filterInfo, collations)) {
|
|
1019
|
+
yield row;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Resolve the secondary index chosen by the planner from `filterInfo.idxStr`.
|
|
1025
|
+
*
|
|
1026
|
+
* The planner emits `idx=<name>(<n>);plan=…` when its access plan set both an
|
|
1027
|
+
* `indexName` and `seekColumnIndexes` (see `getBestAccessPlan` and
|
|
1028
|
+
* rule-select-access-path.ts). Decoding goes through the engine's shared
|
|
1029
|
+
* `decodeIdxStr`, so the store, the in-memory vtab, and the isolation overlay all
|
|
1030
|
+
* read one format. Returns null for the PK/scan sentinels (`_primary_`, `fullscan`,
|
|
1031
|
+
* `empty`), an idxStr that names no index, or a name absent from `schema.indexes` —
|
|
1032
|
+
* every one of which routes back to a PK/full-scan arm.
|
|
1033
|
+
*/
|
|
1034
|
+
resolveIndexFromIdxStr(idxStr) {
|
|
1035
|
+
const spec = decodeIdxStr(idxStr);
|
|
1036
|
+
if (!spec)
|
|
1037
|
+
return null;
|
|
1038
|
+
const name = spec.indexName;
|
|
1039
|
+
if (!name || name === '_primary_')
|
|
1040
|
+
return null;
|
|
1041
|
+
const indexes = this.tableSchema?.indexes ?? [];
|
|
1042
|
+
return indexes.find(i => i.name.toLowerCase() === name.toLowerCase()) ?? null;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Analyze filter info to determine a secondary-index access pattern, mirroring
|
|
1046
|
+
* {@link analyzePKAccess} but over the index chosen in `idxStr`.
|
|
1047
|
+
*
|
|
1048
|
+
* A contiguous leading-prefix EQ on the index columns yields a `point` window
|
|
1049
|
+
* (the prefix covers every entry sharing those leading values — an index seek
|
|
1050
|
+
* is a PREFIX scan, not a single row, since the index need not be unique and
|
|
1051
|
+
* the PK suffix varies); otherwise a range (LT/LE/GT/GE) on the LEADING index
|
|
1052
|
+
* column yields a `range` window. Returns null when neither applies or when the
|
|
1053
|
+
* index is unresolved. {@link matchesFilters} stays the authoritative row filter,
|
|
1054
|
+
* so the window need only be a SUPERSET.
|
|
1055
|
+
*
|
|
1056
|
+
* Index-column bytes are encoded under the table key collation K (NOT the index's
|
|
1057
|
+
* per-column declared collation — see `buildIndexKey`), while `matchesFilters` compares
|
|
1058
|
+
* under the index column's effective collation C. The EQ/prefix window is a superset
|
|
1059
|
+
* whenever K is coarser-or-equal to C, which `StoreModule.tryIndexAccessPlan` checked
|
|
1060
|
+
* before it named this index. The RANGE window additionally needs byte order to BE
|
|
1061
|
+
* comparator order, so it is gated on {@link keyOrderMatchesCollation} (which demands
|
|
1062
|
+
* `C === K` plus K's `orderPreserving` assertion); when that fails we return null and
|
|
1063
|
+
* the caller full-scans.
|
|
1064
|
+
*/
|
|
1065
|
+
analyzeIndexAccess(filterInfo) {
|
|
1066
|
+
const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
|
|
1067
|
+
if (!index)
|
|
1068
|
+
return null;
|
|
1069
|
+
const indexCols = index.columns.map(c => c.index);
|
|
1070
|
+
const indexDirections = index.columns.map(c => !!c.desc);
|
|
1071
|
+
// Contiguous leading-prefix EQ → point/prefix window.
|
|
1072
|
+
const eqValues = [];
|
|
1073
|
+
for (let i = 0; i < indexCols.length; i++) {
|
|
1074
|
+
const eq = filterInfo.constraints?.find(c => c.constraint.iColumn === indexCols[i]
|
|
1075
|
+
&& c.constraint.op === IndexConstraintOp.EQ
|
|
1076
|
+
&& c.argvIndex > 0);
|
|
1077
|
+
if (!eq)
|
|
1078
|
+
break;
|
|
1079
|
+
eqValues.push(filterInfo.args[eq.argvIndex - 1]);
|
|
1080
|
+
}
|
|
1081
|
+
if (eqValues.length > 0) {
|
|
1082
|
+
const bounds = buildIndexPrefixBounds(eqValues, this.encodeOptions, indexDirections.slice(0, eqValues.length));
|
|
1083
|
+
return { index, type: 'point', bounds };
|
|
1084
|
+
}
|
|
1085
|
+
// Else a range on the LEADING index column.
|
|
1086
|
+
const leadingCol = indexCols[0];
|
|
1087
|
+
const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
|
|
1088
|
+
const rangeConstraints = (filterInfo.constraints ?? []).filter(c => c.constraint.iColumn === leadingCol && rangeOps.includes(c.constraint.op));
|
|
1089
|
+
if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index, leadingCol)) {
|
|
1090
|
+
const bounds = this.buildIndexRangeBounds(rangeConstraints.map(c => ({
|
|
1091
|
+
op: c.constraint.op,
|
|
1092
|
+
value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
|
|
1093
|
+
})), indexDirections[0]);
|
|
1094
|
+
return { index, type: 'range', bounds };
|
|
1095
|
+
}
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* True when a byte window over `leadingCol` of `index` reproduces the comparator's
|
|
1100
|
+
* order. Mirrors the range arm of `StoreModule.tryIndexAccessPlan`: the window's bytes
|
|
1101
|
+
* come from the table key collation K, the residual compares under the index column's
|
|
1102
|
+
* effective collation C, so both must be the same order-preserving collation.
|
|
1103
|
+
*/
|
|
1104
|
+
indexRangeIsOrderSafe(index, leadingCol) {
|
|
1105
|
+
const schema = this.tableSchema;
|
|
1106
|
+
const K = this.encodeOptions.collation ?? 'NOCASE';
|
|
1107
|
+
const col = schema.columns[leadingCol];
|
|
1108
|
+
const indexCol = index.columns.find(c => c.index === leadingCol);
|
|
1109
|
+
const C = indexCol?.collation ?? col?.collation ?? 'BINARY';
|
|
1110
|
+
return keyOrderMatchesCollation(this.db, col, K, C);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Convert leading-index-column LT/LE/GT/GE constraints into one encoded-byte
|
|
1114
|
+
* `gte`/`lt` window — the secondary-index analogue of {@link buildPKRangeBounds}.
|
|
1115
|
+
*
|
|
1116
|
+
* Each bound value is encoded under {@link encodeOptions} (the table key collation K
|
|
1117
|
+
* and its normalizer resolver) and the leading index column's DESC `dir` — exactly as
|
|
1118
|
+
* {@link buildIndexKey} encodes
|
|
1119
|
+
* that column — via {@link buildIndexPrefixBounds}, giving the byte region
|
|
1120
|
+
* `[lo, hi)` whose leading column equals that value. The op maps that region's
|
|
1121
|
+
* endpoints onto `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path:
|
|
1122
|
+
*
|
|
1123
|
+
* | op | ASC | DESC |
|
|
1124
|
+
* |----|----------|----------|
|
|
1125
|
+
* | GE | gte = lo | lt = hi |
|
|
1126
|
+
* | GT | gte = hi | lt = lo |
|
|
1127
|
+
* | LE | lt = hi | gte = lo |
|
|
1128
|
+
* | LT | lt = lo | gte = hi |
|
|
1129
|
+
*
|
|
1130
|
+
* Across constraints keep the MAX lower and MIN upper. An `undefined` upper (an
|
|
1131
|
+
* `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
|
|
1132
|
+
* SUPERSET. A NULL/missing bound value is skipped (the planner never pushes
|
|
1133
|
+
* `= NULL`, and a range op against NULL rejects every row in matchesFilters).
|
|
1134
|
+
*/
|
|
1135
|
+
buildIndexRangeBounds(constraints, dir) {
|
|
1136
|
+
const full = buildFullScanBounds();
|
|
1137
|
+
let gte = full.gte;
|
|
1138
|
+
let lt;
|
|
1139
|
+
for (const c of constraints) {
|
|
1140
|
+
if (c.value === undefined || c.value === null)
|
|
1141
|
+
continue;
|
|
1142
|
+
const { gte: lo, lt: hi } = buildIndexPrefixBounds([c.value], this.encodeOptions, [dir]);
|
|
1143
|
+
const lower = !dir
|
|
1144
|
+
? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
|
|
1145
|
+
: (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
|
|
1146
|
+
const upper = !dir
|
|
1147
|
+
? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
|
|
1148
|
+
: (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
|
|
1149
|
+
if (lower && compareBytes(lower, gte) > 0)
|
|
1150
|
+
gte = lower;
|
|
1151
|
+
if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
|
|
1152
|
+
lt = upper;
|
|
1153
|
+
}
|
|
1154
|
+
return lt === undefined ? { gte } : { gte, lt };
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Scan a secondary index over `access.bounds`, resolving each index entry to its
|
|
1158
|
+
* base row and re-filtering.
|
|
1159
|
+
*
|
|
1160
|
+
* {@link iterateEffective} yields the committed index entries merged with this
|
|
1161
|
+
* transaction's pending index puts/deletes (read-your-own-writes over the
|
|
1162
|
+
* index), in index-key byte order — the order the isolation overlay merge relies
|
|
1163
|
+
* on (`isolated-table.ts` § buildSortKey). We resolve each entry to its row via
|
|
1164
|
+
* its stored data-key value WITHOUT reordering, so index-key order is preserved.
|
|
1165
|
+
*
|
|
1166
|
+
* Defense in depth mirroring the memory layer's live-recheck: a resolved-null
|
|
1167
|
+
* row (the entry's row was deleted — a pending index delete would normally
|
|
1168
|
+
* suppress the entry, but a committed entry can lag) is skipped, and every
|
|
1169
|
+
* resolved row is re-checked by {@link matchesFilters} (the byte window is only
|
|
1170
|
+
* a superset, and a stale entry whose indexed column no longer matches is
|
|
1171
|
+
* dropped).
|
|
1172
|
+
*/
|
|
1173
|
+
async *scanIndex(indexStore, access, filterInfo) {
|
|
1174
|
+
// Re-check each resolved row under the INDEX's per-column collation (see
|
|
1175
|
+
// matchesFilters): the planner dropped the residual based on the index
|
|
1176
|
+
// column's collation, which an explicit index `COLLATE` can make differ from
|
|
1177
|
+
// the table column's declared collation.
|
|
1178
|
+
const indexCollations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
|
|
1179
|
+
for await (const entry of this.iterateEffective(indexStore, access.bounds)) {
|
|
1180
|
+
// NOTE: a legacy index store (written before index values carried the data
|
|
1181
|
+
// key) holds EMPTY values; a zero-length data key is not a row key, so skip
|
|
1182
|
+
// it rather than resolve it to the wrong row. Because the access plan marked
|
|
1183
|
+
// the filter handled and dropped the residual, an indexed query over such a
|
|
1184
|
+
// store returns NOTHING rather than the matching rows — a silent wrong
|
|
1185
|
+
// result, not an error. Backwards compatibility is waived project-wide
|
|
1186
|
+
// (AGENTS.md) and no test provider carries on-disk data, so nothing exercises
|
|
1187
|
+
// this today. If real persisted stores predating this format come into play,
|
|
1188
|
+
// their indexes must be dropped + recreated (or the table rebuilt); the
|
|
1189
|
+
// durable fix is to version-stamp the index store and rebuild on open, or to
|
|
1190
|
+
// fall back to a full scan the first time an empty value is seen.
|
|
1191
|
+
if (entry.value.length === 0)
|
|
1192
|
+
continue;
|
|
1193
|
+
// NOTE: one extra data-store `get` per matched index entry — the row lives
|
|
1194
|
+
// in the data store, not the index (the index value carries only the data
|
|
1195
|
+
// key, no covering payload). Fine now; if index-covered scans ever dominate
|
|
1196
|
+
// a profile, consider storing the serialized row as a covering index value,
|
|
1197
|
+
// at the cost of an index rewrite on EVERY column change (not just indexed
|
|
1198
|
+
// columns) — deliberately not done here.
|
|
1199
|
+
const row = await this.readEffectiveRowByKey(entry.value);
|
|
1200
|
+
if (row && this.matchesFilters(row, filterInfo, indexCollations)) {
|
|
664
1201
|
yield row;
|
|
665
1202
|
}
|
|
666
1203
|
}
|
|
667
1204
|
}
|
|
668
|
-
/**
|
|
669
|
-
|
|
1205
|
+
/**
|
|
1206
|
+
* Check if a row matches the filter constraints.
|
|
1207
|
+
*
|
|
1208
|
+
* `collations` maps a constrained column index to the comparison function that
|
|
1209
|
+
* column must be re-checked under; every caller builds it once per scan with
|
|
1210
|
+
* {@link resolveFilterCollations}, whose doc comment explains how the name is
|
|
1211
|
+
* chosen. A column absent from the map compares BINARY — the same result
|
|
1212
|
+
* {@link resolveFilterCollations} produces for an undeclared collation, and the
|
|
1213
|
+
* only reachable absence, since both walk the constraint list under identical
|
|
1214
|
+
* skip conditions.
|
|
1215
|
+
*/
|
|
1216
|
+
matchesFilters(row, filterInfo, collations) {
|
|
670
1217
|
if (!filterInfo.constraints || filterInfo.constraints.length === 0) {
|
|
671
1218
|
return true;
|
|
672
1219
|
}
|
|
@@ -677,13 +1224,7 @@ export class StoreTable extends VirtualTable {
|
|
|
677
1224
|
}
|
|
678
1225
|
const rowValue = row[constraint.iColumn];
|
|
679
1226
|
const filterValue = filterInfo.args[argvIndex - 1];
|
|
680
|
-
|
|
681
|
-
// same resolution the access path's collation-cover analysis uses
|
|
682
|
-
// (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides
|
|
683
|
-
// a pushed constraint is fully covered, and the same source this file's
|
|
684
|
-
// UNIQUE checks compare under. On a collation MATCH the planner drops the
|
|
685
|
-
// residual Filter, so this filter alone must reproduce the predicate.
|
|
686
|
-
const collation = this.tableSchema.columns[constraint.iColumn]?.collation;
|
|
1227
|
+
const collation = collations.get(constraint.iColumn) ?? BINARY_COLLATION;
|
|
687
1228
|
if (!this.compareValues(rowValue, constraint.op, filterValue, collation)) {
|
|
688
1229
|
return false;
|
|
689
1230
|
}
|
|
@@ -691,19 +1232,75 @@ export class StoreTable extends VirtualTable {
|
|
|
691
1232
|
return true;
|
|
692
1233
|
}
|
|
693
1234
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
* column
|
|
1235
|
+
* Resolve, ONCE per scan, the comparison collation function for every pushed
|
|
1236
|
+
* constraint column, keyed by column index.
|
|
1237
|
+
*
|
|
1238
|
+
* The name for a column is its DECLARED collation (absent ⇒ BINARY) — the same
|
|
1239
|
+
* resolution the access path's collation-cover analysis uses
|
|
1240
|
+
* (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides a
|
|
1241
|
+
* pushed constraint is fully covered, and the same source this file's UNIQUE
|
|
1242
|
+
* checks compare under. On a collation MATCH the planner drops the residual
|
|
1243
|
+
* Filter, so {@link matchesFilters} alone must reproduce the predicate.
|
|
1244
|
+
*
|
|
1245
|
+
* `indexCollationNames` (from {@link indexColumnCollations}) overrides the
|
|
1246
|
+
* declared name for the secondary-index scan arm: the planner's MATCH there is
|
|
1247
|
+
* against the INDEX column's collation, which an explicit `COLLATE` on the index
|
|
1248
|
+
* can make differ from the table column's.
|
|
1249
|
+
*
|
|
1250
|
+
* Names resolve against {@link collationResolver}, so an unregistered collation
|
|
1251
|
+
* raises `no such collation sequence` at scan setup rather than byte-ordering
|
|
1252
|
+
* every row.
|
|
1253
|
+
*
|
|
1254
|
+
* NOTE: rebuilt on every `query()` / `scanPKRange()` / `scanIndex()` call — one
|
|
1255
|
+
* registry lookup per distinct constrained column, dwarfed by the scan's I/O. If a
|
|
1256
|
+
* point-lookup-heavy profile ever shows it, memoize on the `FilterInfo`.
|
|
1257
|
+
*/
|
|
1258
|
+
resolveFilterCollations(filterInfo, indexCollationNames) {
|
|
1259
|
+
const resolved = new Map();
|
|
1260
|
+
if (!filterInfo.constraints)
|
|
1261
|
+
return resolved;
|
|
1262
|
+
for (const { constraint, argvIndex } of filterInfo.constraints) {
|
|
1263
|
+
if (constraint.iColumn < 0 || argvIndex <= 0)
|
|
1264
|
+
continue;
|
|
1265
|
+
if (resolved.has(constraint.iColumn))
|
|
1266
|
+
continue;
|
|
1267
|
+
const name = indexCollationNames?.has(constraint.iColumn)
|
|
1268
|
+
? indexCollationNames.get(constraint.iColumn)
|
|
1269
|
+
: this.tableSchema.columns[constraint.iColumn]?.collation;
|
|
1270
|
+
resolved.set(constraint.iColumn, name ? this.collationResolver(name) : BINARY_COLLATION);
|
|
1271
|
+
}
|
|
1272
|
+
return resolved;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Effective per-column comparison collation for a secondary index's columns:
|
|
1276
|
+
* the index column's own `COLLATE` when present, else the underlying table
|
|
1277
|
+
* column's declared collation. Mirrors the resolution `StoreModule`'s
|
|
1278
|
+
* index-maintenance UNIQUE dedup uses (`indexCol.collation ?? tableColumn`), so
|
|
1279
|
+
* a re-checked index-scan row compares under the same collation the planner used
|
|
1280
|
+
* to justify dropping (or keeping) the residual Filter.
|
|
1281
|
+
*/
|
|
1282
|
+
indexColumnCollations(index) {
|
|
1283
|
+
const cols = this.tableSchema.columns;
|
|
1284
|
+
const map = new Map();
|
|
1285
|
+
for (const c of index.columns) {
|
|
1286
|
+
map.set(c.index, c.collation ?? cols[c.index]?.collation);
|
|
1287
|
+
}
|
|
1288
|
+
return map;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Compare two values according to an operator, under `collationFunc` (already
|
|
1292
|
+
* resolved by {@link resolveFilterCollations} against this database's collation
|
|
1293
|
+
* registry). So the LT/LE/GT/GE range bounds honour a NOCASE/RTRIM/custom column
|
|
1294
|
+
* collation rather than a raw BINARY JS comparison — the capability
|
|
698
1295
|
* `StoreModule.getBestAccessPlan` advertises via `honorsCollatedRangeBounds`.
|
|
699
1296
|
* NULL on either side fails every operator except EQ-with-both-NULL (the
|
|
700
1297
|
* internal point-lookup convention; the planner never pushes `= NULL`).
|
|
701
1298
|
*/
|
|
702
|
-
compareValues(a, op, b,
|
|
1299
|
+
compareValues(a, op, b, collationFunc) {
|
|
703
1300
|
if (a === null || b === null) {
|
|
704
1301
|
return op === IndexConstraintOp.EQ ? a === b : false;
|
|
705
1302
|
}
|
|
706
|
-
const cmp =
|
|
1303
|
+
const cmp = compareSqlValuesFast(a, b, collationFunc);
|
|
707
1304
|
switch (op) {
|
|
708
1305
|
case IndexConstraintOp.EQ: return cmp === 0;
|
|
709
1306
|
case IndexConstraintOp.NE: return cmp !== 0;
|
|
@@ -1010,8 +1607,9 @@ export class StoreTable extends VirtualTable {
|
|
|
1010
1607
|
* sides leaks the old entry. Other paths pass the same pk for both.
|
|
1011
1608
|
*/
|
|
1012
1609
|
async updateSecondaryIndexes(inTransaction, oldRow, newRow, oldPk, newPk = oldPk) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1610
|
+
// Maintain the MATERIALIZED index set: the hidden `_uc_*` per-UNIQUE index must be
|
|
1611
|
+
// kept in step with DML so the enforcement seek finds every live row.
|
|
1612
|
+
const indexes = this.materializedSchema.indexes || [];
|
|
1015
1613
|
for (const index of indexes) {
|
|
1016
1614
|
const indexStore = await this.ensureIndexStore(index.name);
|
|
1017
1615
|
const indexCols = index.columns.map(c => c.index);
|
|
@@ -1038,13 +1636,20 @@ export class StoreTable extends VirtualTable {
|
|
|
1038
1636
|
if (newRow && (!predicate || predicate.evaluate(newRow) === true)) {
|
|
1039
1637
|
const newIndexValues = indexCols.map(i => newRow[i]);
|
|
1040
1638
|
const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
|
|
1041
|
-
// Index value
|
|
1042
|
-
|
|
1639
|
+
// Index value = the row's encoded DATA key. The index-entry key can
|
|
1640
|
+
// locate a row's byte window, but its PK suffix is not losslessly
|
|
1641
|
+
// recoverable to SqlValues (a NOCASE/RTRIM PK column encodes lossily)
|
|
1642
|
+
// and its length varies per entry in a range scan — so a scan resolves
|
|
1643
|
+
// each entry back to its base row via this stored data key
|
|
1644
|
+
// (`scanIndex` → `readEffectiveRowByKey(entry.value)`), never by
|
|
1645
|
+
// decoding the suffix. `newPk` is the PK the entry is keyed under, so
|
|
1646
|
+
// its data key byte-matches the data store's key for this row.
|
|
1647
|
+
const dataKeyValue = this.encodeDataKey(newPk);
|
|
1043
1648
|
if (inTransaction && this.coordinator) {
|
|
1044
|
-
this.coordinator.put(newIndexKey,
|
|
1649
|
+
this.coordinator.put(newIndexKey, dataKeyValue, indexStore);
|
|
1045
1650
|
}
|
|
1046
1651
|
else {
|
|
1047
|
-
await indexStore.put(newIndexKey,
|
|
1652
|
+
await indexStore.put(newIndexKey, dataKeyValue);
|
|
1048
1653
|
}
|
|
1049
1654
|
}
|
|
1050
1655
|
}
|
|
@@ -1059,7 +1664,7 @@ export class StoreTable extends VirtualTable {
|
|
|
1059
1664
|
return undefined;
|
|
1060
1665
|
let compiled = this.predicateCache.get(uc);
|
|
1061
1666
|
if (!compiled) {
|
|
1062
|
-
compiled = compilePredicate(uc.predicate, this.tableSchema.columns);
|
|
1667
|
+
compiled = compilePredicate(uc.predicate, this.tableSchema.columns, this.tableSchema.name);
|
|
1063
1668
|
this.predicateCache.set(uc, compiled);
|
|
1064
1669
|
}
|
|
1065
1670
|
return compiled;
|
|
@@ -1074,7 +1679,7 @@ export class StoreTable extends VirtualTable {
|
|
|
1074
1679
|
return undefined;
|
|
1075
1680
|
let compiled = this.indexPredicateCache.get(index);
|
|
1076
1681
|
if (!compiled) {
|
|
1077
|
-
compiled = compilePredicate(index.predicate, this.tableSchema.columns);
|
|
1682
|
+
compiled = compilePredicate(index.predicate, this.tableSchema.columns, this.tableSchema.name);
|
|
1078
1683
|
this.indexPredicateCache.set(index, compiled);
|
|
1079
1684
|
}
|
|
1080
1685
|
return compiled;
|
|
@@ -1146,15 +1751,7 @@ export class StoreTable extends VirtualTable {
|
|
|
1146
1751
|
const predicate = this.compileFor(uc);
|
|
1147
1752
|
if (predicate && predicate.evaluate(newRow) !== true)
|
|
1148
1753
|
continue;
|
|
1149
|
-
|
|
1150
|
-
// backing-host-capable module — memory by default, this store module under
|
|
1151
|
-
// `using store` — queried through the db with reads-own-writes) answers
|
|
1152
|
-
// the uniqueness question, mirroring the memory enforcement path. Falls
|
|
1153
|
-
// back to the per-scan source search when no row-time covering MV exists.
|
|
1154
|
-
const coveringMv = this.db._findRowTimeCoveringStructure(schema.schemaName, schema.name, uc);
|
|
1155
|
-
const conflict = coveringMv
|
|
1156
|
-
? await this.findUniqueConflictViaCoveringMv(coveringMv, uc, predicate, newRow, selfPks)
|
|
1157
|
-
: await this.findUniqueConflict(uc, predicate, newRow, selfPks);
|
|
1754
|
+
const conflict = await this.findUniqueConflictFor(uc, predicate, newRow, selfPks);
|
|
1158
1755
|
if (!conflict)
|
|
1159
1756
|
continue;
|
|
1160
1757
|
// Resolve action per-constraint: statement OR > per-UC default > ABORT.
|
|
@@ -1183,6 +1780,178 @@ export class StoreTable extends VirtualTable {
|
|
|
1183
1780
|
}
|
|
1184
1781
|
return null;
|
|
1185
1782
|
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Route one UNIQUE constraint's conflict search to the cheapest SOUND finder,
|
|
1785
|
+
* in descending preference:
|
|
1786
|
+
*
|
|
1787
|
+
* 1. A linked row-time covering MV — its backing table (hosted by any
|
|
1788
|
+
* backing-host-capable module — memory by default, this store module under
|
|
1789
|
+
* `using store` — queried through the db with reads-own-writes) answers the
|
|
1790
|
+
* uniqueness question, mirroring the memory enforcement path.
|
|
1791
|
+
* 2. A physical secondary index realizing the constraint — one prefix seek
|
|
1792
|
+
* instead of a full table scan (see {@link findIndexForUniqueConstraint}).
|
|
1793
|
+
* 3. The full data-store scan ({@link findUniqueConflict}) — always correct,
|
|
1794
|
+
* O(rows) per checked row.
|
|
1795
|
+
*
|
|
1796
|
+
* Every finder returns the SAME `{pk, row}` shape, so the caller's conflict
|
|
1797
|
+
* action (ABORT / IGNORE / REPLACE eviction) is finder-independent.
|
|
1798
|
+
*/
|
|
1799
|
+
async findUniqueConflictFor(uc, predicate, newRow, selfPks) {
|
|
1800
|
+
const schema = this.tableSchema;
|
|
1801
|
+
const coveringMv = this.db._findRowTimeCoveringStructure(schema.schemaName, schema.name, uc);
|
|
1802
|
+
if (coveringMv)
|
|
1803
|
+
return this.findUniqueConflictViaCoveringMv(coveringMv, uc, predicate, newRow, selfPks);
|
|
1804
|
+
const index = this.findIndexForUniqueConstraint(uc);
|
|
1805
|
+
if (index) {
|
|
1806
|
+
const viaIndex = await this.findUniqueConflictViaIndex(index, uc, predicate, newRow, selfPks);
|
|
1807
|
+
if (viaIndex !== INDEX_UNUSABLE)
|
|
1808
|
+
return viaIndex;
|
|
1809
|
+
}
|
|
1810
|
+
return this.findUniqueConflict(uc, predicate, newRow, selfPks);
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* The `schema.indexes` entry whose physical index store can serve `uc`'s
|
|
1814
|
+
* conflict search as a point seek, or undefined when none can.
|
|
1815
|
+
*
|
|
1816
|
+
* Every non-derived UNIQUE now carries a materialized implicit index
|
|
1817
|
+
* (`_uc_*`, see {@link withImplicitUniqueIndexes}), so — like the memory
|
|
1818
|
+
* backend — a plain column- or table-level `UNIQUE` is index-servable, not
|
|
1819
|
+
* only an explicit `CREATE INDEX`. A UC is index-servable when:
|
|
1820
|
+
*
|
|
1821
|
+
* - it is index-derived (`derivedFromIndex`, from `CREATE UNIQUE INDEX`) and
|
|
1822
|
+
* its named index is still present — the index's partial predicate then
|
|
1823
|
+
* equals the constraint's by construction (`appendIndexToTableSchema`); or
|
|
1824
|
+
* - some index's columns equal `uc.columns` positionally AND whose predicate
|
|
1825
|
+
* is the SAME object as `uc.predicate` (reference identity — the implicit
|
|
1826
|
+
* materializer sets it that way). For a FULL UNIQUE that is
|
|
1827
|
+
* `undefined === undefined`, which also admits any user full index over the
|
|
1828
|
+
* same columns. For a PARTIAL `unique(...) where p` it admits only the
|
|
1829
|
+
* co-scoped `_uc_*` (which holds exactly the in-scope rows); an arbitrary
|
|
1830
|
+
* user partial index with a DIFFERENT predicate object stays conservative
|
|
1831
|
+
* (declined → full scan), because it physically omits rows the constraint
|
|
1832
|
+
* still covers. The serving index need not be UNIQUE — a plain index over
|
|
1833
|
+
* the constrained columns still holds every in-scope row.
|
|
1834
|
+
*
|
|
1835
|
+
* Collation guard (see {@link indexSeekHonorsEnforcementCollation}) may still
|
|
1836
|
+
* reject the found index, which routes the check back to the full scan.
|
|
1837
|
+
*/
|
|
1838
|
+
findIndexForUniqueConstraint(uc) {
|
|
1839
|
+
// NOTE: re-resolved for every constrained ROW written, and the collation guard
|
|
1840
|
+
// below re-derives `uniqueEnforcementCollations` each time. Both are linear in
|
|
1841
|
+
// `schema.indexes` / `uc.columns` and dwarfed by the seek's I/O, so this is fine
|
|
1842
|
+
// now. If a table with many indexes ever shows up on an insert-heavy profile,
|
|
1843
|
+
// memoize the (uc → index | undefined) resolution in a WeakMap keyed on the
|
|
1844
|
+
// frozen UniqueConstraintSchema, as `predicateCache` above does — a CREATE/DROP
|
|
1845
|
+
// INDEX yields fresh constraint objects, so such a cache invalidates itself.
|
|
1846
|
+
// Reads the MATERIALIZED index set so the hidden `_uc_*` realizing a plain UNIQUE
|
|
1847
|
+
// is visible (the engine-facing `tableSchema` carries only explicit/derived indexes).
|
|
1848
|
+
const indexes = this.materializedSchema.indexes;
|
|
1849
|
+
if (!indexes || indexes.length === 0)
|
|
1850
|
+
return undefined;
|
|
1851
|
+
const index = uc.derivedFromIndex
|
|
1852
|
+
? indexes.find(ix => ix.name === uc.derivedFromIndex)
|
|
1853
|
+
: indexes.find(ix => ix.predicate === uc.predicate
|
|
1854
|
+
&& ix.columns.length === uc.columns.length
|
|
1855
|
+
&& ix.columns.every((c, i) => c.index === uc.columns[i]));
|
|
1856
|
+
if (!index)
|
|
1857
|
+
return undefined;
|
|
1858
|
+
return this.indexSeekHonorsEnforcementCollation(uc) ? index : undefined;
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* True when a point seek into the index realizing `uc` returns a SUPERSET of
|
|
1862
|
+
* the constraint's true conflict set — the only condition under which the
|
|
1863
|
+
* seek may replace the full scan.
|
|
1864
|
+
*
|
|
1865
|
+
* An index key's leading (index-column) bytes are encoded under the TABLE KEY
|
|
1866
|
+
* collation K (`buildIndexKey` passes `this.encodeOptions`), NOT the index's
|
|
1867
|
+
* declared per-column COLLATE and NOT the constraint's enforcement collation
|
|
1868
|
+
* C (`uniqueEnforcementCollations` — the index's per-column COLLATE for an
|
|
1869
|
+
* index-derived UC, else the declared column collation). A seek therefore
|
|
1870
|
+
* fetches exactly `{rows K-equal to newRow}` while the re-validation keeps
|
|
1871
|
+
* `{rows C-equal to newRow}`. Soundness needs
|
|
1872
|
+
* `{C-equal} ⊆ {K-equal}`, i.e. K must be COARSER-OR-EQUAL to C per column:
|
|
1873
|
+
*
|
|
1874
|
+
* - non-text column → its bytes are type-native, collation-independent: safe.
|
|
1875
|
+
* - C == K → the sets coincide: safe.
|
|
1876
|
+
* - K = NOCASE, C = BINARY → K strictly coarser: safe superset.
|
|
1877
|
+
* - otherwise (K = BINARY over C = NOCASE/RTRIM; K = NOCASE over C = RTRIM)
|
|
1878
|
+
* the seek UNDER-fetches and a real duplicate would be silently accepted:
|
|
1879
|
+
* reject, so the caller full-scans.
|
|
1880
|
+
*
|
|
1881
|
+
* Same direction and same admitted cases as the read-side guard in
|
|
1882
|
+
* `StoreModule.tryIndexAccessPlan`; conservative rather than exhaustive
|
|
1883
|
+
* (K = RTRIM over C = BINARY is provably safe but declined), which costs an
|
|
1884
|
+
* optimization, never correctness. The coarseness test is only sound for the
|
|
1885
|
+
* built-in names: a custom K equals a custom C only when the index column names
|
|
1886
|
+
* that same collation, and otherwise falls through to the full scan.
|
|
1887
|
+
*/
|
|
1888
|
+
indexSeekHonorsEnforcementCollation(uc) {
|
|
1889
|
+
const schema = this.tableSchema;
|
|
1890
|
+
const K = (this.encodeOptions.collation ?? 'NOCASE').toUpperCase();
|
|
1891
|
+
const collations = uniqueEnforcementCollations(schema, uc);
|
|
1892
|
+
return uc.columns.every((colIdx, i) => {
|
|
1893
|
+
if (!columnCanHoldText(schema.columns[colIdx]))
|
|
1894
|
+
return true;
|
|
1895
|
+
const C = (collations[i] ?? 'BINARY').toUpperCase();
|
|
1896
|
+
return C === K || (K === 'NOCASE' && C === 'BINARY');
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* The index analogue of {@link findUniqueConflict}: seek the index realizing
|
|
1901
|
+
* `uc` at the point formed by `newRow`'s constrained-column values, and
|
|
1902
|
+
* re-validate each resolved candidate exactly as the full scan does.
|
|
1903
|
+
*
|
|
1904
|
+
* The seek encodes ALL of `uc.columns` (positionally aligned with the index's
|
|
1905
|
+
* columns, guaranteed by `appendIndexToTableSchema` / the column-set match in
|
|
1906
|
+
* {@link findIndexForUniqueConstraint}) as a leading prefix of the index key,
|
|
1907
|
+
* under the same key collation and per-column DESC directions
|
|
1908
|
+
* {@link updateSecondaryIndexes} used to write it. The remaining suffix is the
|
|
1909
|
+
* row's PK, so the window spans every entry sharing those column values.
|
|
1910
|
+
* {@link iterateEffective} merges this transaction's pending index puts/deletes
|
|
1911
|
+
* over the committed entries, giving read-your-own-writes; each entry resolves
|
|
1912
|
+
* to its LIVE row through the data key stored as the entry's value.
|
|
1913
|
+
*
|
|
1914
|
+
* The seek only narrows the CANDIDATE set to a superset (guaranteed by
|
|
1915
|
+
* {@link indexSeekHonorsEnforcementCollation}); the authoritative comparison is
|
|
1916
|
+
* the identical self-PK exclusion, per-column enforcement-collation compare,
|
|
1917
|
+
* and partial-predicate scope check the full scan performs. A partial index
|
|
1918
|
+
* already excludes out-of-scope rows physically — the predicate re-check is
|
|
1919
|
+
* kept as defense in depth.
|
|
1920
|
+
*
|
|
1921
|
+
* Returns {@link INDEX_UNUSABLE} rather than a (possibly wrong) answer when an
|
|
1922
|
+
* entry carries a legacy empty value.
|
|
1923
|
+
*/
|
|
1924
|
+
async findUniqueConflictViaIndex(index, uc, predicate, newRow, selfPks) {
|
|
1925
|
+
const indexStore = await this.ensureIndexStore(index.name);
|
|
1926
|
+
// Resolved once, above the candidate loop: the resolver throws on an
|
|
1927
|
+
// unregistered name and cannot be inlined, so a per-candidate call would be
|
|
1928
|
+
// pure overhead.
|
|
1929
|
+
const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
|
|
1930
|
+
const bounds = buildIndexPrefixBounds(uc.columns.map(c => newRow[c]), this.encodeOptions, index.columns.map(c => !!c.desc));
|
|
1931
|
+
for await (const entry of this.iterateEffective(indexStore, bounds)) {
|
|
1932
|
+
// A legacy index store (written before index values carried the data key)
|
|
1933
|
+
// holds EMPTY values. `scanIndex` may skip such an entry — a read that
|
|
1934
|
+
// returns too few rows. Skipping here would instead ACCEPT a duplicate, so
|
|
1935
|
+
// abandon the index and let the caller full-scan. See the NOTE in
|
|
1936
|
+
// `scanIndex` for the durable fix.
|
|
1937
|
+
if (entry.value.length === 0)
|
|
1938
|
+
return INDEX_UNUSABLE;
|
|
1939
|
+
// Resolve to the LIVE row: a pending index delete normally suppresses the
|
|
1940
|
+
// entry, but a committed entry can lag a row deleted this transaction.
|
|
1941
|
+
const candidate = await this.readEffectiveRowByKey(entry.value);
|
|
1942
|
+
if (!candidate)
|
|
1943
|
+
continue;
|
|
1944
|
+
const pk = this.extractPK(candidate);
|
|
1945
|
+
if (selfPks.some(skip => this.keysEqual(pk, skip)))
|
|
1946
|
+
continue;
|
|
1947
|
+
if (uc.columns.some((c, i) => compareSqlValuesFast(newRow[c], candidate[c], collations[i]) !== 0))
|
|
1948
|
+
continue;
|
|
1949
|
+
if (predicate && predicate.evaluate(candidate) !== true)
|
|
1950
|
+
continue;
|
|
1951
|
+
return { pk, row: candidate };
|
|
1952
|
+
}
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1186
1955
|
/**
|
|
1187
1956
|
* Scan committed + pending data rows for a row matching `newRow` on
|
|
1188
1957
|
* `uc.columns` whose PK is not in `selfPks`. For partial UNIQUE, candidates
|
|
@@ -1200,7 +1969,8 @@ export class StoreTable extends VirtualTable {
|
|
|
1200
1969
|
const constrainedCols = uc.columns;
|
|
1201
1970
|
// One comparison collation per constrained column — the index's per-column
|
|
1202
1971
|
// COLLATE for an index-derived UNIQUE, else the declared column collation.
|
|
1203
|
-
|
|
1972
|
+
// Resolved once here, not per candidate row.
|
|
1973
|
+
const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
|
|
1204
1974
|
const matches = (candidate) => {
|
|
1205
1975
|
const pk = this.extractPK(candidate);
|
|
1206
1976
|
for (const skip of selfPks) {
|
|
@@ -1209,7 +1979,7 @@ export class StoreTable extends VirtualTable {
|
|
|
1209
1979
|
}
|
|
1210
1980
|
for (let i = 0; i < constrainedCols.length; i++) {
|
|
1211
1981
|
const idx = constrainedCols[i];
|
|
1212
|
-
if (
|
|
1982
|
+
if (compareSqlValuesFast(newRow[idx], candidate[idx], collations[i]) !== 0)
|
|
1213
1983
|
return null;
|
|
1214
1984
|
}
|
|
1215
1985
|
// Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
|
|
@@ -1252,7 +2022,8 @@ export class StoreTable extends VirtualTable {
|
|
|
1252
2022
|
*/
|
|
1253
2023
|
async findUniqueConflictViaCoveringMv(mv, uc, predicate, newRow, selfPks) {
|
|
1254
2024
|
const newSourcePk = this.extractPK(newRow);
|
|
1255
|
-
|
|
2025
|
+
// Resolved once, above the candidate loop.
|
|
2026
|
+
const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
|
|
1256
2027
|
const candidates = await this.db._lookupCoveringConflicts(mv, uc, newRow, newSourcePk);
|
|
1257
2028
|
for (const cand of candidates) {
|
|
1258
2029
|
const liveRow = await this.readLiveRowByPk(cand.pk);
|
|
@@ -1270,7 +2041,7 @@ export class StoreTable extends VirtualTable {
|
|
|
1270
2041
|
// index over a BINARY column) is declined upstream by the collation gate in
|
|
1271
2042
|
// findRowTimeCoveringStructure, so only BINARY-floor or equal-collation MVs
|
|
1272
2043
|
// reach here — the superset this re-validation can soundly filter.
|
|
1273
|
-
if (uc.columns.some((c, i) =>
|
|
2044
|
+
if (uc.columns.some((c, i) => compareSqlValuesFast(newRow[c], liveRow[c], collations[i]) !== 0))
|
|
1274
2045
|
continue;
|
|
1275
2046
|
if (predicate && predicate.evaluate(liveRow) !== true)
|
|
1276
2047
|
continue;
|
|
@@ -1344,7 +2115,7 @@ export class StoreTable extends VirtualTable {
|
|
|
1344
2115
|
* Declared secondary-UNIQUE enforcement for maintenance writes — the store
|
|
1345
2116
|
* mirror of the memory manager's `enforceSecondaryUniqueOnMaintenance` (see
|
|
1346
2117
|
* `vtab/backing-host.ts` § Constraint validation for the contract and
|
|
1347
|
-
* docs/
|
|
2118
|
+
* docs/mv-constraints.md § Derived-row constraint validation for the
|
|
1348
2119
|
* semantics). Called by `StoreBackingHost.applyMaintenance` AFTER the op
|
|
1349
2120
|
* batch lands in the coordinator's pending state: post-batch is load-bearing
|
|
1350
2121
|
* (a `replace-all` diff applies puts before deletes, so a per-op check would
|
|
@@ -1359,8 +2130,10 @@ export class StoreTable extends VirtualTable {
|
|
|
1359
2130
|
* and would miss a same-batch colliding pair. The conflict action is a hard
|
|
1360
2131
|
* abort (a derivation write carries no user OR clause, and a declared
|
|
1361
2132
|
* `on conflict replace`/`ignore` default must not evict or drop derived
|
|
1362
|
-
* rows). Per-image cost is one effective scan
|
|
1363
|
-
*
|
|
2133
|
+
* rows). Per-image cost is one effective full scan: unlike the DML path
|
|
2134
|
+
* ({@link findUniqueConflictFor}), this one is NOT routed through
|
|
2135
|
+
* {@link findUniqueConflictViaIndex}, because a backing table keeps no
|
|
2136
|
+
* secondary indexes by design — there is never an index store to seek.
|
|
1364
2137
|
*
|
|
1365
2138
|
* Zero overhead when the table declares no secondary UNIQUE (every MV-sugar
|
|
1366
2139
|
* backing, and most maintained tables): one empty-array check.
|