deepbase-sqlite 3.10.1 → 3.11.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 +31 -0
- package/package.json +7 -7
- package/src/SqliteDriver.js +126 -3
- package/src/config.js +20 -1
- package/src/index.d.ts +9 -0
- package/test/test.js +183 -0
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ new SqliteDriver({
|
|
|
62
62
|
baseDelayMs: 25, // Exponential backoff base
|
|
63
63
|
maxDelayMs: 250 // Backoff cap
|
|
64
64
|
},
|
|
65
|
+
queryWindowMaxRecords: 1000, // query() records a native window may reach
|
|
66
|
+
queryWindowMaxProbes: 12000, // query() lookups the order guard may spend
|
|
65
67
|
nidAlphabet: 'ABC...', // Alphabet for ID generation
|
|
66
68
|
nidLength: 10 // Length of generated IDs
|
|
67
69
|
})
|
|
@@ -143,6 +145,35 @@ Efficiently stores nested objects using a key-value schema:
|
|
|
143
145
|
|
|
144
146
|
Each row also stores a database-assigned `seq` so reads that rebuild objects use `ORDER BY seq, key`. That matches JavaScript insertion order for sibling keys and keeps `shift()` / `pop()` aligned with `JsonDriver`. For legacy databases, the driver only adds the missing column and index; it does not renumber existing rows. Historical ties remain deterministic through the `key` fallback order.
|
|
145
147
|
|
|
148
|
+
### Querying
|
|
149
|
+
|
|
150
|
+
`db.query(...path)` evaluates `where` / `orderBy` / `skip` / `take` / `select` in memory and never pushes filters into SQLite. Terminals (`toArray`, `first`, `count`, `any`) return records shaped `{ id, value }`, where `id` is the property name and `value` the stored value:
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
const adults = await db.query('users').where('age', '>=', 18).orderBy('age').toArray();
|
|
154
|
+
// [{ id: 'alice', value: { name: 'Alice', age: 30 } }, ...]
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Native pagination window
|
|
158
|
+
|
|
159
|
+
When the chain *starts* with `skip()`/`take()`, the driver answers that window from the key index and rebuilds only the records the window keeps, instead of materialising the whole collection. `first()` and `any()` use the same path because they are a single-record window.
|
|
160
|
+
|
|
161
|
+
```javascript
|
|
162
|
+
// Reads the key index plus 10 records, not the 20,000 records in the collection.
|
|
163
|
+
const page = await db.query('users').skip(100).take(10).toArray();
|
|
164
|
+
|
|
165
|
+
// Filters and ordering still run in memory, so a leading where()/orderBy()
|
|
166
|
+
// falls back to reading the collection.
|
|
167
|
+
const adults = await db.query('users').where('age', '>=', 18).take(10).toArray();
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Two guards keep the native window cheaper than reading the collection, both configurable on the driver:
|
|
171
|
+
|
|
172
|
+
- `queryWindowMaxRecords` (default `1000`): how far `offset + limit` may reach before the driver reads the collection instead.
|
|
173
|
+
- `queryWindowMaxProbes` (default `12000`): how many key-index lookups the order guard may spend. The guard exists because stored keys escape `.` and `\` and expand records into `<key>.<field>` rows, so a plain key scan can disagree with the record order for ids such as `u1` and `u1!`; when it cannot prove the order, the driver falls back to the generic path.
|
|
174
|
+
|
|
175
|
+
Set either budget to `0` to always use the generic path. Measurements on 20,000 records (80,000 rows, 4 fields each): `first()` 71 ms → 0.04 ms, `take(10)` 71 ms → 0.14 ms, `skip(100).take(10)` 69 ms → 0.8 ms, `take(1000)` 70 ms → 13 ms.
|
|
176
|
+
|
|
146
177
|
### ACID Compliance
|
|
147
178
|
|
|
148
179
|
SQLite provides:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepbase-sqlite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "⚡ DeepBase SQLite - SQLite database driver",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.cjs",
|
|
@@ -17,10 +17,7 @@
|
|
|
17
17
|
"better-sqlite3": "^11.8.1"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"deepbase": "^3.
|
|
21
|
-
},
|
|
22
|
-
"scripts": {
|
|
23
|
-
"test": "mocha test/test.js test/test-multiprocess.js"
|
|
20
|
+
"deepbase": "^3.11.0"
|
|
24
21
|
},
|
|
25
22
|
"devDependencies": {
|
|
26
23
|
"mocha": "^10.8.2"
|
|
@@ -44,5 +41,8 @@
|
|
|
44
41
|
"bugs": {
|
|
45
42
|
"url": "https://github.com/clasen/DeepBase/issues"
|
|
46
43
|
},
|
|
47
|
-
"homepage": "https://github.com/clasen/DeepBase/tree/main/packages/driver-sqlite"
|
|
48
|
-
|
|
44
|
+
"homepage": "https://github.com/clasen/DeepBase/tree/main/packages/driver-sqlite",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "mocha test/test.js test/test-multiprocess.js"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/SqliteDriver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeepBaseDriver } from 'deepbase';
|
|
1
|
+
import { DeepBaseDriver, evaluateQuery, removeKey, resolveQueryWindow } from 'deepbase';
|
|
2
2
|
import Database from 'better-sqlite3';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import * as pathModule from 'path';
|
|
@@ -8,20 +8,37 @@ import { backupFrom, checkIntegrityAt, checkpoint, vacuum } from './maintenance.
|
|
|
8
8
|
import { ensureSchema } from './schema.js';
|
|
9
9
|
|
|
10
10
|
export class SqliteDriver extends DeepBaseDriver {
|
|
11
|
-
constructor({
|
|
11
|
+
constructor({
|
|
12
|
+
name,
|
|
13
|
+
path,
|
|
14
|
+
pragma,
|
|
15
|
+
busyTimeoutMs,
|
|
16
|
+
busyRetry,
|
|
17
|
+
queryWindowMaxRecords,
|
|
18
|
+
queryWindowMaxProbes,
|
|
19
|
+
...opts
|
|
20
|
+
} = {}) {
|
|
12
21
|
super(opts);
|
|
13
22
|
|
|
14
23
|
if (typeof path !== 'string' || path.trim() === '' || !pathModule.isAbsolute(path)) {
|
|
15
24
|
throw new TypeError('SqliteDriver requires an absolute "path" option.');
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
const config = resolveSqliteConfig({
|
|
27
|
+
const config = resolveSqliteConfig({
|
|
28
|
+
pragma,
|
|
29
|
+
busyTimeoutMs,
|
|
30
|
+
busyRetry,
|
|
31
|
+
queryWindowMaxRecords,
|
|
32
|
+
queryWindowMaxProbes,
|
|
33
|
+
});
|
|
19
34
|
this.name = name || 'default';
|
|
20
35
|
this.path = path;
|
|
21
36
|
this.pragma = config.pragma;
|
|
22
37
|
this.pragmaConfig = config.pragmaConfig;
|
|
23
38
|
this.busyTimeoutMs = config.busyTimeoutMs;
|
|
24
39
|
this.busyRetry = config.busyRetry;
|
|
40
|
+
this.queryWindowMaxRecords = config.queryWindowMaxRecords;
|
|
41
|
+
this.queryWindowMaxProbes = config.queryWindowMaxProbes;
|
|
25
42
|
|
|
26
43
|
this.path = pathModule.resolve(this.path);
|
|
27
44
|
this.fileName = pathModule.join(this.path, `${this.name}.db`);
|
|
@@ -89,6 +106,9 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
89
106
|
this.getLastKeyStmt = this.db.prepare('SELECT key FROM deepbase ORDER BY seq DESC, key DESC LIMIT 1');
|
|
90
107
|
this.delChildrenStmt = this.db.prepare('DELETE FROM deepbase WHERE key >= ? AND key < ?');
|
|
91
108
|
this.hasChildrenStmt = this.db.prepare('SELECT 1 FROM deepbase WHERE key >= ? AND key < ? LIMIT 1');
|
|
109
|
+
this.hasRangeStmt = this.db.prepare('SELECT 1 FROM deepbase WHERE key >= ? AND key < ? LIMIT 1');
|
|
110
|
+
this.getChildKeysStmt = this.db.prepare('SELECT key FROM deepbase WHERE key >= ? AND key < ? ORDER BY key');
|
|
111
|
+
this.getAllKeysStmt = this.db.prepare('SELECT key FROM deepbase ORDER BY key');
|
|
92
112
|
|
|
93
113
|
const setTxn = this.db.transaction((key, jsonValue, keys) => {
|
|
94
114
|
this._expandParentObjects(keys);
|
|
@@ -208,6 +228,86 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
208
228
|
return this._getSync(args);
|
|
209
229
|
}
|
|
210
230
|
|
|
231
|
+
async query(path, steps) {
|
|
232
|
+
await this.connect();
|
|
233
|
+
const window = resolveQueryWindow(steps);
|
|
234
|
+
if (window) {
|
|
235
|
+
const records = this._readQueryWindow(path, window);
|
|
236
|
+
if (records) return evaluateQuery(records, window.rest, { path });
|
|
237
|
+
}
|
|
238
|
+
return evaluateQuery(this._getSync(path), steps, { path });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Answer a leading skip()/take() window from the key index, so only the kept
|
|
243
|
+
* records are rebuilt. Returns null when the window cannot be proven
|
|
244
|
+
* equivalent to reading the collection in record order.
|
|
245
|
+
* @param {Array<string|number>} path - Path to the queried object
|
|
246
|
+
* @param {{offset: number, limit: number|null}} window - Resolved window
|
|
247
|
+
* @returns {object|null} Records keyed by id, or null for the generic path
|
|
248
|
+
*/
|
|
249
|
+
_readQueryWindow(path, { offset, limit }) {
|
|
250
|
+
const needed = limit === null ? Infinity : offset + limit;
|
|
251
|
+
if (needed === 0 || needed > this.queryWindowMaxRecords) return null;
|
|
252
|
+
|
|
253
|
+
const ids = [];
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
let probes = 0;
|
|
256
|
+
let previous;
|
|
257
|
+
|
|
258
|
+
const scan = path.length === 0
|
|
259
|
+
? this.getAllKeysStmt.iterate()
|
|
260
|
+
: this.getChildKeysStmt.iterate(...this._childRange(this._pathToKey(path)));
|
|
261
|
+
|
|
262
|
+
for (const row of scan) {
|
|
263
|
+
const id = this._keyToPath(row.key)[path.length];
|
|
264
|
+
if (id === undefined || id === previous) continue;
|
|
265
|
+
// Record ids must sit in one run of rows, in ascending key order, for the
|
|
266
|
+
// key index order to match the record order evaluateQuery() establishes.
|
|
267
|
+
if (seen.has(id)) return null;
|
|
268
|
+
// One probe per id character, plus the children probe below, keeps the
|
|
269
|
+
// guard cheaper than reading the collection for any window we accept.
|
|
270
|
+
probes += id.length + 1;
|
|
271
|
+
if (probes > this.queryWindowMaxProbes) return null;
|
|
272
|
+
// The first id sizes the whole window: long ids cost a probe each, so a
|
|
273
|
+
// window that cannot fit the budget falls back before probing further.
|
|
274
|
+
if (ids.length === 0 && needed * probes > this.queryWindowMaxProbes) return null;
|
|
275
|
+
if (this._isWindowEdgeUnsafe(path, id)) return null;
|
|
276
|
+
seen.add(id);
|
|
277
|
+
previous = id;
|
|
278
|
+
ids.push(id);
|
|
279
|
+
if (ids.length >= needed) break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// No rows means either a missing path or a value that is not an object;
|
|
283
|
+
// both keep the generic path so it can report the same error.
|
|
284
|
+
if (ids.length === 0) return null;
|
|
285
|
+
|
|
286
|
+
const selected = limit === null ? ids.slice(offset) : ids.slice(offset, offset + limit);
|
|
287
|
+
const records = {};
|
|
288
|
+
for (const id of selected) {
|
|
289
|
+
records[id] = this._getSync([...path, id]);
|
|
290
|
+
}
|
|
291
|
+
return records;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Stored keys escape "." and "\\", and records expand into "<key>.<field>"
|
|
296
|
+
* rows, so a key-order scan can disagree with the record order evaluateQuery()
|
|
297
|
+
* uses. Probe the two shapes that can produce that disagreement.
|
|
298
|
+
* @param {Array<string|number>} path - Path to the queried object
|
|
299
|
+
* @param {string} id - Record id to check
|
|
300
|
+
* @returns {boolean} True when a native window could skip an earlier record
|
|
301
|
+
*/
|
|
302
|
+
_isWindowEdgeUnsafe(path, id) {
|
|
303
|
+
for (let index = 0; index < id.length; index++) {
|
|
304
|
+
const prefix = this._pathToKey([...path, id.slice(0, index)]);
|
|
305
|
+
if (this.hasRangeStmt.get(`${prefix}\\`, `${prefix}]`)) return true;
|
|
306
|
+
if (id[index] < '.' && this.hasChildrenStmt.get(...this._childRange(prefix))) return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
211
311
|
_getSync(args) {
|
|
212
312
|
if (args.length === 0) {
|
|
213
313
|
return this._getRootObject();
|
|
@@ -269,10 +369,33 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
269
369
|
const key = this._pathToKey(keys);
|
|
270
370
|
const childRange = this._childRange(key);
|
|
271
371
|
return this._runWrite(() => {
|
|
372
|
+
if (this._delArrayElement(keys)) return;
|
|
272
373
|
this._delTxn(key, ...childRange, keys);
|
|
273
374
|
});
|
|
274
375
|
}
|
|
275
376
|
|
|
377
|
+
/**
|
|
378
|
+
* A stored array lives in one row, so deleting one of its indexes has to
|
|
379
|
+
* splice the array and rewrite that row instead of deleting a child row that
|
|
380
|
+
* does not exist. Returns true when the array handled the delete.
|
|
381
|
+
* @param {Array<string|number>} keys - Path to the parent plus the index
|
|
382
|
+
* @returns {boolean} True when an array element was removed
|
|
383
|
+
*/
|
|
384
|
+
_delArrayElement(keys) {
|
|
385
|
+
if (keys.length < 2) return false;
|
|
386
|
+
|
|
387
|
+
const parentPath = keys.slice(0, -1);
|
|
388
|
+
const parent = this._getSync(parentPath);
|
|
389
|
+
if (!Array.isArray(parent)) return false;
|
|
390
|
+
|
|
391
|
+
const index = keys[keys.length - 1];
|
|
392
|
+
// removeKey() splices real indexes and ignores everything else.
|
|
393
|
+
if (!removeKey(parent, index)) return false;
|
|
394
|
+
|
|
395
|
+
this._setTxn(this._pathToKey(parentPath), JSON.stringify(parent), keys);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
276
399
|
async inc(...args) {
|
|
277
400
|
const i = args.pop();
|
|
278
401
|
return this.upd(...args, n => n + i);
|
package/src/config.js
CHANGED
|
@@ -26,6 +26,11 @@ const PRAGMA_PROFILES = Object.freeze({
|
|
|
26
26
|
export const SQLITE_CONFIG = Object.freeze({
|
|
27
27
|
defaultPragma: 'balanced',
|
|
28
28
|
busyTimeoutMs: 5000,
|
|
29
|
+
// query() answers a leading skip()/take() window from the key index. Both
|
|
30
|
+
// budgets keep that scan cheaper than reading the whole collection: how far
|
|
31
|
+
// the window may reach, and how many order probes it may spend.
|
|
32
|
+
queryWindowMaxRecords: 1000,
|
|
33
|
+
queryWindowMaxProbes: 12000,
|
|
29
34
|
busyRetry: Object.freeze({
|
|
30
35
|
maxAttempts: 2,
|
|
31
36
|
baseDelayMs: 25,
|
|
@@ -40,7 +45,13 @@ function assertNonNegativeInteger(name, value) {
|
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
export function resolveSqliteConfig({
|
|
48
|
+
export function resolveSqliteConfig({
|
|
49
|
+
pragma,
|
|
50
|
+
busyTimeoutMs,
|
|
51
|
+
busyRetry,
|
|
52
|
+
queryWindowMaxRecords,
|
|
53
|
+
queryWindowMaxProbes,
|
|
54
|
+
} = {}) {
|
|
44
55
|
const resolvedPragma = pragma ?? SQLITE_CONFIG.defaultPragma;
|
|
45
56
|
if (!Object.prototype.hasOwnProperty.call(SQLITE_CONFIG.pragmaProfiles, resolvedPragma)) {
|
|
46
57
|
throw new TypeError(`pragma must be one of: ${Object.keys(SQLITE_CONFIG.pragmaProfiles).join(', ')}`);
|
|
@@ -68,10 +79,18 @@ export function resolveSqliteConfig({ pragma, busyTimeoutMs, busyRetry } = {}) {
|
|
|
68
79
|
throw new TypeError('busyRetry.maxDelayMs must be greater than or equal to busyRetry.baseDelayMs');
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
const resolvedQueryWindowMaxRecords = queryWindowMaxRecords ?? SQLITE_CONFIG.queryWindowMaxRecords;
|
|
83
|
+
assertNonNegativeInteger('queryWindowMaxRecords', resolvedQueryWindowMaxRecords);
|
|
84
|
+
|
|
85
|
+
const resolvedQueryWindowMaxProbes = queryWindowMaxProbes ?? SQLITE_CONFIG.queryWindowMaxProbes;
|
|
86
|
+
assertNonNegativeInteger('queryWindowMaxProbes', resolvedQueryWindowMaxProbes);
|
|
87
|
+
|
|
71
88
|
return {
|
|
72
89
|
pragma: resolvedPragma,
|
|
73
90
|
pragmaConfig: SQLITE_CONFIG.pragmaProfiles[resolvedPragma],
|
|
74
91
|
busyTimeoutMs: resolvedBusyTimeoutMs,
|
|
75
92
|
busyRetry: resolvedBusyRetry,
|
|
93
|
+
queryWindowMaxRecords: resolvedQueryWindowMaxRecords,
|
|
94
|
+
queryWindowMaxProbes: resolvedQueryWindowMaxProbes,
|
|
76
95
|
};
|
|
77
96
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export interface SqliteDriverOptions extends DeepBaseDriverOptions {
|
|
|
24
24
|
pragma?: 'none' | 'safe' | 'balanced' | 'fast';
|
|
25
25
|
busyTimeoutMs?: number;
|
|
26
26
|
busyRetry?: SqliteBusyRetryOptions;
|
|
27
|
+
/**
|
|
28
|
+
* Records a native `query()` window may reach, counting `offset + limit`.
|
|
29
|
+
* Beyond it the driver reads the collection and evaluates in memory.
|
|
30
|
+
*/
|
|
31
|
+
queryWindowMaxRecords?: number;
|
|
32
|
+
/** Key-index lookups the `query()` order guard may spend before falling back. */
|
|
33
|
+
queryWindowMaxProbes?: number;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export class SqliteDriver extends DeepBaseDriver {
|
|
@@ -35,6 +42,8 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
35
42
|
pragma: string;
|
|
36
43
|
busyTimeoutMs: number;
|
|
37
44
|
busyRetry: Required<SqliteBusyRetryOptions>;
|
|
45
|
+
queryWindowMaxRecords: number;
|
|
46
|
+
queryWindowMaxProbes: number;
|
|
38
47
|
|
|
39
48
|
/** Opens the existing database read-only, runs `PRAGMA integrity_check`,
|
|
40
49
|
* then closes it. Does not connect or mutate the driver database. */
|
package/test/test.js
CHANGED
|
@@ -4,6 +4,8 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { DeepBase } from '../../core/src/index.js';
|
|
7
|
+
import { arrayScenarios } from '../../core/test/array-scenario.js';
|
|
8
|
+
import { seedQueryFixture, queryScenarios } from '../../core/test/query-scenario.js';
|
|
7
9
|
import { SqliteDriver } from '../src/SqliteDriver.js';
|
|
8
10
|
|
|
9
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -22,6 +24,21 @@ describe('SqliteDriver configuration', function () {
|
|
|
22
24
|
/requires an absolute "path" option/,
|
|
23
25
|
);
|
|
24
26
|
});
|
|
27
|
+
|
|
28
|
+
it('should reject invalid query window budgets', function () {
|
|
29
|
+
const options = { name: 'config', path: testDataPath };
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => new SqliteDriver({ ...options, queryWindowMaxRecords: -1 }),
|
|
32
|
+
/queryWindowMaxRecords must be a non-negative integer/,
|
|
33
|
+
);
|
|
34
|
+
assert.throws(
|
|
35
|
+
() => new SqliteDriver({ ...options, queryWindowMaxProbes: 1.5 }),
|
|
36
|
+
/queryWindowMaxProbes must be a non-negative integer/,
|
|
37
|
+
);
|
|
38
|
+
const driver = new SqliteDriver(options);
|
|
39
|
+
assert.strictEqual(driver.queryWindowMaxRecords, 1000);
|
|
40
|
+
assert.strictEqual(driver.queryWindowMaxProbes, 12000);
|
|
41
|
+
});
|
|
25
42
|
});
|
|
26
43
|
|
|
27
44
|
for (const pragma of PRAGMA_MODES) {
|
|
@@ -200,6 +217,14 @@ for (const pragma of PRAGMA_MODES) {
|
|
|
200
217
|
});
|
|
201
218
|
});
|
|
202
219
|
|
|
220
|
+
describe('del()/pop()/shift() sobre arrays', function () {
|
|
221
|
+
for (const scenario of arrayScenarios) {
|
|
222
|
+
it(scenario.title, async function () {
|
|
223
|
+
await scenario.run(db);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
203
228
|
describe('Add Operation', function () {
|
|
204
229
|
it('should add item with auto-generated ID', async function () {
|
|
205
230
|
const p = await db.add('users', { name: 'Charlie' });
|
|
@@ -858,6 +883,164 @@ for (const pragma of PRAGMA_MODES) {
|
|
|
858
883
|
});
|
|
859
884
|
}
|
|
860
885
|
});
|
|
886
|
+
|
|
887
|
+
describe('query()', function () {
|
|
888
|
+
beforeEach(async function () {
|
|
889
|
+
await seedQueryFixture(db);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
for (const scenario of queryScenarios) {
|
|
893
|
+
it(scenario.title, async function () {
|
|
894
|
+
await scenario.run(db);
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
describe('query() native window', function () {
|
|
900
|
+
// Same file, same data, but without the key-index window: the reference
|
|
901
|
+
// always reads the collection and evaluates every step in memory.
|
|
902
|
+
let reference;
|
|
903
|
+
|
|
904
|
+
beforeEach(async function () {
|
|
905
|
+
reference = new DeepBase(new SqliteDriver({
|
|
906
|
+
name: `test-${pragma}-${testCounter}`,
|
|
907
|
+
path: testDataPath,
|
|
908
|
+
pragma,
|
|
909
|
+
queryWindowMaxRecords: 0,
|
|
910
|
+
}));
|
|
911
|
+
await reference.connect();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
afterEach(async function () {
|
|
915
|
+
await reference.disconnect();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// One row per field, so records live in fragmented rows.
|
|
919
|
+
async function seedRecords(ids) {
|
|
920
|
+
await db.del();
|
|
921
|
+
for (const [index, id] of ids.entries()) {
|
|
922
|
+
await db.set('users', id, 'name', `name ${id}`);
|
|
923
|
+
await db.set('users', id, 'age', index);
|
|
924
|
+
await db.set('users', id, 'address', 'city', 'Rosario');
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const countRecords = length =>
|
|
929
|
+
Array.from({ length }, (unused, index) => `u${String(index).padStart(3, '0')}`);
|
|
930
|
+
|
|
931
|
+
// Record ids that make the key index order disagree with the record order.
|
|
932
|
+
const ID_SETS = [
|
|
933
|
+
['u000', 'u001', 'u002', 'u003', 'u004', 'u005'],
|
|
934
|
+
['u1', 'u1!', 'u2'],
|
|
935
|
+
['.hidden', '0alpha', 'z'],
|
|
936
|
+
['a.b', 'a0', 'a1'],
|
|
937
|
+
['x', 'x\\y', 'y'],
|
|
938
|
+
['', 'a', 'b'],
|
|
939
|
+
];
|
|
940
|
+
const CHAINS = [
|
|
941
|
+
query => query.take(1),
|
|
942
|
+
query => query.take(2),
|
|
943
|
+
query => query.skip(1).take(2),
|
|
944
|
+
query => query.take(2).skip(1),
|
|
945
|
+
query => query.skip(3),
|
|
946
|
+
query => query.take(0),
|
|
947
|
+
query => query.take(2).where('age', '>', 0),
|
|
948
|
+
];
|
|
949
|
+
|
|
950
|
+
function instrument(driver) {
|
|
951
|
+
const probe = { rows: 0, checks: 0 };
|
|
952
|
+
const all = driver.getChildrenStmt.all.bind(driver.getChildrenStmt);
|
|
953
|
+
driver.getChildrenStmt.all = (...args) => {
|
|
954
|
+
const rows = all(...args);
|
|
955
|
+
probe.rows += rows.length;
|
|
956
|
+
return rows;
|
|
957
|
+
};
|
|
958
|
+
const range = driver.hasRangeStmt.get.bind(driver.hasRangeStmt);
|
|
959
|
+
driver.hasRangeStmt.get = (...args) => {
|
|
960
|
+
probe.checks++;
|
|
961
|
+
return range(...args);
|
|
962
|
+
};
|
|
963
|
+
return probe;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
it('matches the generic path for windows and pathological ids', async function () {
|
|
967
|
+
for (const ids of ID_SETS) {
|
|
968
|
+
await seedRecords(ids);
|
|
969
|
+
for (const chain of CHAINS) {
|
|
970
|
+
for (const terminal of ['toArray', 'first', 'count', 'any']) {
|
|
971
|
+
const native = await chain(db.query('users'))[terminal]();
|
|
972
|
+
const generic = await chain(reference.query('users'))[terminal]();
|
|
973
|
+
assert.deepStrictEqual(native, generic, `${JSON.stringify(ids)} / ${terminal}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('keeps the record order for ids that break the key order', async function () {
|
|
980
|
+
await seedRecords(['u1', 'u1!', 'u2']);
|
|
981
|
+
assert.deepStrictEqual(
|
|
982
|
+
(await db.query('users').take(1).toArray()).map(record => record.id),
|
|
983
|
+
['u1'],
|
|
984
|
+
);
|
|
985
|
+
assert.deepStrictEqual(
|
|
986
|
+
(await db.query('users').skip(1).toArray()).map(record => record.id),
|
|
987
|
+
['u1!', 'u2'],
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
await seedRecords(['.hidden', '0alpha', 'z']);
|
|
991
|
+
assert.deepStrictEqual(
|
|
992
|
+
(await db.query('users').take(1).toArray()).map(record => record.id),
|
|
993
|
+
['.hidden'],
|
|
994
|
+
);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it('reads only the records the window keeps', async function () {
|
|
998
|
+
await seedRecords(countRecords(60));
|
|
999
|
+
|
|
1000
|
+
const nativeProbe = instrument(db.getDriver(0));
|
|
1001
|
+
const native = await db.query('users').take(5).toArray();
|
|
1002
|
+
const genericProbe = instrument(reference.getDriver(0));
|
|
1003
|
+
const generic = await reference.query('users').take(5).toArray();
|
|
1004
|
+
|
|
1005
|
+
assert.deepStrictEqual(native, generic);
|
|
1006
|
+
assert.strictEqual(native.length, 5);
|
|
1007
|
+
assert.ok(nativeProbe.checks > 0, 'the window guard did not run');
|
|
1008
|
+
assert.ok(nativeProbe.rows <= 20, `native window read ${nativeProbe.rows} child rows`);
|
|
1009
|
+
assert.ok(genericProbe.rows >= 120, `generic path read only ${genericProbe.rows} child rows`);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('answers first() and any() from the window', async function () {
|
|
1013
|
+
await seedRecords(countRecords(60));
|
|
1014
|
+
|
|
1015
|
+
const probe = instrument(db.getDriver(0));
|
|
1016
|
+
assert.deepStrictEqual(await db.query('users').first(), {
|
|
1017
|
+
id: 'u000',
|
|
1018
|
+
value: { name: 'name u000', age: 0, address: { city: 'Rosario' } },
|
|
1019
|
+
});
|
|
1020
|
+
assert.strictEqual(await db.query('users').any(), true);
|
|
1021
|
+
assert.ok(probe.checks > 0, 'first()/any() did not use the window');
|
|
1022
|
+
assert.ok(probe.rows <= 8, `first()/any() read ${probe.rows} child rows`);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('leaves chains that another step opens on the generic path', async function () {
|
|
1026
|
+
await seedRecords(countRecords(20));
|
|
1027
|
+
|
|
1028
|
+
const probe = instrument(db.getDriver(0));
|
|
1029
|
+
const filtered = await db.query('users').where('age', '=', 7).take(2).toArray();
|
|
1030
|
+
assert.strictEqual(filtered.length, 1);
|
|
1031
|
+
assert.strictEqual(probe.checks, 0);
|
|
1032
|
+
|
|
1033
|
+
const ordered = await db.query('users').orderBy('age').take(2).toArray();
|
|
1034
|
+
assert.strictEqual(ordered.length, 2);
|
|
1035
|
+
assert.strictEqual(probe.checks, 0);
|
|
1036
|
+
|
|
1037
|
+
const deep = await db.query('users').skip(500).take(2).toArray();
|
|
1038
|
+
assert.deepStrictEqual(deep, []);
|
|
1039
|
+
// A skip past the end still answers from the key index, without
|
|
1040
|
+
// rebuilding the records it drops.
|
|
1041
|
+
assert.ok(probe.checks > 0, 'skip past the end did not use the window');
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
861
1044
|
});
|
|
862
1045
|
}
|
|
863
1046
|
|