@b2bc-devkit/sheetorm 1.2.0 → 1.2.3
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/package.json +171 -86
- package/dist/npm/SheetOrm.d.ts +0 -41
- package/dist/npm/SheetOrm.d.ts.map +0 -1
- package/dist/npm/SheetOrm.js +0 -41
- package/dist/npm/SheetOrm.js.map +0 -1
- package/dist/npm/core/Decorators.d.ts +0 -145
- package/dist/npm/core/Decorators.d.ts.map +0 -1
- package/dist/npm/core/Decorators.js +0 -226
- package/dist/npm/core/Decorators.js.map +0 -1
- package/dist/npm/core/Record.d.ts +0 -184
- package/dist/npm/core/Record.d.ts.map +0 -1
- package/dist/npm/core/Record.js +0 -321
- package/dist/npm/core/Record.js.map +0 -1
- package/dist/npm/core/RecordConstructor.d.ts +0 -20
- package/dist/npm/core/RecordConstructor.d.ts.map +0 -1
- package/dist/npm/core/RecordConstructor.js +0 -2
- package/dist/npm/core/RecordConstructor.js.map +0 -1
- package/dist/npm/core/RecordStatic.d.ts +0 -25
- package/dist/npm/core/RecordStatic.d.ts.map +0 -1
- package/dist/npm/core/RecordStatic.js +0 -2
- package/dist/npm/core/RecordStatic.js.map +0 -1
- package/dist/npm/core/Registry.d.ts +0 -101
- package/dist/npm/core/Registry.d.ts.map +0 -1
- package/dist/npm/core/Registry.js +0 -231
- package/dist/npm/core/Registry.js.map +0 -1
- package/dist/npm/core/SheetRepository.d.ts +0 -331
- package/dist/npm/core/SheetRepository.d.ts.map +0 -1
- package/dist/npm/core/SheetRepository.js +0 -1041
- package/dist/npm/core/SheetRepository.js.map +0 -1
- package/dist/npm/core/cache/MemoryCache.d.ts +0 -67
- package/dist/npm/core/cache/MemoryCache.d.ts.map +0 -1
- package/dist/npm/core/cache/MemoryCache.js +0 -112
- package/dist/npm/core/cache/MemoryCache.js.map +0 -1
- package/dist/npm/core/types/Entity.d.ts +0 -21
- package/dist/npm/core/types/Entity.d.ts.map +0 -1
- package/dist/npm/core/types/Entity.js +0 -2
- package/dist/npm/core/types/Entity.js.map +0 -1
- package/dist/npm/core/types/FieldDefinition.d.ts +0 -21
- package/dist/npm/core/types/FieldDefinition.d.ts.map +0 -1
- package/dist/npm/core/types/FieldDefinition.js +0 -2
- package/dist/npm/core/types/FieldDefinition.js.map +0 -1
- package/dist/npm/core/types/FieldType.d.ts +0 -12
- package/dist/npm/core/types/FieldType.d.ts.map +0 -1
- package/dist/npm/core/types/FieldType.js +0 -2
- package/dist/npm/core/types/FieldType.js.map +0 -1
- package/dist/npm/core/types/Filter.d.ts +0 -16
- package/dist/npm/core/types/Filter.d.ts.map +0 -1
- package/dist/npm/core/types/Filter.js +0 -2
- package/dist/npm/core/types/Filter.js.map +0 -1
- package/dist/npm/core/types/FilterOperator.d.ts +0 -16
- package/dist/npm/core/types/FilterOperator.d.ts.map +0 -1
- package/dist/npm/core/types/FilterOperator.js +0 -2
- package/dist/npm/core/types/FilterOperator.js.map +0 -1
- package/dist/npm/core/types/GroupResult.d.ts +0 -14
- package/dist/npm/core/types/GroupResult.d.ts.map +0 -1
- package/dist/npm/core/types/GroupResult.js +0 -2
- package/dist/npm/core/types/GroupResult.js.map +0 -1
- package/dist/npm/core/types/ICacheProvider.d.ts +0 -20
- package/dist/npm/core/types/ICacheProvider.d.ts.map +0 -1
- package/dist/npm/core/types/ICacheProvider.js +0 -2
- package/dist/npm/core/types/ICacheProvider.js.map +0 -1
- package/dist/npm/core/types/ISheetAdapter.d.ts +0 -53
- package/dist/npm/core/types/ISheetAdapter.d.ts.map +0 -1
- package/dist/npm/core/types/ISheetAdapter.js +0 -2
- package/dist/npm/core/types/ISheetAdapter.js.map +0 -1
- package/dist/npm/core/types/ISpreadsheetAdapter.d.ts +0 -49
- package/dist/npm/core/types/ISpreadsheetAdapter.d.ts.map +0 -1
- package/dist/npm/core/types/ISpreadsheetAdapter.js +0 -2
- package/dist/npm/core/types/ISpreadsheetAdapter.js.map +0 -1
- package/dist/npm/core/types/IndexDefinition.d.ts +0 -16
- package/dist/npm/core/types/IndexDefinition.d.ts.map +0 -1
- package/dist/npm/core/types/IndexDefinition.js +0 -2
- package/dist/npm/core/types/IndexDefinition.js.map +0 -1
- package/dist/npm/core/types/LifecycleHooks.d.ts +0 -32
- package/dist/npm/core/types/LifecycleHooks.d.ts.map +0 -1
- package/dist/npm/core/types/LifecycleHooks.js +0 -2
- package/dist/npm/core/types/LifecycleHooks.js.map +0 -1
- package/dist/npm/core/types/PaginatedResult.d.ts +0 -18
- package/dist/npm/core/types/PaginatedResult.d.ts.map +0 -1
- package/dist/npm/core/types/PaginatedResult.js +0 -2
- package/dist/npm/core/types/PaginatedResult.js.map +0 -1
- package/dist/npm/core/types/QueryOptions.d.ts +0 -21
- package/dist/npm/core/types/QueryOptions.d.ts.map +0 -1
- package/dist/npm/core/types/QueryOptions.js +0 -2
- package/dist/npm/core/types/QueryOptions.js.map +0 -1
- package/dist/npm/core/types/SortClause.d.ts +0 -10
- package/dist/npm/core/types/SortClause.d.ts.map +0 -1
- package/dist/npm/core/types/SortClause.js +0 -2
- package/dist/npm/core/types/SortClause.js.map +0 -1
- package/dist/npm/core/types/SystemColumns.d.ts +0 -16
- package/dist/npm/core/types/SystemColumns.d.ts.map +0 -1
- package/dist/npm/core/types/SystemColumns.js +0 -16
- package/dist/npm/core/types/SystemColumns.js.map +0 -1
- package/dist/npm/core/types/TableSchema.d.ts +0 -24
- package/dist/npm/core/types/TableSchema.d.ts.map +0 -1
- package/dist/npm/core/types/TableSchema.js +0 -2
- package/dist/npm/core/types/TableSchema.js.map +0 -1
- package/dist/npm/index/IndexMeta.d.ts +0 -16
- package/dist/npm/index/IndexMeta.d.ts.map +0 -1
- package/dist/npm/index/IndexMeta.js +0 -2
- package/dist/npm/index/IndexMeta.js.map +0 -1
- package/dist/npm/index/IndexStore.d.ts +0 -397
- package/dist/npm/index/IndexStore.d.ts.map +0 -1
- package/dist/npm/index/IndexStore.js +0 -1102
- package/dist/npm/index/IndexStore.js.map +0 -1
- package/dist/npm/index.d.ts +0 -117
- package/dist/npm/index.d.ts.map +0 -1
- package/dist/npm/index.js +0 -371
- package/dist/npm/index.js.map +0 -1
- package/dist/npm/query/Query.d.ts +0 -176
- package/dist/npm/query/Query.d.ts.map +0 -1
- package/dist/npm/query/Query.js +0 -270
- package/dist/npm/query/Query.js.map +0 -1
- package/dist/npm/query/QueryEngine.d.ts +0 -140
- package/dist/npm/query/QueryEngine.d.ts.map +0 -1
- package/dist/npm/query/QueryEngine.js +0 -453
- package/dist/npm/query/QueryEngine.js.map +0 -1
- package/dist/npm/storage/GoogleSheetAdapter.d.ts +0 -119
- package/dist/npm/storage/GoogleSheetAdapter.d.ts.map +0 -1
- package/dist/npm/storage/GoogleSheetAdapter.js +0 -238
- package/dist/npm/storage/GoogleSheetAdapter.js.map +0 -1
- package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts +0 -71
- package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts.map +0 -1
- package/dist/npm/storage/GoogleSpreadsheetAdapter.js +0 -131
- package/dist/npm/storage/GoogleSpreadsheetAdapter.js.map +0 -1
- package/dist/npm/testing/ParityCatalog.d.ts +0 -38
- package/dist/npm/testing/ParityCatalog.d.ts.map +0 -1
- package/dist/npm/testing/ParityCatalog.js +0 -407
- package/dist/npm/testing/ParityCatalog.js.map +0 -1
- package/dist/npm/testing/RuntimeBenchmark.d.ts +0 -37
- package/dist/npm/testing/RuntimeBenchmark.d.ts.map +0 -1
- package/dist/npm/testing/RuntimeBenchmark.js +0 -449
- package/dist/npm/testing/RuntimeBenchmark.js.map +0 -1
- package/dist/npm/testing/RuntimeParity.d.ts +0 -66
- package/dist/npm/testing/RuntimeParity.d.ts.map +0 -1
- package/dist/npm/testing/RuntimeParity.js +0 -4193
- package/dist/npm/testing/RuntimeParity.js.map +0 -1
- package/dist/npm/utils/Serialization.d.ts +0 -111
- package/dist/npm/utils/Serialization.d.ts.map +0 -1
- package/dist/npm/utils/Serialization.js +0 -281
- package/dist/npm/utils/Serialization.js.map +0 -1
- package/dist/npm/utils/SheetOrmLogger.d.ts +0 -31
- package/dist/npm/utils/SheetOrmLogger.d.ts.map +0 -1
- package/dist/npm/utils/SheetOrmLogger.js +0 -42
- package/dist/npm/utils/SheetOrmLogger.js.map +0 -1
- package/dist/npm/utils/Uuid.d.ts +0 -29
- package/dist/npm/utils/Uuid.d.ts.map +0 -1
- package/dist/npm/utils/Uuid.js +0 -86
- package/dist/npm/utils/Uuid.js.map +0 -1
|
@@ -1,4193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module RuntimeParity
|
|
3
|
-
*
|
|
4
|
-
* Runtime parity test suite for Google Apps Script (GAS).
|
|
5
|
-
*
|
|
6
|
-
* This file mirrors the Jest test suites (tests/*.test.ts) but runs against
|
|
7
|
-
* real Google Sheets via the GAS execution environment. Its purpose is to
|
|
8
|
-
* verify that SheetORM behaves identically in both the Jest mock environment
|
|
9
|
-
* and the real GAS runtime ("parity").
|
|
10
|
-
*
|
|
11
|
-
* **Architecture**:
|
|
12
|
-
* - {@link RuntimeParityState} — per-run state: unique table names, spreadsheet access.
|
|
13
|
-
* - {@link runtimeSuiteHandlers} — a map of `file → testName → handler` that mirrors
|
|
14
|
-
* every Jest `test()` block. Each handler exercises the same logic but using
|
|
15
|
-
* real Google Sheets API calls instead of mocks.
|
|
16
|
-
* - {@link RuntimeParity} — public API (static methods) invoked from GAS menu:
|
|
17
|
-
* - `runStageOne()` / `runStageTwo()` / `runStageThree()` / `runStageFour()` — execute test subsets.
|
|
18
|
-
* - `validate()` — cross-check collected results against {@link ParityCatalog}.
|
|
19
|
-
*
|
|
20
|
-
* Tests are split into four stages to stay within the 6-minute GAS execution
|
|
21
|
-
* time limit. Each stage persists results to a `__parity_results_*` sheet so
|
|
22
|
-
* that `validate()` can aggregate them later.
|
|
23
|
-
*
|
|
24
|
-
* Helper functions (`fail`, `assertTrue`, `assertEqual`, `assertDeepEqual`,
|
|
25
|
-
* `assertThrows`, `sleepMs`) provide assertion primitives similar to Jest's
|
|
26
|
-
* `expect()` API.
|
|
27
|
-
*/
|
|
28
|
-
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
29
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
30
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
31
|
-
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
32
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
33
|
-
};
|
|
34
|
-
import { Registry } from "../core/Registry.js";
|
|
35
|
-
import { IndexStore } from "../index/IndexStore.js";
|
|
36
|
-
import { Query } from "../query/Query.js";
|
|
37
|
-
import { Record as BaseRecord } from "../core/Record.js";
|
|
38
|
-
import { Decorators } from "../core/Decorators.js";
|
|
39
|
-
import { QueryEngine } from "../query/QueryEngine.js";
|
|
40
|
-
import { GoogleSpreadsheetAdapter } from "../storage/GoogleSpreadsheetAdapter.js";
|
|
41
|
-
import { MemoryCache } from "../core/cache/MemoryCache.js";
|
|
42
|
-
import { SheetRepository } from "../core/SheetRepository.js";
|
|
43
|
-
import { Serialization } from "../utils/Serialization.js";
|
|
44
|
-
import { Uuid } from "../utils/Uuid.js";
|
|
45
|
-
import { ParityCatalog } from "./ParityCatalog.js";
|
|
46
|
-
import { SheetOrmLogger } from "../utils/SheetOrmLogger.js";
|
|
47
|
-
/** Destructure decorator functions for concise usage in test model definitions. */
|
|
48
|
-
const { Indexed, Required, Field, resetDecoratorCaches } = Decorators;
|
|
49
|
-
// ─── RuntimeParityState ───────────────────────────────────────────────────────
|
|
50
|
-
/**
|
|
51
|
-
* Per-run state container for runtime parity tests.
|
|
52
|
-
*
|
|
53
|
-
* Generates unique table names (using a timestamp + sequence) to avoid
|
|
54
|
-
* collisions between consecutive runs. Also provides access to the
|
|
55
|
-
* active spreadsheet and adapter.
|
|
56
|
-
*/
|
|
57
|
-
class RuntimeParityState {
|
|
58
|
-
constructor() {
|
|
59
|
-
/** Timestamp-based run identifier for unique table naming. */
|
|
60
|
-
this.runId = Date.now();
|
|
61
|
-
/** Monotonically increasing counter for table name uniqueness. */
|
|
62
|
-
this.sequence = 0;
|
|
63
|
-
/** Lazily-initialised spreadsheet reference. */
|
|
64
|
-
this.spreadsheet = null;
|
|
65
|
-
}
|
|
66
|
-
/** Return the active GAS spreadsheet (cached after first call). */
|
|
67
|
-
getSpreadsheet() {
|
|
68
|
-
if (this.spreadsheet)
|
|
69
|
-
return this.spreadsheet;
|
|
70
|
-
if (typeof SpreadsheetApp === "undefined") {
|
|
71
|
-
throw new Error("SpreadsheetApp is not available. Run this function in Google Apps Script runtime.");
|
|
72
|
-
}
|
|
73
|
-
this.spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
|
|
74
|
-
return this.spreadsheet;
|
|
75
|
-
}
|
|
76
|
-
/** Create a fresh GoogleSpreadsheetAdapter for the active spreadsheet. */
|
|
77
|
-
getAdapter() {
|
|
78
|
-
return new GoogleSpreadsheetAdapter(this.getSpreadsheet());
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Generate a unique table name based on `baseName`, run ID, and sequence.
|
|
82
|
-
* Ensures no two tests within the same run share a sheet name.
|
|
83
|
-
*/
|
|
84
|
-
nextTableName(baseName) {
|
|
85
|
-
this.sequence += 1;
|
|
86
|
-
return `${baseName}_${this.runId}_${this.sequence}`;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Delete all sheets from the active spreadsheet except one "Sheet1" keeper.
|
|
90
|
-
*
|
|
91
|
-
* Reuses the first existing sheet as a keeper to avoid the 10M-cell limit
|
|
92
|
-
* issue that can occur when inserting a new sheet into a nearly-full
|
|
93
|
-
* spreadsheet, then deletes all remaining sheets. The keeper is cleared
|
|
94
|
-
* and trimmed to 1 row × 1 column.
|
|
95
|
-
*/
|
|
96
|
-
clearAllSheets(log) {
|
|
97
|
-
const emit = log ?? (() => { });
|
|
98
|
-
const spreadsheet = this.getSpreadsheet();
|
|
99
|
-
const originalSheets = spreadsheet.getSheets();
|
|
100
|
-
emit(`[SheetORM] Sheets found before cleanup: ${originalSheets.length}`);
|
|
101
|
-
if (originalSheets.length === 0) {
|
|
102
|
-
const keeper = spreadsheet.insertSheet("Sheet1");
|
|
103
|
-
keeper.clear();
|
|
104
|
-
emit('[SheetORM] No sheets existed. Created and prepared keeper: "Sheet1"');
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
// Reuse first existing sheet as keeper to avoid temporarily increasing cell count
|
|
108
|
-
// (insertSheet can fail when spreadsheet is close to 10M-cell limit).
|
|
109
|
-
const keeper = originalSheets[0];
|
|
110
|
-
emit(`[SheetORM] Keeper sheet: "${keeper.getName()}"`);
|
|
111
|
-
let remainingToDelete = originalSheets.length - 1;
|
|
112
|
-
for (let i = 1; i < originalSheets.length; i += 1) {
|
|
113
|
-
const sheetToDelete = originalSheets[i];
|
|
114
|
-
emit(`[SheetORM] Deleting sheet: "${sheetToDelete.getName()}" | remaining: ${remainingToDelete}`);
|
|
115
|
-
spreadsheet.deleteSheet(sheetToDelete);
|
|
116
|
-
remainingToDelete -= 1;
|
|
117
|
-
emit(`[SheetORM] Deleted. Remaining: ${remainingToDelete}`);
|
|
118
|
-
}
|
|
119
|
-
// Keep one clean, minimal sheet so subsequent test sheets fit under cell limits.
|
|
120
|
-
keeper.clear();
|
|
121
|
-
const maxRows = keeper.getMaxRows();
|
|
122
|
-
if (maxRows > 1) {
|
|
123
|
-
keeper.deleteRows(2, maxRows - 1);
|
|
124
|
-
}
|
|
125
|
-
const maxColumns = keeper.getMaxColumns();
|
|
126
|
-
if (maxColumns > 1) {
|
|
127
|
-
keeper.deleteColumns(2, maxColumns - 1);
|
|
128
|
-
}
|
|
129
|
-
if (keeper.getName() !== "Sheet1") {
|
|
130
|
-
keeper.setName("Sheet1");
|
|
131
|
-
}
|
|
132
|
-
emit('[SheetORM] Cleanup finished. Remaining sheet: "Sheet1"');
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
// ─── Assertion helpers ────────────────────────────────────────────────────────
|
|
136
|
-
// Minimal assertion primitives that mirror Jest's expect() API.
|
|
137
|
-
// Designed for GAS where Jest is unavailable.
|
|
138
|
-
/** Unconditionally throw an error — used by assertion helpers. */
|
|
139
|
-
function fail(message) {
|
|
140
|
-
throw new Error(message);
|
|
141
|
-
}
|
|
142
|
-
/** Assert that `condition` is truthy; throw a descriptive error otherwise. */
|
|
143
|
-
function assertTrue(condition, message) {
|
|
144
|
-
if (!condition)
|
|
145
|
-
fail(message);
|
|
146
|
-
}
|
|
147
|
-
/** Assert strict equality (===) between `actual` and `expected`. */
|
|
148
|
-
function assertEqual(actual, expected, message) {
|
|
149
|
-
if (actual !== expected) {
|
|
150
|
-
fail(`${message}. Expected: ${String(expected)}, actual: ${String(actual)}`);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/** Assert deep equality via JSON.stringify comparison. */
|
|
154
|
-
function assertDeepEqual(actual, expected, message) {
|
|
155
|
-
const actualJson = JSON.stringify(actual);
|
|
156
|
-
const expectedJson = JSON.stringify(expected);
|
|
157
|
-
if (actualJson !== expectedJson) {
|
|
158
|
-
fail(`${message}. Expected: ${expectedJson}, actual: ${actualJson}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
/** Assert that `run` throws an error whose message matches `pattern`. */
|
|
162
|
-
function assertThrows(run, pattern, message) {
|
|
163
|
-
try {
|
|
164
|
-
run();
|
|
165
|
-
}
|
|
166
|
-
catch (error) {
|
|
167
|
-
const text = error instanceof Error ? error.message : String(error);
|
|
168
|
-
if (!pattern.test(text)) {
|
|
169
|
-
fail(`${message}. Error did not match pattern ${pattern.toString()}. Got: ${text}`);
|
|
170
|
-
}
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
fail(`${message}. Expected function to throw.`);
|
|
174
|
-
}
|
|
175
|
-
/** Sleep for `milliseconds` using GAS `Utilities.sleep` or busy-wait fallback. */
|
|
176
|
-
function sleepMs(milliseconds) {
|
|
177
|
-
if (typeof Utilities !== "undefined" && typeof Utilities.sleep === "function") {
|
|
178
|
-
Utilities.sleep(milliseconds);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
const start = Date.now();
|
|
182
|
-
while (Date.now() - start < milliseconds) {
|
|
183
|
-
// busy wait for non-GAS fallback
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
const queryItems = [
|
|
187
|
-
{ __id: "1", name: "Apple", price: 1.5, category: "fruit" },
|
|
188
|
-
{ __id: "2", name: "Banana", price: 0.8, category: "fruit" },
|
|
189
|
-
{ __id: "3", name: "Carrot", price: 1.2, category: "vegetable" },
|
|
190
|
-
{ __id: "4", name: "Donut", price: 2.5, category: "pastry" },
|
|
191
|
-
{ __id: "5", name: "Eggplant", price: 3.0, category: "vegetable" },
|
|
192
|
-
];
|
|
193
|
-
function createBuilder() {
|
|
194
|
-
return new Query(() => [...queryItems]);
|
|
195
|
-
}
|
|
196
|
-
const queryEngineUsers = [
|
|
197
|
-
{ __id: "1", name: "Anna", age: 28, active: true, city: "Warszawa" },
|
|
198
|
-
{ __id: "2", name: "Jan", age: 35, active: true, city: "Kraków" },
|
|
199
|
-
{ __id: "3", name: "Piotr", age: 45, active: false, city: "Warszawa" },
|
|
200
|
-
{ __id: "4", name: "Maria", age: 22, active: true, city: "Gdańsk" },
|
|
201
|
-
{ __id: "5", name: "Zofia", age: 60, active: false, city: "Kraków" },
|
|
202
|
-
];
|
|
203
|
-
const runtimeSuiteHandlers = {
|
|
204
|
-
"cache.test.ts": {
|
|
205
|
-
"stores and retrieves values": () => {
|
|
206
|
-
const cache = new MemoryCache(1000);
|
|
207
|
-
cache.set("key1", "value1");
|
|
208
|
-
assertEqual(cache.get("key1"), "value1", "cache should return stored value");
|
|
209
|
-
},
|
|
210
|
-
"returns null for missing keys": () => {
|
|
211
|
-
const cache = new MemoryCache(1000);
|
|
212
|
-
assertEqual(cache.get("nonexistent"), null, "cache miss should return null");
|
|
213
|
-
},
|
|
214
|
-
"has() returns true for existing keys": () => {
|
|
215
|
-
const cache = new MemoryCache(1000);
|
|
216
|
-
cache.set("key1", 42);
|
|
217
|
-
assertTrue(cache.has("key1"), "cache should have key1");
|
|
218
|
-
assertTrue(!cache.has("nonexistent"), "cache should not have nonexistent key");
|
|
219
|
-
},
|
|
220
|
-
"delete() removes a key": () => {
|
|
221
|
-
const cache = new MemoryCache(1000);
|
|
222
|
-
cache.set("key1", "val");
|
|
223
|
-
cache.delete("key1");
|
|
224
|
-
assertEqual(cache.get("key1"), null, "deleted key should be missing");
|
|
225
|
-
},
|
|
226
|
-
"clear() removes all keys": () => {
|
|
227
|
-
const cache = new MemoryCache(1000);
|
|
228
|
-
cache.set("a", 1);
|
|
229
|
-
cache.set("b", 2);
|
|
230
|
-
cache.clear();
|
|
231
|
-
assertTrue(!cache.has("a"), "cache should not have key a after clear");
|
|
232
|
-
assertTrue(!cache.has("b"), "cache should not have key b after clear");
|
|
233
|
-
},
|
|
234
|
-
"expires entries after TTL": () => {
|
|
235
|
-
const cache = new MemoryCache(40);
|
|
236
|
-
cache.set("key1", "value1");
|
|
237
|
-
assertEqual(cache.get("key1"), "value1", "value should exist before TTL");
|
|
238
|
-
sleepMs(70);
|
|
239
|
-
assertEqual(cache.get("key1"), null, "value should expire after TTL");
|
|
240
|
-
},
|
|
241
|
-
"has() returns false after TTL expiry": () => {
|
|
242
|
-
const cache = new MemoryCache(40);
|
|
243
|
-
cache.set("ttlKey", "value");
|
|
244
|
-
assertEqual(cache.has("ttlKey"), true, "has() should return true before TTL");
|
|
245
|
-
sleepMs(70);
|
|
246
|
-
assertEqual(cache.has("ttlKey"), false, "has() should return false after TTL");
|
|
247
|
-
},
|
|
248
|
-
"allows per-key TTL override": () => {
|
|
249
|
-
const cache = new MemoryCache(1000);
|
|
250
|
-
cache.set("short", "val", 30);
|
|
251
|
-
cache.set("long", "val", 500);
|
|
252
|
-
sleepMs(60);
|
|
253
|
-
assertEqual(cache.get("short"), null, "short TTL key should expire first");
|
|
254
|
-
assertEqual(cache.get("long"), "val", "long TTL key should still exist");
|
|
255
|
-
},
|
|
256
|
-
"stores complex objects": () => {
|
|
257
|
-
const cache = new MemoryCache(1000);
|
|
258
|
-
const obj = { name: "test", items: [1, 2, 3] };
|
|
259
|
-
cache.set("obj", obj);
|
|
260
|
-
assertDeepEqual(cache.get("obj"), obj, "cache should preserve complex object");
|
|
261
|
-
},
|
|
262
|
-
"constructor throws for NaN TTL": () => {
|
|
263
|
-
let threw = false;
|
|
264
|
-
try {
|
|
265
|
-
new MemoryCache(NaN);
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
threw = true;
|
|
269
|
-
}
|
|
270
|
-
assertTrue(threw, "MemoryCache(NaN) should throw");
|
|
271
|
-
},
|
|
272
|
-
"constructor throws for negative TTL": () => {
|
|
273
|
-
let threw = false;
|
|
274
|
-
try {
|
|
275
|
-
new MemoryCache(-100);
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
threw = true;
|
|
279
|
-
}
|
|
280
|
-
assertTrue(threw, "MemoryCache(-100) should throw");
|
|
281
|
-
},
|
|
282
|
-
"set() throws for NaN per-key TTL": () => {
|
|
283
|
-
const cache = new MemoryCache(1000);
|
|
284
|
-
cache.set("key1", "value1");
|
|
285
|
-
let threw = false;
|
|
286
|
-
try {
|
|
287
|
-
cache.set("key2", "value2", NaN);
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
threw = true;
|
|
291
|
-
}
|
|
292
|
-
assertTrue(threw, "set() with NaN TTL should throw");
|
|
293
|
-
},
|
|
294
|
-
"TTL of 0 expires immediately": () => {
|
|
295
|
-
const cache = new MemoryCache(1000);
|
|
296
|
-
cache.set("instant", "gone", 0);
|
|
297
|
-
assertEqual(cache.get("instant"), null, "TTL=0 entry should be expired immediately");
|
|
298
|
-
assertTrue(!cache.has("instant"), "has() should return false for TTL=0 entry");
|
|
299
|
-
},
|
|
300
|
-
"constructor with TTL of 0 expires entries immediately": () => {
|
|
301
|
-
const instant = new MemoryCache(0);
|
|
302
|
-
instant.set("key", "value");
|
|
303
|
-
assertEqual(instant.get("key"), null, "entry with default TTL=0 should expire immediately");
|
|
304
|
-
assertTrue(!instant.has("key"), "has() should return false for default-TTL=0 entry");
|
|
305
|
-
},
|
|
306
|
-
"set() throws for negative per-key TTL": () => {
|
|
307
|
-
const cache = new MemoryCache(1000);
|
|
308
|
-
let threw = false;
|
|
309
|
-
try {
|
|
310
|
-
cache.set("k", "v", -1);
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
threw = true;
|
|
314
|
-
}
|
|
315
|
-
assertTrue(threw, "set() with negative TTL should throw");
|
|
316
|
-
},
|
|
317
|
-
"set() throws for Infinity per-key TTL": () => {
|
|
318
|
-
const cache = new MemoryCache(1000);
|
|
319
|
-
let threw = false;
|
|
320
|
-
try {
|
|
321
|
-
cache.set("k", "v", Infinity);
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
threw = true;
|
|
325
|
-
}
|
|
326
|
-
assertTrue(threw, "set() with Infinity TTL should throw");
|
|
327
|
-
},
|
|
328
|
-
},
|
|
329
|
-
"index-store.test.ts": {
|
|
330
|
-
"creates a combined index sheet": (ctx) => {
|
|
331
|
-
const adapter = ctx.state.getAdapter();
|
|
332
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
333
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
334
|
-
indexStore.createCombinedIndex(indexTable);
|
|
335
|
-
assertTrue(adapter.getSheetNames().includes(indexTable), "combined index sheet should be created");
|
|
336
|
-
},
|
|
337
|
-
"adds and looks up entries": (ctx) => {
|
|
338
|
-
const adapter = ctx.state.getAdapter();
|
|
339
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
340
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
341
|
-
indexStore.createCombinedIndex(indexTable);
|
|
342
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
343
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
344
|
-
indexStore.addToCombined(indexTable, "email", "anna@example.com", "user-002");
|
|
345
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "jan@example.com"), ["user-001"], "lookup should return matching entity id");
|
|
346
|
-
},
|
|
347
|
-
"enforces unique index": (ctx) => {
|
|
348
|
-
const adapter = ctx.state.getAdapter();
|
|
349
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
350
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
351
|
-
indexStore.createCombinedIndex(indexTable);
|
|
352
|
-
indexStore.registerIndex(indexTable, "email", true);
|
|
353
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
354
|
-
assertThrows(() => indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-002"), /Unique index violation/, "unique index should reject duplicated values for different entities");
|
|
355
|
-
},
|
|
356
|
-
"allows same entity to re-index with same value (unique)": (ctx) => {
|
|
357
|
-
const adapter = ctx.state.getAdapter();
|
|
358
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
359
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
360
|
-
indexStore.createCombinedIndex(indexTable);
|
|
361
|
-
indexStore.registerIndex(indexTable, "email", true);
|
|
362
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
363
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
364
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "jan@example.com"), ["user-001"], "same entity/value reindex should stay valid");
|
|
365
|
-
},
|
|
366
|
-
"removes entries when value is cleared in update": (ctx) => {
|
|
367
|
-
const adapter = ctx.state.getAdapter();
|
|
368
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
369
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
370
|
-
indexStore.createCombinedIndex(indexTable);
|
|
371
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
372
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
373
|
-
indexStore.updateInCombined(indexTable, "user-001", { email: "jan@example.com" }, { email: "" });
|
|
374
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "jan@example.com"), [], "removed index entry should not be found");
|
|
375
|
-
},
|
|
376
|
-
"removes all entries for an entity": (ctx) => {
|
|
377
|
-
const adapter = ctx.state.getAdapter();
|
|
378
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
379
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
380
|
-
indexStore.createCombinedIndex(indexTable);
|
|
381
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
382
|
-
indexStore.registerIndex(indexTable, "name", false);
|
|
383
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
384
|
-
indexStore.addToCombined(indexTable, "name", "Jan", "user-001");
|
|
385
|
-
indexStore.removeAllFromCombined(indexTable, "user-001");
|
|
386
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "jan@example.com"), [], "email index entries should be removed for entity");
|
|
387
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Jan"), [], "name index entries should be removed for entity");
|
|
388
|
-
},
|
|
389
|
-
"updates entries when value changes": (ctx) => {
|
|
390
|
-
const adapter = ctx.state.getAdapter();
|
|
391
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
392
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
393
|
-
indexStore.createCombinedIndex(indexTable);
|
|
394
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
395
|
-
indexStore.addToCombined(indexTable, "email", "old@example.com", "user-001");
|
|
396
|
-
indexStore.updateInCombined(indexTable, "user-001", { email: "old@example.com" }, { email: "new@example.com" });
|
|
397
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "old@example.com"), [], "old value should be removed from index");
|
|
398
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "new@example.com"), ["user-001"], "new value should be indexed");
|
|
399
|
-
},
|
|
400
|
-
"supports independent lookups per indexed field": (ctx) => {
|
|
401
|
-
const adapter = ctx.state.getAdapter();
|
|
402
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
403
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
404
|
-
indexStore.createCombinedIndex(indexTable);
|
|
405
|
-
indexStore.registerIndex(indexTable, "name", false);
|
|
406
|
-
indexStore.registerIndex(indexTable, "city", false);
|
|
407
|
-
indexStore.addToCombined(indexTable, "name", "Jan", "user-001");
|
|
408
|
-
indexStore.addToCombined(indexTable, "city", "Warszawa", "user-001");
|
|
409
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Jan"), ["user-001"], "name lookup should return matching entity");
|
|
410
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "city", "Warszawa"), ["user-001"], "city lookup should return matching entity");
|
|
411
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Warszawa"), [], "lookups must stay scoped to field");
|
|
412
|
-
},
|
|
413
|
-
"drops a combined index": (ctx) => {
|
|
414
|
-
const adapter = ctx.state.getAdapter();
|
|
415
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
416
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
417
|
-
indexStore.createCombinedIndex(indexTable);
|
|
418
|
-
indexStore.dropCombinedIndex(indexTable);
|
|
419
|
-
assertTrue(!adapter.getSheetNames().includes(indexTable), "dropped index sheet should be removed");
|
|
420
|
-
},
|
|
421
|
-
"cancelIndexBatch() discards buffered entries": (ctx) => {
|
|
422
|
-
const adapter = ctx.state.getAdapter();
|
|
423
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
424
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
425
|
-
indexStore.createCombinedIndex(indexTable);
|
|
426
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
427
|
-
indexStore.beginIndexBatch();
|
|
428
|
-
indexStore.addAllFieldsToCombined(indexTable, [{ field: "email", value: "a@e.com" }], "u-001");
|
|
429
|
-
indexStore.cancelIndexBatch();
|
|
430
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "a@e.com"), [], "cancelled batch entries should not appear in index");
|
|
431
|
-
},
|
|
432
|
-
"removeMultipleFromCombined() bulk-removes entries": (ctx) => {
|
|
433
|
-
const adapter = ctx.state.getAdapter();
|
|
434
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
435
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
436
|
-
indexStore.createCombinedIndex(indexTable);
|
|
437
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
438
|
-
indexStore.addToCombined(indexTable, "email", "a@e.com", "u-001");
|
|
439
|
-
indexStore.addToCombined(indexTable, "email", "b@e.com", "u-002");
|
|
440
|
-
indexStore.addToCombined(indexTable, "email", "c@e.com", "u-003");
|
|
441
|
-
indexStore.removeMultipleFromCombined(indexTable, ["u-001", "u-003"]);
|
|
442
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "b@e.com"), ["u-002"], "only u-002 should remain after bulk remove");
|
|
443
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "a@e.com"), [], "u-001 should be removed");
|
|
444
|
-
},
|
|
445
|
-
"removeMultipleFromCombined() no-op for empty array": (ctx) => {
|
|
446
|
-
const adapter = ctx.state.getAdapter();
|
|
447
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
448
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
449
|
-
indexStore.createCombinedIndex(indexTable);
|
|
450
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
451
|
-
indexStore.addToCombined(indexTable, "email", "a@e.com", "u-001");
|
|
452
|
-
indexStore.removeMultipleFromCombined(indexTable, []);
|
|
453
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "a@e.com"), ["u-001"], "empty remove array should leave index intact");
|
|
454
|
-
},
|
|
455
|
-
"existsCombined() checks for index sheet": (ctx) => {
|
|
456
|
-
const adapter = ctx.state.getAdapter();
|
|
457
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
458
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
459
|
-
assertTrue(!indexStore.existsCombined(indexTable), "index should not exist before create");
|
|
460
|
-
indexStore.createCombinedIndex(indexTable);
|
|
461
|
-
assertTrue(indexStore.existsCombined(indexTable), "index should exist after create");
|
|
462
|
-
},
|
|
463
|
-
"getIndexedFields() returns registered fields": (ctx) => {
|
|
464
|
-
const adapter = ctx.state.getAdapter();
|
|
465
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
466
|
-
const indexTable = `idx_${ctx.state.nextTableName("Users")}`;
|
|
467
|
-
indexStore.registerIndex(indexTable, "email", true);
|
|
468
|
-
indexStore.registerIndex(indexTable, "name", false);
|
|
469
|
-
const fields = indexStore.getIndexedFields(indexTable);
|
|
470
|
-
assertEqual(fields.length, 2, "there should be two registered indexed fields");
|
|
471
|
-
assertDeepEqual(fields.map((f) => f.field).sort(), ["email", "name"], "indexed field names should match");
|
|
472
|
-
},
|
|
473
|
-
// ─── N-gram search (Solr-like) ─────────────────
|
|
474
|
-
"searchCombined (n-gram) > finds exact token match": (ctx) => {
|
|
475
|
-
const adapter = ctx.state.getAdapter();
|
|
476
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
477
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
478
|
-
indexStore.createCombinedIndex(indexTable);
|
|
479
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
480
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
481
|
-
indexStore.addToCombined(indexTable, "model", "Mercedes-Benz C200", "car-002");
|
|
482
|
-
const ids = indexStore.searchCombined(indexTable, "model", "BMW");
|
|
483
|
-
assertDeepEqual(ids, ["car-001"], "exact token 'BMW' should match car-001");
|
|
484
|
-
},
|
|
485
|
-
"searchCombined (n-gram) > finds partial match via trigrams": (ctx) => {
|
|
486
|
-
const adapter = ctx.state.getAdapter();
|
|
487
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
488
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
489
|
-
indexStore.createCombinedIndex(indexTable);
|
|
490
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
491
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
492
|
-
const ids = indexStore.searchCombined(indexTable, "model", "320");
|
|
493
|
-
assertDeepEqual(ids, ["car-001"], "partial '320' should match via trigrams");
|
|
494
|
-
},
|
|
495
|
-
"searchCombined (n-gram) > is case insensitive": (ctx) => {
|
|
496
|
-
const adapter = ctx.state.getAdapter();
|
|
497
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
498
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
499
|
-
indexStore.createCombinedIndex(indexTable);
|
|
500
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
501
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
502
|
-
const ids = indexStore.searchCombined(indexTable, "model", "bmw");
|
|
503
|
-
assertDeepEqual(ids, ["car-001"], "case-insensitive search should work");
|
|
504
|
-
},
|
|
505
|
-
"searchCombined (n-gram) > handles multi-token query (intersection)": (ctx) => {
|
|
506
|
-
const adapter = ctx.state.getAdapter();
|
|
507
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
508
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
509
|
-
indexStore.createCombinedIndex(indexTable);
|
|
510
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
511
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
512
|
-
indexStore.addToCombined(indexTable, "model", "BMW X5", "car-002");
|
|
513
|
-
const ids = indexStore.searchCombined(indexTable, "model", "BMW 320");
|
|
514
|
-
assertDeepEqual(ids, ["car-001"], "multi-token intersection should return only car-001");
|
|
515
|
-
},
|
|
516
|
-
"searchCombined (n-gram) > returns empty for no match": (ctx) => {
|
|
517
|
-
const adapter = ctx.state.getAdapter();
|
|
518
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
519
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
520
|
-
indexStore.createCombinedIndex(indexTable);
|
|
521
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
522
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
523
|
-
const ids = indexStore.searchCombined(indexTable, "model", "Volvo");
|
|
524
|
-
assertDeepEqual(ids, [], "unmatched query should return empty array");
|
|
525
|
-
},
|
|
526
|
-
"searchCombined (n-gram) > respects limit parameter": (ctx) => {
|
|
527
|
-
const adapter = ctx.state.getAdapter();
|
|
528
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
529
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
530
|
-
indexStore.createCombinedIndex(indexTable);
|
|
531
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
532
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
533
|
-
indexStore.addToCombined(indexTable, "model", "BMW X5", "car-002");
|
|
534
|
-
const ids = indexStore.searchCombined(indexTable, "model", "BMW", 1);
|
|
535
|
-
assertEqual(ids.length, 1, "limit should restrict number of results");
|
|
536
|
-
},
|
|
537
|
-
"searchCombined (n-gram) > finds match through normalized separators": (ctx) => {
|
|
538
|
-
const adapter = ctx.state.getAdapter();
|
|
539
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
540
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
541
|
-
indexStore.createCombinedIndex(indexTable);
|
|
542
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
543
|
-
indexStore.addToCombined(indexTable, "model", "Mercedes-Benz C200", "car-001");
|
|
544
|
-
const ids = indexStore.searchCombined(indexTable, "model", "Mercedes Benz");
|
|
545
|
-
assertDeepEqual(ids, ["car-001"], "dash-normalized query should match");
|
|
546
|
-
},
|
|
547
|
-
"searchCombined (n-gram) > invalidates search cache after flushIndexBatch": (ctx) => {
|
|
548
|
-
const adapter = ctx.state.getAdapter();
|
|
549
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
550
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
551
|
-
indexStore.createCombinedIndex(indexTable);
|
|
552
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
553
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
554
|
-
assertDeepEqual(indexStore.searchCombined(indexTable, "model", "Civic"), [], "no Civic yet");
|
|
555
|
-
indexStore.beginIndexBatch();
|
|
556
|
-
indexStore.addAllFieldsToCombined(indexTable, [{ field: "model", value: "Honda Civic" }], "car-002");
|
|
557
|
-
indexStore.flushIndexBatch();
|
|
558
|
-
const ids = indexStore.searchCombined(indexTable, "model", "Civic");
|
|
559
|
-
assertDeepEqual(ids, ["car-002"], "search should find entry added via batch after flush");
|
|
560
|
-
},
|
|
561
|
-
"searchCombined (n-gram) > invalidates search index cache on data change": (ctx) => {
|
|
562
|
-
const adapter = ctx.state.getAdapter();
|
|
563
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
564
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
565
|
-
indexStore.createCombinedIndex(indexTable);
|
|
566
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
567
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
568
|
-
assertDeepEqual(indexStore.searchCombined(indexTable, "model", "Volvo"), [], "no Volvo yet");
|
|
569
|
-
indexStore.addToCombined(indexTable, "model", "Volvo S60", "car-002");
|
|
570
|
-
const ids = indexStore.searchCombined(indexTable, "model", "Volvo");
|
|
571
|
-
assertDeepEqual(ids, ["car-002"], "search should pick up newly added entry");
|
|
572
|
-
},
|
|
573
|
-
"searchCombined (n-gram) > returns empty for empty query": (ctx) => {
|
|
574
|
-
const adapter = ctx.state.getAdapter();
|
|
575
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
576
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
577
|
-
indexStore.createCombinedIndex(indexTable);
|
|
578
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
579
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
580
|
-
assertDeepEqual(indexStore.searchCombined(indexTable, "model", ""), [], "empty query should return []");
|
|
581
|
-
},
|
|
582
|
-
"searchCombined (n-gram) > finds substring within a token via trigrams": (ctx) => {
|
|
583
|
-
const adapter = ctx.state.getAdapter();
|
|
584
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
585
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
586
|
-
indexStore.createCombinedIndex(indexTable);
|
|
587
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
588
|
-
indexStore.addToCombined(indexTable, "model", "Toyota Corolla", "car-001");
|
|
589
|
-
const ids = indexStore.searchCombined(indexTable, "model", "Corol");
|
|
590
|
-
assertDeepEqual(ids, ["car-001"], "substring 'Corol' should match via trigrams");
|
|
591
|
-
},
|
|
592
|
-
"normalizeForSearch > lowercases and trims": () => {
|
|
593
|
-
assertEqual(IndexStore.normalizeForSearch(" BMW 320i "), "bmw 320i", "should lowercase and trim");
|
|
594
|
-
},
|
|
595
|
-
"normalizeForSearch > normalizes dashes to spaces": () => {
|
|
596
|
-
assertEqual(IndexStore.normalizeForSearch("Mercedes-Benz"), "mercedes benz", "dash should become space");
|
|
597
|
-
},
|
|
598
|
-
"normalizeForSearch > collapses whitespace": () => {
|
|
599
|
-
assertEqual(IndexStore.normalizeForSearch("a b c"), "a b c", "multiple spaces should collapse");
|
|
600
|
-
},
|
|
601
|
-
"normalizeForSearch > returns empty for null-ish input": () => {
|
|
602
|
-
assertEqual(IndexStore.normalizeForSearch(""), "", "empty string should return empty");
|
|
603
|
-
},
|
|
604
|
-
"ngrams > generates trigrams": () => {
|
|
605
|
-
const ngs = IndexStore.ngrams("abcde", 3);
|
|
606
|
-
assertTrue(ngs.size === 3, "should have 3 trigrams");
|
|
607
|
-
assertTrue(ngs.has("abc"), "should contain abc");
|
|
608
|
-
assertTrue(ngs.has("bcd"), "should contain bcd");
|
|
609
|
-
assertTrue(ngs.has("cde"), "should contain cde");
|
|
610
|
-
},
|
|
611
|
-
"ngrams > returns empty for short input": () => {
|
|
612
|
-
const ngs = IndexStore.ngrams("ab", 3);
|
|
613
|
-
assertEqual(ngs.size, 0, "input shorter than n should produce empty set");
|
|
614
|
-
},
|
|
615
|
-
"ngrams > strips whitespace before generating": () => {
|
|
616
|
-
const ngs = IndexStore.ngrams("a b c d e", 3);
|
|
617
|
-
assertTrue(ngs.size === 3, "whitespace should be stripped first");
|
|
618
|
-
assertTrue(ngs.has("abc"), "should contain abc after stripping spaces");
|
|
619
|
-
},
|
|
620
|
-
"searchCombined (n-gram) > returns empty array for limit=0": (ctx) => {
|
|
621
|
-
const adapter = ctx.state.getAdapter();
|
|
622
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
623
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
624
|
-
indexStore.createCombinedIndex(indexTable);
|
|
625
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
626
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
627
|
-
const ids = indexStore.searchCombined(indexTable, "model", "BMW", 0);
|
|
628
|
-
assertEqual(ids.length, 0, "searchCombined with limit=0 should return empty array");
|
|
629
|
-
},
|
|
630
|
-
"searchCombined (n-gram) > finds match for query shorter than trigram size": (ctx) => {
|
|
631
|
-
const adapter = ctx.state.getAdapter();
|
|
632
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
633
|
-
const indexTable = `idx_${ctx.state.nextTableName("Cars")}`;
|
|
634
|
-
indexStore.createCombinedIndex(indexTable);
|
|
635
|
-
indexStore.registerIndex(indexTable, "model", false);
|
|
636
|
-
indexStore.addToCombined(indexTable, "model", "BMW 320i", "car-001");
|
|
637
|
-
const ids = indexStore.searchCombined(indexTable, "model", "bm");
|
|
638
|
-
assertTrue(ids.length > 0, "2-char query should find match via substring fallback");
|
|
639
|
-
assertTrue(ids.includes("car-001"), "should contain car-001");
|
|
640
|
-
},
|
|
641
|
-
"lookupCombined returns empty for non-existent index table": (ctx) => {
|
|
642
|
-
const adapter = ctx.state.getAdapter();
|
|
643
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
644
|
-
const ids = indexStore.lookupCombined("idx_NonExistent", "field", "value");
|
|
645
|
-
assertDeepEqual(ids, [], "lookupCombined should return empty for non-existent table");
|
|
646
|
-
},
|
|
647
|
-
"updateInCombined throws unique violation when value conflicts with another entity": (ctx) => {
|
|
648
|
-
const adapter = ctx.state.getAdapter();
|
|
649
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
650
|
-
const indexTable = ctx.state.nextTableName("idx_upd_uniq");
|
|
651
|
-
indexStore.createCombinedIndex(indexTable);
|
|
652
|
-
indexStore.registerIndex(indexTable, "email", true);
|
|
653
|
-
indexStore.addToCombined(indexTable, "email", "jan@example.com", "user-001");
|
|
654
|
-
indexStore.addToCombined(indexTable, "email", "anna@example.com", "user-002");
|
|
655
|
-
assertThrows(() => indexStore.updateInCombined(indexTable, "user-002", { email: "anna@example.com" }, { email: "jan@example.com" }), /Unique index violation/, "updateInCombined should throw unique violation");
|
|
656
|
-
},
|
|
657
|
-
"addAllFieldsToCombined detects unique violation in pending batch entries": (ctx) => {
|
|
658
|
-
const adapter = ctx.state.getAdapter();
|
|
659
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
660
|
-
const indexTable = ctx.state.nextTableName("idx_batch_uniq");
|
|
661
|
-
indexStore.createCombinedIndex(indexTable);
|
|
662
|
-
indexStore.registerIndex(indexTable, "email", true);
|
|
663
|
-
indexStore.beginIndexBatch();
|
|
664
|
-
indexStore.addAllFieldsToCombined(indexTable, [{ field: "email", value: "dup@example.com" }], "user-001");
|
|
665
|
-
assertThrows(() => indexStore.addAllFieldsToCombined(indexTable, [{ field: "email", value: "dup@example.com" }], "user-002"), /Unique index violation/, "addAllFieldsToCombined should detect unique violation in pending batch");
|
|
666
|
-
indexStore.cancelIndexBatch();
|
|
667
|
-
},
|
|
668
|
-
"operates correctly without cache provider": (ctx) => {
|
|
669
|
-
const adapter = ctx.state.getAdapter();
|
|
670
|
-
const indexStore = new IndexStore(adapter);
|
|
671
|
-
const indexTable = ctx.state.nextTableName("idx_nocache");
|
|
672
|
-
indexStore.createCombinedIndex(indexTable);
|
|
673
|
-
indexStore.registerIndex(indexTable, "name", false);
|
|
674
|
-
indexStore.addToCombined(indexTable, "name", "Alice", "e-001");
|
|
675
|
-
indexStore.addToCombined(indexTable, "name", "Bob", "e-002");
|
|
676
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Alice"), ["e-001"], "lookup should work without cache");
|
|
677
|
-
indexStore.updateInCombined(indexTable, "e-001", { name: "Alice" }, { name: "Alicia" });
|
|
678
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Alicia"), ["e-001"], "update should work without cache");
|
|
679
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Alice"), [], "old value should be gone after update without cache");
|
|
680
|
-
indexStore.removeAllFromCombined(indexTable, "e-002");
|
|
681
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "name", "Bob"), [], "remove should work without cache");
|
|
682
|
-
},
|
|
683
|
-
"removeMultipleFromCombined is no-op for non-existent entity IDs": (ctx) => {
|
|
684
|
-
const adapter = ctx.state.getAdapter();
|
|
685
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
686
|
-
const indexTable = ctx.state.nextTableName("idx_rm_noexist");
|
|
687
|
-
indexStore.createCombinedIndex(indexTable);
|
|
688
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
689
|
-
indexStore.addToCombined(indexTable, "email", "a@example.com", "user-001");
|
|
690
|
-
indexStore.removeMultipleFromCombined(indexTable, ["user-999", "user-888"]);
|
|
691
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "a@example.com"), ["user-001"], "existing entry should remain after removing non-existent IDs");
|
|
692
|
-
},
|
|
693
|
-
"updateInCombined creates entry when field goes from empty to populated": (ctx) => {
|
|
694
|
-
const adapter = ctx.state.getAdapter();
|
|
695
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
696
|
-
const indexTable = ctx.state.nextTableName("idx_upd_empty");
|
|
697
|
-
indexStore.createCombinedIndex(indexTable);
|
|
698
|
-
indexStore.registerIndex(indexTable, "email", false);
|
|
699
|
-
indexStore.updateInCombined(indexTable, "user-001", { email: "" }, { email: "new@example.com" });
|
|
700
|
-
assertDeepEqual(indexStore.lookupCombined(indexTable, "email", "new@example.com"), ["user-001"], "entry should be created when going from empty to populated");
|
|
701
|
-
},
|
|
702
|
-
"normalizeForSearch > normalizes em-dashes and underscores to spaces": () => {
|
|
703
|
-
assertEqual(IndexStore.normalizeForSearch("hello\u2014world"), "hello world", "em-dash should be normalized to space");
|
|
704
|
-
assertEqual(IndexStore.normalizeForSearch("snake_case"), "snake case", "underscore should be normalized to space");
|
|
705
|
-
assertEqual(IndexStore.normalizeForSearch("a\u2013b"), "a b", "en-dash should be normalized to space");
|
|
706
|
-
},
|
|
707
|
-
"lookupCombined deduplicates entityIds when same entity indexed twice": (ctx) => {
|
|
708
|
-
const adapter = ctx.state.getAdapter();
|
|
709
|
-
const indexTableName = ctx.state.nextTableName("idx_DedupLookup");
|
|
710
|
-
const store = new IndexStore(adapter, new MemoryCache());
|
|
711
|
-
store.createCombinedIndex(indexTableName);
|
|
712
|
-
store.registerIndex(indexTableName, "email", false);
|
|
713
|
-
store.addToCombined(indexTableName, "email", "dup@example.com", "e-001");
|
|
714
|
-
// Non-unique: second call creates a duplicate row for the same entity
|
|
715
|
-
store.addToCombined(indexTableName, "email", "dup@example.com", "e-001");
|
|
716
|
-
const ids = store.lookupCombined(indexTableName, "email", "dup@example.com");
|
|
717
|
-
assertEqual(ids.length, 1, "lookupCombined should deduplicate repeated entityIds");
|
|
718
|
-
assertEqual(ids[0], "e-001", "deduplicated result should be e-001");
|
|
719
|
-
},
|
|
720
|
-
"searchCombined deduplicates entityIds from repeated index entries": (ctx) => {
|
|
721
|
-
const adapter = ctx.state.getAdapter();
|
|
722
|
-
const indexTableName = ctx.state.nextTableName("idx_DedupSearch");
|
|
723
|
-
const store = new IndexStore(adapter, new MemoryCache());
|
|
724
|
-
store.createCombinedIndex(indexTableName);
|
|
725
|
-
store.registerIndex(indexTableName, "model", false);
|
|
726
|
-
store.addToCombined(indexTableName, "model", "BMW 320i", "car-001");
|
|
727
|
-
// Duplicate row for same entity
|
|
728
|
-
store.addToCombined(indexTableName, "model", "BMW 320i", "car-001");
|
|
729
|
-
const ids = store.searchCombined(indexTableName, "model", "BMW");
|
|
730
|
-
assertEqual(ids.length, 1, "searchCombined should deduplicate repeated entityIds");
|
|
731
|
-
assertEqual(ids[0], "car-001", "deduplicated result should be car-001");
|
|
732
|
-
},
|
|
733
|
-
"clearAllCaches() allows the search cache to be rebuilt": (ctx) => {
|
|
734
|
-
const adapter = ctx.state.getAdapter();
|
|
735
|
-
const indexTableName = ctx.state.nextTableName("idx_ClearCaches");
|
|
736
|
-
const store = new IndexStore(adapter, new MemoryCache());
|
|
737
|
-
store.createCombinedIndex(indexTableName);
|
|
738
|
-
store.registerIndex(indexTableName, "name", false);
|
|
739
|
-
store.addToCombined(indexTableName, "name", "Alice", "user-001");
|
|
740
|
-
// Populate the search index cache
|
|
741
|
-
const before = store.searchCombined(indexTableName, "name", "Alice");
|
|
742
|
-
assertDeepEqual(before, ["user-001"], "search should find entry before cache clear");
|
|
743
|
-
// Clear all caches
|
|
744
|
-
store.clearAllCaches();
|
|
745
|
-
// Search should still work after cache is cleared and rebuilt from sheet
|
|
746
|
-
const after = store.searchCombined(indexTableName, "name", "Alice");
|
|
747
|
-
assertDeepEqual(after, ["user-001"], "search should still find entry after cache clear");
|
|
748
|
-
},
|
|
749
|
-
},
|
|
750
|
-
"query.test.ts": {
|
|
751
|
-
"filters with where()": () => {
|
|
752
|
-
const result = createBuilder().where("category", "=", "fruit").execute();
|
|
753
|
-
assertEqual(result.length, 2, "where should filter by category");
|
|
754
|
-
},
|
|
755
|
-
"chains multiple where() as AND": () => {
|
|
756
|
-
const result = createBuilder().where("category", "=", "fruit").and("price", ">", 1).execute();
|
|
757
|
-
assertEqual(result.length, 1, "where+and should behave as AND");
|
|
758
|
-
assertEqual(result[0].name, "Apple", "only Apple should match");
|
|
759
|
-
},
|
|
760
|
-
"sorts results": () => {
|
|
761
|
-
const result = createBuilder().orderBy("price", "desc").execute();
|
|
762
|
-
assertEqual(result[0].name, "Eggplant", "highest price should be first");
|
|
763
|
-
assertEqual(result[4].name, "Banana", "lowest price should be last");
|
|
764
|
-
},
|
|
765
|
-
"limits results": () => {
|
|
766
|
-
const result = createBuilder().orderBy("price", "asc").limit(2).execute();
|
|
767
|
-
assertEqual(result.length, 2, "limit should cut result set");
|
|
768
|
-
assertEqual(result[0].name, "Banana", "first sorted item should be Banana");
|
|
769
|
-
},
|
|
770
|
-
"applies offset": () => {
|
|
771
|
-
const result = createBuilder().orderBy("price", "asc").offset(2).limit(2).execute();
|
|
772
|
-
assertEqual(result.length, 2, "offset+limit should produce 2 items");
|
|
773
|
-
assertEqual(result[0].name, "Apple", "offset should skip first two items");
|
|
774
|
-
},
|
|
775
|
-
"limit() throws for negative number": () => {
|
|
776
|
-
let threw = false;
|
|
777
|
-
try {
|
|
778
|
-
createBuilder().limit(-1);
|
|
779
|
-
}
|
|
780
|
-
catch {
|
|
781
|
-
threw = true;
|
|
782
|
-
}
|
|
783
|
-
assertTrue(threw, "limit(-1) should throw");
|
|
784
|
-
},
|
|
785
|
-
"limit() throws for NaN": () => {
|
|
786
|
-
let threw = false;
|
|
787
|
-
try {
|
|
788
|
-
createBuilder().limit(NaN);
|
|
789
|
-
}
|
|
790
|
-
catch {
|
|
791
|
-
threw = true;
|
|
792
|
-
}
|
|
793
|
-
assertTrue(threw, "limit(NaN) should throw");
|
|
794
|
-
},
|
|
795
|
-
"offset() throws for negative number": () => {
|
|
796
|
-
let threw = false;
|
|
797
|
-
try {
|
|
798
|
-
createBuilder().offset(-1);
|
|
799
|
-
}
|
|
800
|
-
catch {
|
|
801
|
-
threw = true;
|
|
802
|
-
}
|
|
803
|
-
assertTrue(threw, "offset(-1) should throw");
|
|
804
|
-
},
|
|
805
|
-
"offset() throws for Infinity": () => {
|
|
806
|
-
let threw = false;
|
|
807
|
-
try {
|
|
808
|
-
createBuilder().offset(Infinity);
|
|
809
|
-
}
|
|
810
|
-
catch {
|
|
811
|
-
threw = true;
|
|
812
|
-
}
|
|
813
|
-
assertTrue(threw, "offset(Infinity) should throw");
|
|
814
|
-
},
|
|
815
|
-
"first() returns the first match": () => {
|
|
816
|
-
const result = createBuilder().where("category", "=", "vegetable").orderBy("price", "asc").first();
|
|
817
|
-
assertTrue(result !== null, "first should return an entity when match exists");
|
|
818
|
-
assertEqual(result?.name, "Carrot", "first vegetable by price should be Carrot");
|
|
819
|
-
},
|
|
820
|
-
"first() returns null when no match": () => {
|
|
821
|
-
const result = createBuilder().where("category", "=", "nonexistent").first();
|
|
822
|
-
assertEqual(result, null, "first should return null for empty result");
|
|
823
|
-
},
|
|
824
|
-
"first() respects offset": () => {
|
|
825
|
-
// Sorted by price asc: Banana(0.8), Carrot(1.2), Apple(1.5), Donut(2.5), Eggplant(3.0)
|
|
826
|
-
const result = createBuilder().orderBy("price", "asc").offset(2).first();
|
|
827
|
-
assertTrue(result !== null, "first with offset should return an entity");
|
|
828
|
-
assertEqual(result?.name, "Apple", "third cheapest item should be Apple");
|
|
829
|
-
},
|
|
830
|
-
"count() returns matching count": () => {
|
|
831
|
-
const count = createBuilder().where("category", "=", "fruit").count();
|
|
832
|
-
assertEqual(count, 2, "count should return number of matching rows");
|
|
833
|
-
},
|
|
834
|
-
"select() returns paginated result": () => {
|
|
835
|
-
const result = createBuilder().where("category", "=", "vegetable").select(0, 10);
|
|
836
|
-
assertEqual(result.total, 2, "pagination total should match filtered set");
|
|
837
|
-
assertEqual(result.items.length, 2, "items length should match filtered set");
|
|
838
|
-
assertTrue(!result.hasNext, "hasNext should be false for complete page");
|
|
839
|
-
},
|
|
840
|
-
"select() applies orderBy before pagination": () => {
|
|
841
|
-
const result = createBuilder().orderBy("price", "asc").select(1, 2);
|
|
842
|
-
assertEqual(result.total, 5, "total should represent full sorted set");
|
|
843
|
-
assertEqual(result.items.length, 2, "page size should respect limit");
|
|
844
|
-
assertEqual(result.items[0].name, "Carrot", "first paged item should be Carrot");
|
|
845
|
-
assertEqual(result.items[1].name, "Apple", "second paged item should be Apple");
|
|
846
|
-
assertTrue(result.hasNext, "hasNext should be true when more sorted rows remain");
|
|
847
|
-
},
|
|
848
|
-
"groupBy() groups results": () => {
|
|
849
|
-
const groups = createBuilder().groupBy("category");
|
|
850
|
-
assertEqual(groups.length, 3, "groupBy should produce 3 groups");
|
|
851
|
-
const fruit = groups.find((g) => g.key === "fruit");
|
|
852
|
-
assertTrue(Boolean(fruit), "fruit group should exist");
|
|
853
|
-
assertEqual(fruit?.count, 2, "fruit group count should be 2");
|
|
854
|
-
},
|
|
855
|
-
"build() returns query options": () => {
|
|
856
|
-
const query = createBuilder()
|
|
857
|
-
.where("name", "startsWith", "A")
|
|
858
|
-
.orderBy("price", "asc")
|
|
859
|
-
.limit(5)
|
|
860
|
-
.offset(0)
|
|
861
|
-
.build();
|
|
862
|
-
assertEqual(query.where?.length, 1, "build should include one where clause");
|
|
863
|
-
assertEqual(query.orderBy?.length, 1, "build should include one orderBy clause");
|
|
864
|
-
assertEqual(query.limit, 5, "build should preserve limit");
|
|
865
|
-
assertEqual(query.offset, 0, "build should preserve offset");
|
|
866
|
-
},
|
|
867
|
-
"build() includes offset when set alone": () => {
|
|
868
|
-
const query = createBuilder().offset(5).build();
|
|
869
|
-
assertEqual(query.offset, 5, "build should include offset when set alone");
|
|
870
|
-
},
|
|
871
|
-
"returns entities matching either condition": () => {
|
|
872
|
-
const result = createBuilder()
|
|
873
|
-
.where("category", "=", "pastry")
|
|
874
|
-
.or("category", "=", "vegetable")
|
|
875
|
-
.execute();
|
|
876
|
-
assertEqual(result.length, 3, "or should return entities matching either condition");
|
|
877
|
-
},
|
|
878
|
-
"applies AND within each OR group": () => {
|
|
879
|
-
const result = createBuilder()
|
|
880
|
-
.where("category", "=", "fruit")
|
|
881
|
-
.and("price", ">", 1)
|
|
882
|
-
.or("category", "=", "vegetable")
|
|
883
|
-
.and("price", "<", 2)
|
|
884
|
-
.execute();
|
|
885
|
-
assertEqual(result.length, 2, "AND within OR groups should produce correct results");
|
|
886
|
-
},
|
|
887
|
-
"chains multiple or() calls": () => {
|
|
888
|
-
const result = createBuilder()
|
|
889
|
-
.where("name", "=", "Apple")
|
|
890
|
-
.or("name", "=", "Banana")
|
|
891
|
-
.or("name", "=", "Donut")
|
|
892
|
-
.execute();
|
|
893
|
-
assertEqual(result.length, 3, "multiple or() calls should chain correctly");
|
|
894
|
-
},
|
|
895
|
-
"works with orderBy": () => {
|
|
896
|
-
const result = createBuilder()
|
|
897
|
-
.where("category", "=", "fruit")
|
|
898
|
-
.or("category", "=", "pastry")
|
|
899
|
-
.orderBy("price", "desc")
|
|
900
|
-
.execute();
|
|
901
|
-
assertEqual(result[0].name, "Donut", "first result should be Donut (highest price)");
|
|
902
|
-
},
|
|
903
|
-
"works with limit and offset": () => {
|
|
904
|
-
const result = createBuilder()
|
|
905
|
-
.where("category", "=", "fruit")
|
|
906
|
-
.or("category", "=", "vegetable")
|
|
907
|
-
.orderBy("price", "asc")
|
|
908
|
-
.limit(2)
|
|
909
|
-
.offset(1)
|
|
910
|
-
.execute();
|
|
911
|
-
assertEqual(result.length, 2, "limit+offset should work with or()");
|
|
912
|
-
assertEqual(result[0].name, "Carrot", "offset 1 should start at Carrot");
|
|
913
|
-
},
|
|
914
|
-
"first() returns first OR match": () => {
|
|
915
|
-
const result = createBuilder()
|
|
916
|
-
.where("category", "=", "vegetable")
|
|
917
|
-
.or("category", "=", "pastry")
|
|
918
|
-
.orderBy("price", "asc")
|
|
919
|
-
.first();
|
|
920
|
-
assertTrue(result !== null, "first() should return a match");
|
|
921
|
-
assertEqual(result?.name, "Carrot", "first OR match sorted by price should be Carrot");
|
|
922
|
-
},
|
|
923
|
-
"count() counts OR matches": () => {
|
|
924
|
-
const count = createBuilder().where("category", "=", "fruit").or("category", "=", "pastry").count();
|
|
925
|
-
assertEqual(count, 3, "count should include all OR matches");
|
|
926
|
-
},
|
|
927
|
-
"select() paginates OR results": () => {
|
|
928
|
-
const result = createBuilder()
|
|
929
|
-
.where("category", "=", "fruit")
|
|
930
|
-
.or("category", "=", "vegetable")
|
|
931
|
-
.select(0, 2);
|
|
932
|
-
assertEqual(result.total, 4, "total should count all OR matches");
|
|
933
|
-
assertEqual(result.items.length, 2, "items should be limited to page size");
|
|
934
|
-
assertTrue(result.hasNext, "hasNext should be true when more items remain");
|
|
935
|
-
},
|
|
936
|
-
"groupBy() groups OR results": () => {
|
|
937
|
-
const groups = createBuilder()
|
|
938
|
-
.where("category", "=", "fruit")
|
|
939
|
-
.or("category", "=", "vegetable")
|
|
940
|
-
.groupBy("category");
|
|
941
|
-
assertEqual(groups.length, 2, "groupBy should produce 2 groups from OR results");
|
|
942
|
-
},
|
|
943
|
-
"build() returns whereGroups for OR queries": () => {
|
|
944
|
-
const qo = createBuilder().where("category", "=", "fruit").or("name", "=", "Donut").build();
|
|
945
|
-
assertEqual(qo.where, undefined, "where should be undefined for OR queries");
|
|
946
|
-
assertEqual(qo.whereGroups?.length, 2, "whereGroups should have 2 groups");
|
|
947
|
-
},
|
|
948
|
-
"build() returns where (not whereGroups) for AND-only queries": () => {
|
|
949
|
-
const qo = createBuilder().where("category", "=", "fruit").and("price", ">", 1).build();
|
|
950
|
-
assertEqual(qo.where?.length, 2, "where should have 2 filters for AND-only");
|
|
951
|
-
assertEqual(qo.whereGroups, undefined, "whereGroups should be undefined for AND-only");
|
|
952
|
-
},
|
|
953
|
-
"or() without preceding where() still filters correctly": () => {
|
|
954
|
-
const result = createBuilder().or("category", "=", "pastry").execute();
|
|
955
|
-
assertEqual(result.length, 1, "or() without where() should match 1 item");
|
|
956
|
-
assertEqual(result[0].name, "Donut", "matched item should be Donut");
|
|
957
|
-
},
|
|
958
|
-
"or().and() without preceding where() chains correctly": () => {
|
|
959
|
-
const result = createBuilder()
|
|
960
|
-
.or("category", "=", "pastry")
|
|
961
|
-
.or("category", "=", "fruit")
|
|
962
|
-
.and("price", ">", 1)
|
|
963
|
-
.execute();
|
|
964
|
-
assertEqual(result.length, 2, "or().and() should match 2 items");
|
|
965
|
-
const names = result.map((r) => r.name).sort();
|
|
966
|
-
assertDeepEqual(names, ["Apple", "Donut"], "matched items should be Apple and Donut");
|
|
967
|
-
},
|
|
968
|
-
"execute() with limit(0) returns an empty array": () => {
|
|
969
|
-
const result = createBuilder().limit(0).execute();
|
|
970
|
-
assertEqual(result.length, 0, "limit(0) should return empty array");
|
|
971
|
-
},
|
|
972
|
-
"first() with limit(0) returns null": () => {
|
|
973
|
-
const result = createBuilder().orderBy("price", "asc").limit(0).first();
|
|
974
|
-
assertEqual(result, null, "first() with limit(0) should return null");
|
|
975
|
-
},
|
|
976
|
-
"build() with limit(0) includes limit 0": () => {
|
|
977
|
-
const opts = createBuilder().limit(0).build();
|
|
978
|
-
assertEqual(opts.limit, 0, "build should include limit 0");
|
|
979
|
-
},
|
|
980
|
-
"build() with no filters returns all undefined options": () => {
|
|
981
|
-
const opts = createBuilder().build();
|
|
982
|
-
assertEqual(opts.where, undefined, "where should be undefined");
|
|
983
|
-
assertEqual(opts.whereGroups, undefined, "whereGroups should be undefined");
|
|
984
|
-
assertEqual(opts.orderBy, undefined, "orderBy should be undefined");
|
|
985
|
-
assertEqual(opts.limit, undefined, "limit should be undefined");
|
|
986
|
-
assertEqual(opts.offset, undefined, "offset should be undefined");
|
|
987
|
-
},
|
|
988
|
-
"first() returns null when offset exceeds result count": () => {
|
|
989
|
-
const result = createBuilder().where("category", "=", "fruit").offset(100).first();
|
|
990
|
-
assertEqual(result, null, "first() should return null when offset exceeds result count");
|
|
991
|
-
},
|
|
992
|
-
"groupBy() respects orderBy before grouping": () => {
|
|
993
|
-
const groups = createBuilder().orderBy("price", "desc").groupBy("category");
|
|
994
|
-
assertTrue(groups.length >= 2, "groupBy with orderBy should return at least 2 groups");
|
|
995
|
-
const fruitGroup = groups.find((g) => g.key === "fruit");
|
|
996
|
-
assertTrue(fruitGroup !== undefined, "fruit group should exist");
|
|
997
|
-
assertEqual(fruitGroup.items.length, 2, "fruit group should have 2 items");
|
|
998
|
-
},
|
|
999
|
-
"execute() with orderBy and offset combined returns correct slice": () => {
|
|
1000
|
-
const result = createBuilder().orderBy("price", "asc").offset(2).limit(2).execute();
|
|
1001
|
-
assertEqual(result.length, 2, "should return 2 items with offset 2 limit 2");
|
|
1002
|
-
assertEqual(result[0].name, "Apple", "3rd item by price asc should be Apple");
|
|
1003
|
-
assertEqual(result[1].name, "Donut", "4th item by price asc should be Donut");
|
|
1004
|
-
},
|
|
1005
|
-
"execute() returns items from offset when no limit is set": () => {
|
|
1006
|
-
const result = createBuilder().orderBy("price", "asc").offset(2).execute();
|
|
1007
|
-
assertEqual(result.length, 3, "offset 2 with no limit should return 3 items");
|
|
1008
|
-
assertEqual(result[0].name, "Apple", "first item after offset 2 should be Apple");
|
|
1009
|
-
assertEqual(result[2].name, "Eggplant", "last item should be Eggplant");
|
|
1010
|
-
},
|
|
1011
|
-
"limit() and offset() floor fractional values": () => {
|
|
1012
|
-
const result = createBuilder().orderBy("price", "asc").limit(2.9).offset(1.7).execute();
|
|
1013
|
-
assertEqual(result.length, 2, "limit(2.9) should floor to 2");
|
|
1014
|
-
assertEqual(result[0].name, "Carrot", "offset(1.7) should floor to 1, so 2nd cheapest");
|
|
1015
|
-
},
|
|
1016
|
-
"Query.from() without resolver throws descriptive error": () => {
|
|
1017
|
-
const original = Query._fromResolverFn;
|
|
1018
|
-
try {
|
|
1019
|
-
Query._fromResolverFn = null;
|
|
1020
|
-
let threw = false;
|
|
1021
|
-
try {
|
|
1022
|
-
Query.from("NonExistent");
|
|
1023
|
-
}
|
|
1024
|
-
catch (e) {
|
|
1025
|
-
threw = true;
|
|
1026
|
-
assertTrue(e.message.includes("not available"), "error message should mention 'not available'");
|
|
1027
|
-
}
|
|
1028
|
-
assertTrue(threw, "Query.from() without resolver should throw");
|
|
1029
|
-
}
|
|
1030
|
-
finally {
|
|
1031
|
-
Query._fromResolverFn = original;
|
|
1032
|
-
}
|
|
1033
|
-
},
|
|
1034
|
-
},
|
|
1035
|
-
"query-engine.test.ts": {
|
|
1036
|
-
"filters with = operator": () => {
|
|
1037
|
-
const filters = [{ field: "city", operator: "=", value: "Kraków" }];
|
|
1038
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1039
|
-
assertEqual(result.length, 2, "equals filter should match two users");
|
|
1040
|
-
assertDeepEqual(result.map((u) => u.name), ["Jan", "Zofia"], "matching users should be Jan and Zofia");
|
|
1041
|
-
},
|
|
1042
|
-
"filters with != operator": () => {
|
|
1043
|
-
const filters = [{ field: "active", operator: "!=", value: false }];
|
|
1044
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1045
|
-
assertEqual(result.length, 3, "not-equal filter should match three users");
|
|
1046
|
-
},
|
|
1047
|
-
"filters with > operator": () => {
|
|
1048
|
-
const filters = [{ field: "age", operator: ">", value: 40 }];
|
|
1049
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1050
|
-
assertEqual(result.length, 2, "greater-than filter should match two users");
|
|
1051
|
-
},
|
|
1052
|
-
"filters with < operator": () => {
|
|
1053
|
-
const filters = [{ field: "age", operator: "<", value: 30 }];
|
|
1054
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1055
|
-
assertEqual(result.length, 2, "less-than filter should match two users");
|
|
1056
|
-
},
|
|
1057
|
-
"filters with >= and <= operators": () => {
|
|
1058
|
-
const filters = [
|
|
1059
|
-
{ field: "age", operator: ">=", value: 28 },
|
|
1060
|
-
{ field: "age", operator: "<=", value: 45 },
|
|
1061
|
-
];
|
|
1062
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1063
|
-
assertEqual(result.length, 3, "range filters should match three users");
|
|
1064
|
-
},
|
|
1065
|
-
"filters with contains operator": () => {
|
|
1066
|
-
const filters = [{ field: "name", operator: "contains", value: "an" }];
|
|
1067
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1068
|
-
assertEqual(result.length, 2, "contains filter should be case-insensitive and match two users");
|
|
1069
|
-
},
|
|
1070
|
-
"contains is case-insensitive for uppercase query": () => {
|
|
1071
|
-
const filters = [{ field: "name", operator: "contains", value: "AN" }];
|
|
1072
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1073
|
-
assertEqual(result.length, 2, "uppercase contains should match Anna and Jan");
|
|
1074
|
-
},
|
|
1075
|
-
"filters with startsWith operator": () => {
|
|
1076
|
-
const filters = [{ field: "name", operator: "startsWith", value: "A" }];
|
|
1077
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1078
|
-
assertEqual(result.length, 1, "startsWith should match one user");
|
|
1079
|
-
},
|
|
1080
|
-
"filters with in operator": () => {
|
|
1081
|
-
const filters = [{ field: "city", operator: "in", value: ["Gdańsk", "Kraków"] }];
|
|
1082
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1083
|
-
assertEqual(result.length, 3, "in operator should match users in both cities");
|
|
1084
|
-
},
|
|
1085
|
-
"filters with search operator (substring match)": () => {
|
|
1086
|
-
const filters = [{ field: "name", operator: "search", value: "an" }];
|
|
1087
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1088
|
-
assertEqual(result.length, 2, "search operator should match Anna and Jan");
|
|
1089
|
-
},
|
|
1090
|
-
"applies multiple filters as AND": () => {
|
|
1091
|
-
const filters = [
|
|
1092
|
-
{ field: "active", operator: "=", value: true },
|
|
1093
|
-
{ field: "age", operator: ">", value: 25 },
|
|
1094
|
-
];
|
|
1095
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1096
|
-
assertEqual(result.length, 2, "multiple filters should combine with AND");
|
|
1097
|
-
},
|
|
1098
|
-
"returns all when no filters": () => {
|
|
1099
|
-
assertEqual(QueryEngine.filterEntities(queryEngineUsers, []).length, 5, "empty filters should return all users");
|
|
1100
|
-
},
|
|
1101
|
-
"returns original array reference when no sort clauses": () => {
|
|
1102
|
-
const result = QueryEngine.sortEntities(queryEngineUsers, []);
|
|
1103
|
-
assertTrue(result === queryEngineUsers, "no-sort fast-path should return original array reference");
|
|
1104
|
-
},
|
|
1105
|
-
"sorts ascending by number": () => {
|
|
1106
|
-
const sorts = [{ field: "age", direction: "asc" }];
|
|
1107
|
-
const result = QueryEngine.sortEntities(queryEngineUsers, sorts);
|
|
1108
|
-
assertDeepEqual(result.map((u) => u.age), [22, 28, 35, 45, 60], "ascending numeric sort should match expected order");
|
|
1109
|
-
},
|
|
1110
|
-
"sorts descending by number": () => {
|
|
1111
|
-
const sorts = [{ field: "age", direction: "desc" }];
|
|
1112
|
-
const result = QueryEngine.sortEntities(queryEngineUsers, sorts);
|
|
1113
|
-
assertDeepEqual(result.map((u) => u.age), [60, 45, 35, 28, 22], "descending numeric sort should match expected order");
|
|
1114
|
-
},
|
|
1115
|
-
"sorts by string": () => {
|
|
1116
|
-
const sorts = [{ field: "name", direction: "asc" }];
|
|
1117
|
-
const result = QueryEngine.sortEntities(queryEngineUsers, sorts);
|
|
1118
|
-
assertDeepEqual(result.map((u) => u.name), ["Anna", "Jan", "Maria", "Piotr", "Zofia"], "string sort should match expected order");
|
|
1119
|
-
},
|
|
1120
|
-
"sorts by multiple fields": () => {
|
|
1121
|
-
const sorts = [
|
|
1122
|
-
{ field: "city", direction: "asc" },
|
|
1123
|
-
{ field: "age", direction: "desc" },
|
|
1124
|
-
];
|
|
1125
|
-
const result = QueryEngine.sortEntities(queryEngineUsers, sorts);
|
|
1126
|
-
assertDeepEqual(result.map((u) => u.name), ["Maria", "Zofia", "Jan", "Piotr", "Anna"], "multi-sort order should match expected");
|
|
1127
|
-
},
|
|
1128
|
-
"does not mutate original array": () => {
|
|
1129
|
-
const original = [...queryEngineUsers];
|
|
1130
|
-
QueryEngine.sortEntities(queryEngineUsers, [{ field: "age", direction: "asc" }]);
|
|
1131
|
-
assertDeepEqual(queryEngineUsers, original, "sortEntities should not mutate input array");
|
|
1132
|
-
},
|
|
1133
|
-
"returns first page": () => {
|
|
1134
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, 0, 2);
|
|
1135
|
-
assertEqual(result.items.length, 2, "first page should contain two items");
|
|
1136
|
-
assertEqual(result.total, 5, "total should be full collection size");
|
|
1137
|
-
assertEqual(result.offset, 0, "offset should match input");
|
|
1138
|
-
assertEqual(result.limit, 2, "limit should match input");
|
|
1139
|
-
assertTrue(result.hasNext, "first page should have next page");
|
|
1140
|
-
},
|
|
1141
|
-
"returns last page": () => {
|
|
1142
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, 4, 2);
|
|
1143
|
-
assertEqual(result.items.length, 1, "last page should contain single item");
|
|
1144
|
-
assertTrue(!result.hasNext, "last page should not have next page");
|
|
1145
|
-
},
|
|
1146
|
-
"returns empty if offset exceeds total": () => {
|
|
1147
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, 10, 2);
|
|
1148
|
-
assertEqual(result.items.length, 0, "offset beyond total should return empty page");
|
|
1149
|
-
assertTrue(!result.hasNext, "empty out-of-range page should not have next");
|
|
1150
|
-
},
|
|
1151
|
-
"groups by field": () => {
|
|
1152
|
-
const groups = QueryEngine.groupEntities(queryEngineUsers, "city");
|
|
1153
|
-
assertEqual(groups.length, 3, "city grouping should produce three groups");
|
|
1154
|
-
const waw = groups.find((g) => g.key === "Warszawa");
|
|
1155
|
-
assertEqual(waw?.count, 2, "Warszawa group should contain two users");
|
|
1156
|
-
},
|
|
1157
|
-
"groups by boolean": () => {
|
|
1158
|
-
const groups = QueryEngine.groupEntities(queryEngineUsers, "active");
|
|
1159
|
-
assertEqual(groups.length, 2, "boolean grouping should produce two groups");
|
|
1160
|
-
const active = groups.find((g) => g.key === true);
|
|
1161
|
-
assertEqual(active?.count, 3, "active=true group should contain three users");
|
|
1162
|
-
},
|
|
1163
|
-
"combines filter + sort + pagination": () => {
|
|
1164
|
-
const options = {
|
|
1165
|
-
where: [{ field: "active", operator: "=", value: true }],
|
|
1166
|
-
orderBy: [{ field: "age", direction: "asc" }],
|
|
1167
|
-
offset: 1,
|
|
1168
|
-
limit: 1,
|
|
1169
|
-
};
|
|
1170
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, options);
|
|
1171
|
-
assertEqual(result.length, 1, "combined query should return one entity");
|
|
1172
|
-
assertEqual(result[0].name, "Anna", "combined query should return Anna");
|
|
1173
|
-
},
|
|
1174
|
-
"applies only sort and limit when no filters are provided": () => {
|
|
1175
|
-
const options = {
|
|
1176
|
-
orderBy: [{ field: "age", direction: "desc" }],
|
|
1177
|
-
limit: 3,
|
|
1178
|
-
};
|
|
1179
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, options);
|
|
1180
|
-
assertEqual(result.length, 3, "query should return three entities after limit");
|
|
1181
|
-
assertDeepEqual(result.map((u) => u.name), ["Zofia", "Piotr", "Jan"], "query should apply sort and limit without filters");
|
|
1182
|
-
},
|
|
1183
|
-
"returns all entities for empty options": () => {
|
|
1184
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, {});
|
|
1185
|
-
assertEqual(result.length, queryEngineUsers.length, "empty options should return all entities");
|
|
1186
|
-
},
|
|
1187
|
-
"executeQuery applies offset without limit correctly": () => {
|
|
1188
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, { offset: 2 });
|
|
1189
|
-
assertEqual(result.length, queryEngineUsers.length - 2, "offset-only should skip first 2 items");
|
|
1190
|
-
assertEqual(result[0].name, "Piotr", "first result should be Piotr (3rd in original)");
|
|
1191
|
-
assertEqual(result[result.length - 1].name, "Zofia", "last result should be Zofia");
|
|
1192
|
-
},
|
|
1193
|
-
"normalizes non-finite offset and limit in executeQuery": () => {
|
|
1194
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, {
|
|
1195
|
-
orderBy: [{ field: "age", direction: "asc" }],
|
|
1196
|
-
offset: Number.POSITIVE_INFINITY,
|
|
1197
|
-
limit: Number.NaN,
|
|
1198
|
-
});
|
|
1199
|
-
assertEqual(result.length, queryEngineUsers.length, "non-finite pagination values should fallback to full range");
|
|
1200
|
-
assertEqual(result[0].name, "Maria", "sorted result should start with youngest user");
|
|
1201
|
-
assertEqual(result[result.length - 1].name, "Zofia", "sorted result should end with oldest user");
|
|
1202
|
-
},
|
|
1203
|
-
"matches entities passing any group": () => {
|
|
1204
|
-
const groups = [
|
|
1205
|
-
[{ field: "city", operator: "=", value: "Gdańsk" }],
|
|
1206
|
-
[{ field: "city", operator: "=", value: "Kraków" }],
|
|
1207
|
-
];
|
|
1208
|
-
const result = QueryEngine.filterEntitiesOr(queryEngineUsers, groups);
|
|
1209
|
-
assertEqual(result.length, 3, "OR groups should match entities passing any group");
|
|
1210
|
-
},
|
|
1211
|
-
"applies AND within each group": () => {
|
|
1212
|
-
const groups = [
|
|
1213
|
-
[
|
|
1214
|
-
{ field: "city", operator: "=", value: "Kraków" },
|
|
1215
|
-
{ field: "active", operator: "=", value: true },
|
|
1216
|
-
],
|
|
1217
|
-
[
|
|
1218
|
-
{ field: "city", operator: "=", value: "Warszawa" },
|
|
1219
|
-
{ field: "active", operator: "=", value: false },
|
|
1220
|
-
],
|
|
1221
|
-
];
|
|
1222
|
-
const result = QueryEngine.filterEntitiesOr(queryEngineUsers, groups);
|
|
1223
|
-
assertEqual(result.length, 2, "AND within each OR group should narrow correctly");
|
|
1224
|
-
},
|
|
1225
|
-
"returns all entities for empty groups": () => {
|
|
1226
|
-
const result = QueryEngine.filterEntitiesOr(queryEngineUsers, []);
|
|
1227
|
-
assertEqual(result.length, 5, "empty groups should return all entities");
|
|
1228
|
-
},
|
|
1229
|
-
"empty group within OR groups matches nothing": () => {
|
|
1230
|
-
const groups = [
|
|
1231
|
-
[], // empty group — contributes no matches
|
|
1232
|
-
[{ field: "city", operator: "=", value: "Gdańsk" }],
|
|
1233
|
-
];
|
|
1234
|
-
const result = QueryEngine.filterEntitiesOr(queryEngineUsers, groups);
|
|
1235
|
-
assertEqual(result.length, 1, "empty group should not pass any entity; only Gdańsk entity matches");
|
|
1236
|
-
assertEqual(result[0].name, "Maria", "only Maria from Gdańsk should match");
|
|
1237
|
-
},
|
|
1238
|
-
"uses OR groups when whereGroups is provided": () => {
|
|
1239
|
-
const options = {
|
|
1240
|
-
whereGroups: [
|
|
1241
|
-
[{ field: "city", operator: "=", value: "Gdańsk" }],
|
|
1242
|
-
[{ field: "name", operator: "=", value: "Anna" }],
|
|
1243
|
-
],
|
|
1244
|
-
orderBy: [{ field: "age", direction: "asc" }],
|
|
1245
|
-
};
|
|
1246
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, options);
|
|
1247
|
-
assertEqual(result.length, 2, "executeQuery should use whereGroups");
|
|
1248
|
-
assertEqual(result[0].name, "Maria", "first sorted result should be Maria");
|
|
1249
|
-
},
|
|
1250
|
-
"prefers whereGroups over where": () => {
|
|
1251
|
-
const options = {
|
|
1252
|
-
where: [{ field: "active", operator: "=", value: true }],
|
|
1253
|
-
whereGroups: [[{ field: "city", operator: "=", value: "Gdańsk" }]],
|
|
1254
|
-
};
|
|
1255
|
-
const result = QueryEngine.executeQuery(queryEngineUsers, options);
|
|
1256
|
-
assertEqual(result.length, 1, "whereGroups should take precedence over where");
|
|
1257
|
-
assertEqual(result[0].name, "Maria", "only Maria should match Gdańsk group");
|
|
1258
|
-
},
|
|
1259
|
-
"contains with non-string value matches nothing": () => {
|
|
1260
|
-
const filters = [{ field: "name", operator: "contains", value: 123 }];
|
|
1261
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1262
|
-
assertEqual(result.length, 0, "contains with non-string value should match nothing");
|
|
1263
|
-
},
|
|
1264
|
-
"startsWith with non-string value matches nothing": () => {
|
|
1265
|
-
const filters = [{ field: "name", operator: "startsWith", value: null }];
|
|
1266
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1267
|
-
assertEqual(result.length, 0, "startsWith with non-string value should match nothing");
|
|
1268
|
-
},
|
|
1269
|
-
"search with non-string value matches nothing": () => {
|
|
1270
|
-
const filters = [{ field: "name", operator: "search", value: undefined }];
|
|
1271
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1272
|
-
assertEqual(result.length, 0, "search with non-string value should match nothing");
|
|
1273
|
-
},
|
|
1274
|
-
"negative offset is clamped to 0": () => {
|
|
1275
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, -5, 2);
|
|
1276
|
-
assertEqual(result.offset, 0, "negative offset should be clamped to 0");
|
|
1277
|
-
assertEqual(result.items.length, 2, "should still return requested items");
|
|
1278
|
-
},
|
|
1279
|
-
"negative limit defaults to full length": () => {
|
|
1280
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, 0, -1);
|
|
1281
|
-
assertEqual(result.limit, queryEngineUsers.length, "negative limit should default to total length");
|
|
1282
|
-
assertEqual(result.items.length, queryEngineUsers.length, "should return all items");
|
|
1283
|
-
},
|
|
1284
|
-
"NaN offset defaults to 0": () => {
|
|
1285
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, NaN, 2);
|
|
1286
|
-
assertEqual(result.offset, 0, "NaN offset should default to 0");
|
|
1287
|
-
assertEqual(result.items.length, 2, "should still return requested items");
|
|
1288
|
-
},
|
|
1289
|
-
"returns no matches for an unrecognized operator": () => {
|
|
1290
|
-
const filters = [
|
|
1291
|
-
{ field: "name", operator: "regex", value: ".*" },
|
|
1292
|
-
];
|
|
1293
|
-
assertEqual(QueryEngine.filterEntities(queryEngineUsers, filters).length, 0, "unknown operator should match nothing");
|
|
1294
|
-
},
|
|
1295
|
-
"returns false when field type differs from value type (number vs string)": () => {
|
|
1296
|
-
const filters = [{ field: "age", operator: ">", value: "thirty" }];
|
|
1297
|
-
assertEqual(QueryEngine.filterEntities(queryEngineUsers, filters).length, 0, "type mismatch should match nothing");
|
|
1298
|
-
},
|
|
1299
|
-
"compares strings when both field and value are strings": () => {
|
|
1300
|
-
const filters = [{ field: "name", operator: "<", value: "Jan" }];
|
|
1301
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1302
|
-
assertDeepEqual(result.map((u) => u.name), ["Anna"], "should match Anna (Anna < Jan)");
|
|
1303
|
-
},
|
|
1304
|
-
"filters by nested dot-path field": () => {
|
|
1305
|
-
const nested = [
|
|
1306
|
-
{ __id: "1", name: "Anna", profile: { city: "Warszawa", score: 80 } },
|
|
1307
|
-
{ __id: "2", name: "Jan", profile: { city: "Kraków", score: 95 } },
|
|
1308
|
-
{ __id: "3", name: "Piotr", profile: { city: "Warszawa", score: 60 } },
|
|
1309
|
-
];
|
|
1310
|
-
const filters = [{ field: "profile.city", operator: "=", value: "Warszawa" }];
|
|
1311
|
-
const result = QueryEngine.filterEntities(nested, filters);
|
|
1312
|
-
assertEqual(result.length, 2, "should match 2 users in Warszawa via dot path");
|
|
1313
|
-
},
|
|
1314
|
-
"filters by nested slash-path field": () => {
|
|
1315
|
-
const nested = [
|
|
1316
|
-
{ __id: "1", name: "Anna", profile: { city: "Warszawa" } },
|
|
1317
|
-
{ __id: "2", name: "Jan", profile: { city: "Kraków" } },
|
|
1318
|
-
];
|
|
1319
|
-
const filters = [{ field: "profile/city", operator: "=", value: "Kraków" }];
|
|
1320
|
-
const result = QueryEngine.filterEntities(nested, filters);
|
|
1321
|
-
assertEqual(result.length, 1, "should match via slash path");
|
|
1322
|
-
assertEqual(result[0].name, "Jan", "should find Jan via slash path");
|
|
1323
|
-
},
|
|
1324
|
-
"sorts by nested field": () => {
|
|
1325
|
-
const nested = [
|
|
1326
|
-
{ __id: "1", name: "Anna", profile: { city: "W", score: 80 } },
|
|
1327
|
-
{ __id: "2", name: "Jan", profile: { city: "K", score: 95 } },
|
|
1328
|
-
{ __id: "3", name: "Piotr", profile: { city: "W", score: 60 } },
|
|
1329
|
-
];
|
|
1330
|
-
const sorts = [{ field: "profile.score", direction: "asc" }];
|
|
1331
|
-
const result = QueryEngine.sortEntities(nested, sorts);
|
|
1332
|
-
assertDeepEqual(result.map((u) => u.name), ["Piotr", "Anna", "Jan"], "should sort by nested score asc");
|
|
1333
|
-
},
|
|
1334
|
-
"returns undefined for missing nested segment": () => {
|
|
1335
|
-
const mixed = [
|
|
1336
|
-
{ __id: "1", name: "A" },
|
|
1337
|
-
{ __id: "2", name: "B", profile: { city: "X" } },
|
|
1338
|
-
];
|
|
1339
|
-
const filters = [{ field: "profile.city", operator: "=", value: "X" }];
|
|
1340
|
-
const result = QueryEngine.filterEntities(mixed, filters);
|
|
1341
|
-
assertEqual(result.length, 1, "should match only entity with nested field");
|
|
1342
|
-
assertEqual(result[0].__id, "2", "should find entity with profile.city = X");
|
|
1343
|
-
},
|
|
1344
|
-
"uses Set for arrays with more than 8 elements": () => {
|
|
1345
|
-
const manyValues = [
|
|
1346
|
-
"Warszawa",
|
|
1347
|
-
"Kraków",
|
|
1348
|
-
"Gdańsk",
|
|
1349
|
-
"Wrocław",
|
|
1350
|
-
"Poznań",
|
|
1351
|
-
"Łódź",
|
|
1352
|
-
"Katowice",
|
|
1353
|
-
"Szczecin",
|
|
1354
|
-
"Lublin",
|
|
1355
|
-
];
|
|
1356
|
-
const filters = [{ field: "city", operator: "in", value: manyValues }];
|
|
1357
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1358
|
-
assertEqual(result.length, 5, "should match 5 users from cities in the large set");
|
|
1359
|
-
},
|
|
1360
|
-
"returns empty items when limit is 0": () => {
|
|
1361
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, 0, 0);
|
|
1362
|
-
assertEqual(result.items.length, 0, "limit 0 should return empty items");
|
|
1363
|
-
assertEqual(result.total, queryEngineUsers.length, "total should equal full collection size");
|
|
1364
|
-
assertEqual(result.limit, 0, "limit should be 0");
|
|
1365
|
-
assertTrue(result.hasNext, "hasNext should be true when total > 0");
|
|
1366
|
-
},
|
|
1367
|
-
"places null values before non-null in ascending order": () => {
|
|
1368
|
-
const data = [
|
|
1369
|
-
{ __id: "1", name: "Anna", score: 80 },
|
|
1370
|
-
{ __id: "2", name: "Jan", score: null },
|
|
1371
|
-
{ __id: "3", name: "Piotr", score: 60 },
|
|
1372
|
-
];
|
|
1373
|
-
const sorted = QueryEngine.sortEntities(data, [{ field: "score", direction: "asc" }]);
|
|
1374
|
-
assertEqual(sorted[0].__id, "2", "null should sort first in ascending order");
|
|
1375
|
-
assertEqual(sorted[1].__id, "3", "60 should be second");
|
|
1376
|
-
assertEqual(sorted[2].__id, "1", "80 should be third");
|
|
1377
|
-
},
|
|
1378
|
-
"places null values last in descending sort": () => {
|
|
1379
|
-
const data = [
|
|
1380
|
-
{ __id: "1", name: "Anna", score: 80 },
|
|
1381
|
-
{ __id: "2", name: "Jan", score: null },
|
|
1382
|
-
{ __id: "3", name: "Piotr", score: 60 },
|
|
1383
|
-
];
|
|
1384
|
-
const sorted = QueryEngine.sortEntities(data, [{ field: "score", direction: "desc" }]);
|
|
1385
|
-
assertEqual(sorted[0].__id, "1", "80 should sort first in descending order");
|
|
1386
|
-
assertEqual(sorted[1].__id, "3", "60 should be second");
|
|
1387
|
-
assertEqual(sorted[2].__id, "2", "null should sort last in descending order");
|
|
1388
|
-
},
|
|
1389
|
-
"groups entities including those with undefined keys": () => {
|
|
1390
|
-
const data = [
|
|
1391
|
-
{ __id: "1", name: "Anna", city: "Warszawa" },
|
|
1392
|
-
{ __id: "2", name: "Jan" },
|
|
1393
|
-
{ __id: "3", name: "Piotr", city: "Warszawa" },
|
|
1394
|
-
];
|
|
1395
|
-
const groups = QueryEngine.groupEntities(data, "city");
|
|
1396
|
-
assertEqual(groups.length, 2, "should produce 2 groups");
|
|
1397
|
-
const warszawaGroup = groups.find((g) => g.key === "Warszawa");
|
|
1398
|
-
const undefinedGroup = groups.find((g) => g.key === undefined);
|
|
1399
|
-
assertEqual(warszawaGroup.count, 2, "Warszawa group should have 2 members");
|
|
1400
|
-
assertEqual(undefinedGroup.count, 1, "undefined group should have 1 member");
|
|
1401
|
-
},
|
|
1402
|
-
"sortEntities returns empty array for empty input": () => {
|
|
1403
|
-
const result = QueryEngine.sortEntities([], [{ field: "name", direction: "asc" }]);
|
|
1404
|
-
assertEqual(result.length, 0, "sorting empty array should return empty array");
|
|
1405
|
-
},
|
|
1406
|
-
"groupEntities returns empty array for empty input": () => {
|
|
1407
|
-
const result = QueryEngine.groupEntities([], "name");
|
|
1408
|
-
assertEqual(result.length, 0, "grouping empty array should return empty array");
|
|
1409
|
-
},
|
|
1410
|
-
"filterEntities with empty entity list returns empty": () => {
|
|
1411
|
-
const filters = [{ field: "name", operator: "=", value: "Anna" }];
|
|
1412
|
-
const result = QueryEngine.filterEntities([], filters);
|
|
1413
|
-
assertEqual(result.length, 0, "filtering empty array should return empty array");
|
|
1414
|
-
},
|
|
1415
|
-
"paginateEntities with Infinity limit defaults to full length": () => {
|
|
1416
|
-
const result = QueryEngine.paginateEntities(queryEngineUsers, 0, Infinity);
|
|
1417
|
-
assertEqual(result.items.length, queryEngineUsers.length, "Infinity limit should return all items");
|
|
1418
|
-
assertEqual(result.total, queryEngineUsers.length, "total should equal full collection size");
|
|
1419
|
-
assertEqual(result.limit, queryEngineUsers.length, "limit should default to full length");
|
|
1420
|
-
assertTrue(!result.hasNext, "hasNext should be false when all items returned");
|
|
1421
|
-
},
|
|
1422
|
-
"sortEntities treats both-null values as equal": () => {
|
|
1423
|
-
const data = [
|
|
1424
|
-
{ __id: "1", name: null, age: 10 },
|
|
1425
|
-
{ __id: "2", name: null, age: 20 },
|
|
1426
|
-
{ __id: "3", name: "Alice", age: 15 },
|
|
1427
|
-
];
|
|
1428
|
-
const sorted = QueryEngine.sortEntities(data, [{ field: "name", direction: "asc" }]);
|
|
1429
|
-
assertEqual(sorted[0].__id, "1", "first null should maintain position");
|
|
1430
|
-
assertEqual(sorted[1].__id, "2", "second null should maintain position");
|
|
1431
|
-
assertEqual(sorted[2].__id, "3", "Alice should come after nulls");
|
|
1432
|
-
},
|
|
1433
|
-
"in operator with non-array value returns empty result": () => {
|
|
1434
|
-
const filters = [{ field: "city", operator: "in", value: "Warszawa" }];
|
|
1435
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1436
|
-
assertEqual(result.length, 0, "in with non-array value should return empty");
|
|
1437
|
-
},
|
|
1438
|
-
"in operator with >8 elements uses Set-based path": () => {
|
|
1439
|
-
const targetCities = [
|
|
1440
|
-
"Warszawa",
|
|
1441
|
-
"Kraków",
|
|
1442
|
-
"Gdańsk",
|
|
1443
|
-
"Łódź",
|
|
1444
|
-
"Lublin",
|
|
1445
|
-
"Białystok",
|
|
1446
|
-
"Katowice",
|
|
1447
|
-
"Bydgoszcz",
|
|
1448
|
-
"Szczecin",
|
|
1449
|
-
];
|
|
1450
|
-
const filters = [{ field: "city", operator: "in", value: targetCities }];
|
|
1451
|
-
const result = QueryEngine.filterEntities(queryEngineUsers, filters);
|
|
1452
|
-
assertEqual(result.length, 5, "in with >8 elements should match all 5 users");
|
|
1453
|
-
assertDeepEqual(result.map((u) => u.name).sort(), ["Anna", "Jan", "Maria", "Piotr", "Zofia"], "should match all users whose cities are in the set");
|
|
1454
|
-
},
|
|
1455
|
-
"contains operator with non-string entity value returns false": () => {
|
|
1456
|
-
const data = [
|
|
1457
|
-
{ __id: "1", name: 12345 },
|
|
1458
|
-
{ __id: "2", name: "hello world" },
|
|
1459
|
-
];
|
|
1460
|
-
const filters = [{ field: "name", operator: "contains", value: "123" }];
|
|
1461
|
-
const result = QueryEngine.filterEntities(data, filters);
|
|
1462
|
-
assertEqual(result.length, 0, "contains on numeric entity value should not match");
|
|
1463
|
-
},
|
|
1464
|
-
},
|
|
1465
|
-
"serialization.test.ts": {
|
|
1466
|
-
"serializes string": () => {
|
|
1467
|
-
const fd = { name: "x", type: "string" };
|
|
1468
|
-
assertEqual(Serialization.serializeValue("hello", fd), "hello", "string serialization should preserve string value");
|
|
1469
|
-
assertEqual(Serialization.serializeValue(123, fd), "123", "string serialization should coerce number to string");
|
|
1470
|
-
assertEqual(Serialization.serializeValue(null, fd), "", "string serialization should map null to empty string");
|
|
1471
|
-
},
|
|
1472
|
-
"serializes number": () => {
|
|
1473
|
-
const fd = { name: "x", type: "number" };
|
|
1474
|
-
assertEqual(Serialization.serializeValue(42, fd), 42, "number serialization should preserve number");
|
|
1475
|
-
assertEqual(Serialization.serializeValue("7", fd), 7, "number serialization should coerce numeric string");
|
|
1476
|
-
},
|
|
1477
|
-
"serializes boolean": () => {
|
|
1478
|
-
const fd = { name: "x", type: "boolean" };
|
|
1479
|
-
assertEqual(Serialization.serializeValue(true, fd), true, "boolean serialization should preserve boolean");
|
|
1480
|
-
assertEqual(Serialization.serializeValue("true", fd), true, "boolean serialization should parse true string");
|
|
1481
|
-
assertEqual(Serialization.serializeValue("false", fd), false, "boolean serialization should parse false string");
|
|
1482
|
-
},
|
|
1483
|
-
"serializes json": () => {
|
|
1484
|
-
const fd = { name: "x", type: "json" };
|
|
1485
|
-
assertEqual(Serialization.serializeValue({ a: 1 }, fd), '{"a":1}', "json serialization should stringify object");
|
|
1486
|
-
assertEqual(Serialization.serializeValue("already string", fd), '"already string"', "json serialization should wrap string with JSON.stringify");
|
|
1487
|
-
},
|
|
1488
|
-
"serializes date": () => {
|
|
1489
|
-
const fd = { name: "x", type: "date" };
|
|
1490
|
-
const date = new Date("2024-01-15T10:00:00.000Z");
|
|
1491
|
-
assertEqual(Serialization.serializeValue(date, fd), "2024-01-15T10:00:00.000Z", "date serialization should use ISO format");
|
|
1492
|
-
},
|
|
1493
|
-
"serializes date type with non-Date string value": () => {
|
|
1494
|
-
const fd = { name: "x", type: "date" };
|
|
1495
|
-
assertEqual(Serialization.serializeValue("2024-01-01", fd), "2024-01-01", "date serialization of string value should return String() pass-through");
|
|
1496
|
-
assertEqual(Serialization.serializeValue("not-a-date", fd), "not-a-date", "date serialization of non-parseable string should return String() pass-through");
|
|
1497
|
-
},
|
|
1498
|
-
"serializes reference": () => {
|
|
1499
|
-
const fd = { name: "x", type: "reference" };
|
|
1500
|
-
assertEqual(Serialization.serializeValue("user-001", fd), "user-001", "reference serialization should keep id string");
|
|
1501
|
-
},
|
|
1502
|
-
"deserializes string": () => {
|
|
1503
|
-
const fd = { name: "x", type: "string" };
|
|
1504
|
-
assertEqual(Serialization.deserializeValue("hello", fd), "hello", "string deserialization should preserve text");
|
|
1505
|
-
assertEqual(Serialization.deserializeValue("", fd), null, "empty string should deserialize to null without default");
|
|
1506
|
-
},
|
|
1507
|
-
"applies defaultValue when empty": () => {
|
|
1508
|
-
const fd = { name: "x", type: "string", defaultValue: "default" };
|
|
1509
|
-
assertEqual(Serialization.deserializeValue("", fd), "default", "empty value should use defaultValue");
|
|
1510
|
-
},
|
|
1511
|
-
"deserializes number": () => {
|
|
1512
|
-
const fd = { name: "x", type: "number" };
|
|
1513
|
-
assertEqual(Serialization.deserializeValue(42, fd), 42, "number deserialization should preserve number");
|
|
1514
|
-
assertEqual(Serialization.deserializeValue("3.14", fd), 3.14, "number deserialization should parse decimal string");
|
|
1515
|
-
assertEqual(Serialization.deserializeValue("abc", fd), null, "invalid number should deserialize to null");
|
|
1516
|
-
},
|
|
1517
|
-
"deserializes boolean": () => {
|
|
1518
|
-
const fd = { name: "x", type: "boolean" };
|
|
1519
|
-
assertEqual(Serialization.deserializeValue(true, fd), true, "boolean deserialization should preserve boolean");
|
|
1520
|
-
assertEqual(Serialization.deserializeValue("true", fd), true, "boolean deserialization should parse true string");
|
|
1521
|
-
assertEqual(Serialization.deserializeValue("false", fd), false, "boolean deserialization should parse false string");
|
|
1522
|
-
assertEqual(Serialization.deserializeValue(NaN, fd), false, "NaN boolean deserialization should return false");
|
|
1523
|
-
},
|
|
1524
|
-
"deserializes date from ISO string": () => {
|
|
1525
|
-
const fd = { name: "x", type: "date" };
|
|
1526
|
-
const result = Serialization.deserializeValue("2024-01-15T10:00:00.000Z", fd);
|
|
1527
|
-
assertEqual(result, "2024-01-15T10:00:00.000Z", "date deserialization should return ISO string");
|
|
1528
|
-
},
|
|
1529
|
-
"deserializes reference type as string": () => {
|
|
1530
|
-
const fd = { name: "x", type: "reference" };
|
|
1531
|
-
assertEqual(Serialization.deserializeValue("user-001", fd), "user-001", "reference should deserialize as string");
|
|
1532
|
-
assertEqual(Serialization.deserializeValue(42, fd), "42", "numeric reference should deserialize as string");
|
|
1533
|
-
},
|
|
1534
|
-
"deserializes json": () => {
|
|
1535
|
-
const fd = { name: "x", type: "json" };
|
|
1536
|
-
assertDeepEqual(Serialization.deserializeValue('{"a":1}', fd), { a: 1 }, "json deserialization should parse valid json");
|
|
1537
|
-
assertEqual(Serialization.deserializeValue("invalid json", fd), null, "invalid json should deserialize to null");
|
|
1538
|
-
},
|
|
1539
|
-
"deserializes json type with non-string value returns it as-is": () => {
|
|
1540
|
-
const fd = { name: "x", type: "json" };
|
|
1541
|
-
const obj = { a: 1 };
|
|
1542
|
-
assertTrue(Serialization.deserializeValue(obj, fd) === obj, "non-string json value should be returned as-is (same reference)");
|
|
1543
|
-
assertEqual(Serialization.deserializeValue(42, fd), 42, "numeric json value should be returned as-is");
|
|
1544
|
-
},
|
|
1545
|
-
"prepends system columns": () => {
|
|
1546
|
-
const fields = [
|
|
1547
|
-
{ name: "name", type: "string" },
|
|
1548
|
-
{ name: "age", type: "number" },
|
|
1549
|
-
];
|
|
1550
|
-
assertDeepEqual(Serialization.buildHeaders(fields), ["__id", "__createdAt", "__updatedAt", "name", "age"], "buildHeaders should prepend system columns");
|
|
1551
|
-
},
|
|
1552
|
-
"round-trips an entity": () => {
|
|
1553
|
-
const fields = [
|
|
1554
|
-
{ name: "name", type: "string" },
|
|
1555
|
-
{ name: "age", type: "number" },
|
|
1556
|
-
{ name: "active", type: "boolean" },
|
|
1557
|
-
];
|
|
1558
|
-
const headers = Serialization.buildHeaders(fields);
|
|
1559
|
-
const entity = {
|
|
1560
|
-
__id: "id-1",
|
|
1561
|
-
__createdAt: "2024-01-01T00:00:00.000Z",
|
|
1562
|
-
__updatedAt: "2024-01-02T00:00:00.000Z",
|
|
1563
|
-
name: "Jan",
|
|
1564
|
-
age: 30,
|
|
1565
|
-
active: true,
|
|
1566
|
-
};
|
|
1567
|
-
const row = Serialization.entityToRow(entity, fields, headers);
|
|
1568
|
-
assertDeepEqual(row, ["id-1", "2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", "Jan", 30, true], "entityToRow should serialize in header order");
|
|
1569
|
-
const restored = Serialization.rowToEntity(row, headers, fields);
|
|
1570
|
-
assertEqual(restored.__id, "id-1", "rowToEntity should restore __id");
|
|
1571
|
-
assertEqual(restored.name, "Jan", "rowToEntity should restore string field");
|
|
1572
|
-
assertEqual(restored.age, 30, "rowToEntity should restore number field");
|
|
1573
|
-
assertEqual(restored.active, true, "rowToEntity should restore boolean field");
|
|
1574
|
-
},
|
|
1575
|
-
"round-trips an entity with explicit fieldMap": () => {
|
|
1576
|
-
const fields = [
|
|
1577
|
-
{ name: "name", type: "string" },
|
|
1578
|
-
{ name: "age", type: "number" },
|
|
1579
|
-
{ name: "active", type: "boolean" },
|
|
1580
|
-
];
|
|
1581
|
-
const headers = Serialization.buildHeaders(fields);
|
|
1582
|
-
const fieldMap = new Map(fields.map((f) => [f.name, f]));
|
|
1583
|
-
const entity = {
|
|
1584
|
-
__id: "id-fm",
|
|
1585
|
-
__createdAt: "2024-06-01T00:00:00.000Z",
|
|
1586
|
-
__updatedAt: "2024-06-02T00:00:00.000Z",
|
|
1587
|
-
name: "Piotr",
|
|
1588
|
-
age: 40,
|
|
1589
|
-
active: false,
|
|
1590
|
-
};
|
|
1591
|
-
const row = Serialization.entityToRow(entity, fields, headers, fieldMap);
|
|
1592
|
-
const restored = Serialization.rowToEntity(row, headers, fields, fieldMap);
|
|
1593
|
-
assertEqual(restored.__id, "id-fm", "fieldMap round-trip should restore __id");
|
|
1594
|
-
assertEqual(restored.name, "Piotr", "fieldMap round-trip should restore string field");
|
|
1595
|
-
assertEqual(restored.age, 40, "fieldMap round-trip should restore number field");
|
|
1596
|
-
assertEqual(restored.active, false, "fieldMap round-trip should restore boolean field");
|
|
1597
|
-
},
|
|
1598
|
-
"handles missing optional fields": () => {
|
|
1599
|
-
const fields = [
|
|
1600
|
-
{ name: "name", type: "string" },
|
|
1601
|
-
{ name: "age", type: "number" },
|
|
1602
|
-
{ name: "active", type: "boolean" },
|
|
1603
|
-
];
|
|
1604
|
-
const headers = Serialization.buildHeaders(fields);
|
|
1605
|
-
const entity = {
|
|
1606
|
-
__id: "id-2",
|
|
1607
|
-
name: "Anna",
|
|
1608
|
-
age: 25,
|
|
1609
|
-
active: false,
|
|
1610
|
-
};
|
|
1611
|
-
const row = Serialization.entityToRow(entity, fields, headers);
|
|
1612
|
-
assertEqual(row[1], "", "missing __createdAt should serialize to empty string");
|
|
1613
|
-
assertEqual(row[2], "", "missing __updatedAt should serialize to empty string");
|
|
1614
|
-
const restored = Serialization.rowToEntity(row, headers, fields);
|
|
1615
|
-
assertEqual(restored.__createdAt, undefined, "missing __createdAt should restore as undefined");
|
|
1616
|
-
},
|
|
1617
|
-
"round-trips json string values": () => {
|
|
1618
|
-
const fd = { name: "x", type: "json" };
|
|
1619
|
-
const serialized = Serialization.serializeValue("hello", fd);
|
|
1620
|
-
assertEqual(serialized, '"hello"', "json serialization of string should wrap in quotes");
|
|
1621
|
-
const deserialized = Serialization.deserializeValue(serialized, fd);
|
|
1622
|
-
assertEqual(deserialized, "hello", "json deserialization should recover original string");
|
|
1623
|
-
},
|
|
1624
|
-
"deserializes native Date objects in date fields to ISO strings": () => {
|
|
1625
|
-
const dateFd = { name: "birthday", type: "date" };
|
|
1626
|
-
const nativeDate = new Date("2024-03-15T10:30:00.000Z");
|
|
1627
|
-
const result = Serialization.deserializeValue(nativeDate, dateFd);
|
|
1628
|
-
assertEqual(result, "2024-03-15T10:30:00.000Z", "native Date should be deserialized to ISO string");
|
|
1629
|
-
},
|
|
1630
|
-
"serializes Infinity as empty for number type": () => {
|
|
1631
|
-
const fd = { name: "x", type: "number" };
|
|
1632
|
-
assertEqual(Serialization.serializeValue(Infinity, fd), "", "Infinity should serialize to empty string");
|
|
1633
|
-
assertEqual(Serialization.serializeValue(-Infinity, fd), "", "-Infinity should serialize to empty string");
|
|
1634
|
-
},
|
|
1635
|
-
"serializes Infinity string as empty for number type": () => {
|
|
1636
|
-
const fd = { name: "x", type: "number" };
|
|
1637
|
-
assertEqual(Serialization.serializeValue("Infinity", fd), "", "Infinity string should serialize to empty string");
|
|
1638
|
-
assertEqual(Serialization.serializeValue("-Infinity", fd), "", "-Infinity string should serialize to empty string");
|
|
1639
|
-
},
|
|
1640
|
-
"serializes invalid Date as empty for date type": () => {
|
|
1641
|
-
const fd = { name: "x", type: "date" };
|
|
1642
|
-
assertEqual(Serialization.serializeValue(new Date("invalid"), fd), "", "invalid Date should serialize to empty string");
|
|
1643
|
-
},
|
|
1644
|
-
"auto-infers number type when fieldDef.type is undefined": () => {
|
|
1645
|
-
const fd = { name: "x" };
|
|
1646
|
-
assertEqual(Serialization.serializeValue(42, fd), 42, "number should be preserved when type is inferred");
|
|
1647
|
-
},
|
|
1648
|
-
"auto-infers boolean type when fieldDef.type is undefined": () => {
|
|
1649
|
-
const fd = { name: "x" };
|
|
1650
|
-
assertEqual(Serialization.serializeValue(true, fd), true, "boolean should be preserved when type is inferred");
|
|
1651
|
-
},
|
|
1652
|
-
"auto-infers object as JSON when fieldDef.type is undefined": () => {
|
|
1653
|
-
const fd = { name: "x" };
|
|
1654
|
-
assertEqual(Serialization.serializeValue({ a: 1 }, fd), '{"a":1}', "object should serialize as JSON when type is inferred");
|
|
1655
|
-
},
|
|
1656
|
-
"deserializes Infinity as null for number type": () => {
|
|
1657
|
-
const fd = { name: "x", type: "number" };
|
|
1658
|
-
assertEqual(Serialization.deserializeValue(Infinity, fd), null, "Infinity should deserialize to null");
|
|
1659
|
-
assertEqual(Serialization.deserializeValue(-Infinity, fd), null, "-Infinity should deserialize to null");
|
|
1660
|
-
},
|
|
1661
|
-
"deserializes Infinity string as null for number type": () => {
|
|
1662
|
-
const fd = { name: "x", type: "number" };
|
|
1663
|
-
assertEqual(Serialization.deserializeValue("Infinity", fd), null, "Infinity string should deserialize to null");
|
|
1664
|
-
},
|
|
1665
|
-
"deserializes invalid Date object as null for date type": () => {
|
|
1666
|
-
const fd = { name: "x", type: "date" };
|
|
1667
|
-
assertEqual(Serialization.deserializeValue(new Date("invalid"), fd), null, "invalid Date should deserialize to null");
|
|
1668
|
-
},
|
|
1669
|
-
"returns raw value when fieldDef.type is undefined": () => {
|
|
1670
|
-
const fd = { name: "x" };
|
|
1671
|
-
assertEqual(Serialization.deserializeValue(42, fd), 42, "number should be returned as-is without explicit type");
|
|
1672
|
-
assertEqual(Serialization.deserializeValue("hello", fd), "hello", "string should be returned as-is without explicit type");
|
|
1673
|
-
},
|
|
1674
|
-
"auto-infers invalid Date as empty string when fieldDef.type is undefined": () => {
|
|
1675
|
-
const fd = { name: "x" };
|
|
1676
|
-
assertEqual(Serialization.serializeValue(new Date("invalid"), fd), "", "invalid Date should auto-infer to empty string");
|
|
1677
|
-
},
|
|
1678
|
-
"auto-infers valid Date to ISO string when fieldDef.type is undefined": () => {
|
|
1679
|
-
const fd = { name: "x" };
|
|
1680
|
-
const d = new Date("2024-01-15T10:00:00.000Z");
|
|
1681
|
-
assertEqual(Serialization.serializeValue(d, fd), "2024-01-15T10:00:00.000Z", "valid Date should auto-infer to ISO string");
|
|
1682
|
-
},
|
|
1683
|
-
"auto-infers string type as String() when fieldDef.type is undefined": () => {
|
|
1684
|
-
const fd = { name: "x" };
|
|
1685
|
-
assertEqual(Serialization.serializeValue("hello", fd), "hello", "string value should fall through to String()");
|
|
1686
|
-
},
|
|
1687
|
-
"serializes NaN as empty for number type": () => {
|
|
1688
|
-
const fd = { name: "x", type: "number" };
|
|
1689
|
-
assertEqual(Serialization.serializeValue(NaN, fd), "", "NaN should serialize to empty string for number type");
|
|
1690
|
-
},
|
|
1691
|
-
"deserializes boolean from number 0 and 1": () => {
|
|
1692
|
-
const fd = { name: "x", type: "boolean" };
|
|
1693
|
-
assertEqual(Serialization.deserializeValue(0, fd), false, "0 should deserialize to false");
|
|
1694
|
-
assertEqual(Serialization.deserializeValue(1, fd), true, "1 should deserialize to true");
|
|
1695
|
-
},
|
|
1696
|
-
"deserializes boolean string false as false": () => {
|
|
1697
|
-
const fd = { name: "x", type: "boolean" };
|
|
1698
|
-
assertEqual(Serialization.deserializeValue("false", fd), false, "'false' should deserialize to false");
|
|
1699
|
-
assertEqual(Serialization.deserializeValue("FALSE", fd), false, "'FALSE' should deserialize to false");
|
|
1700
|
-
},
|
|
1701
|
-
"deserializes yes as true and numeric-string zero as false for boolean type": () => {
|
|
1702
|
-
const fd = { name: "x", type: "boolean" };
|
|
1703
|
-
assertEqual(Serialization.deserializeValue("yes", fd), true, "'yes' should deserialize to true");
|
|
1704
|
-
assertEqual(Serialization.deserializeValue("0", fd), false, "'0' string should deserialize to false");
|
|
1705
|
-
},
|
|
1706
|
-
"deserializes boolean string one as true": () => {
|
|
1707
|
-
const fd = { name: "x", type: "boolean" };
|
|
1708
|
-
assertEqual(Serialization.deserializeValue("1", fd), true, "'1' string should deserialize to true");
|
|
1709
|
-
},
|
|
1710
|
-
"serializes boolean from string yes/no": () => {
|
|
1711
|
-
const fd = { name: "x", type: "boolean" };
|
|
1712
|
-
assertEqual(Serialization.serializeValue("yes", fd), true, "'yes' should serialize to true");
|
|
1713
|
-
assertEqual(Serialization.serializeValue("no", fd), false, "'no' should serialize to false");
|
|
1714
|
-
},
|
|
1715
|
-
"serializes unrecognized field type as string": () => {
|
|
1716
|
-
const fd = { name: "x", type: "custom" };
|
|
1717
|
-
assertEqual(Serialization.serializeValue(42, fd), "42", "unrecognized type should serialize number as string");
|
|
1718
|
-
assertEqual(Serialization.serializeValue(true, fd), "true", "unrecognized type should serialize boolean as string");
|
|
1719
|
-
},
|
|
1720
|
-
"deserializes unrecognized field type as raw value": () => {
|
|
1721
|
-
const fd = { name: "x", type: "custom" };
|
|
1722
|
-
assertEqual(Serialization.deserializeValue(42, fd), 42, "unrecognized type should pass through number");
|
|
1723
|
-
assertEqual(Serialization.deserializeValue("hello", fd), "hello", "unrecognized type should pass through string");
|
|
1724
|
-
},
|
|
1725
|
-
"rowToEntity handles row shorter than headers": () => {
|
|
1726
|
-
const fields = [
|
|
1727
|
-
{ name: "name", type: "string" },
|
|
1728
|
-
{ name: "age", type: "number" },
|
|
1729
|
-
];
|
|
1730
|
-
const headers = Serialization.buildHeaders(fields);
|
|
1731
|
-
const shortRow = ["id-1", "2024-01-01T00:00:00.000Z"];
|
|
1732
|
-
const entity = Serialization.rowToEntity(shortRow, headers, fields);
|
|
1733
|
-
assertEqual(entity.__id, "id-1", "should parse __id from short row");
|
|
1734
|
-
assertEqual(entity.name, null, "missing name should deserialize to null");
|
|
1735
|
-
assertEqual(entity.age, null, "missing age should deserialize to null");
|
|
1736
|
-
},
|
|
1737
|
-
"entityToRow uses raw value for column without field definition": () => {
|
|
1738
|
-
const fields = [{ name: "name", type: "string" }];
|
|
1739
|
-
const headers = ["__id", "__createdAt", "__updatedAt", "name", "extra"];
|
|
1740
|
-
const entity = { __id: "id-1", name: "Alice", extra: "bonus" };
|
|
1741
|
-
const row = Serialization.entityToRow(entity, fields, headers);
|
|
1742
|
-
assertEqual(row[3], "Alice", "name should be serialized via field definition");
|
|
1743
|
-
assertEqual(row[4], "bonus", "extra column without field definition should use raw value");
|
|
1744
|
-
},
|
|
1745
|
-
"rowToEntity keeps raw value for column without field definition": () => {
|
|
1746
|
-
const fields = [{ name: "name", type: "string" }];
|
|
1747
|
-
const headers = ["__id", "__createdAt", "__updatedAt", "name", "extra"];
|
|
1748
|
-
const row = ["id-2", "", "", "Bob", "raw-extra"];
|
|
1749
|
-
const entity = Serialization.rowToEntity(row, headers, fields);
|
|
1750
|
-
assertEqual(entity.__id, "id-2", "should parse __id");
|
|
1751
|
-
assertEqual(entity.name, "Bob", "name should deserialize via field definition");
|
|
1752
|
-
assertEqual(entity.extra, "raw-extra", "extra column without field definition should pass through raw value");
|
|
1753
|
-
},
|
|
1754
|
-
"rowToEntity converts Date objects in system columns to ISO strings": () => {
|
|
1755
|
-
const fields = [{ name: "name", type: "string" }];
|
|
1756
|
-
const headers = Serialization.buildHeaders(fields);
|
|
1757
|
-
const now = new Date("2024-06-15T12:00:00.000Z");
|
|
1758
|
-
const row = ["id-1", now, now, "Alice"];
|
|
1759
|
-
const entity = Serialization.rowToEntity(row, headers, fields);
|
|
1760
|
-
assertEqual(entity.__id, "id-1", "should parse __id");
|
|
1761
|
-
assertEqual(entity.__createdAt, "2024-06-15T12:00:00.000Z", "Date in __createdAt should become ISO string");
|
|
1762
|
-
assertEqual(entity.__updatedAt, "2024-06-15T12:00:00.000Z", "Date in __updatedAt should become ISO string");
|
|
1763
|
-
},
|
|
1764
|
-
"deserializes date from numeric cell value as string": () => {
|
|
1765
|
-
const fd = { name: "timestamp", type: "date" };
|
|
1766
|
-
const result = Serialization.deserializeValue(12345, fd);
|
|
1767
|
-
assertEqual(result, "12345", "numeric cell value for date type should become its string representation");
|
|
1768
|
-
},
|
|
1769
|
-
},
|
|
1770
|
-
"uuid.test.ts": {
|
|
1771
|
-
"returns a string of UUID v4 format": () => {
|
|
1772
|
-
const uuid = Uuid.generate();
|
|
1773
|
-
assertTrue(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid), "generated UUID should match v4 format");
|
|
1774
|
-
},
|
|
1775
|
-
"generates unique values": () => {
|
|
1776
|
-
const uuids = new Set(Array.from({ length: 100 }, () => Uuid.generate()));
|
|
1777
|
-
assertEqual(uuids.size, 100, "100 generated UUIDs should be unique");
|
|
1778
|
-
},
|
|
1779
|
-
"falls back to Math.random when crypto is unavailable": () => {
|
|
1780
|
-
// In GAS runtime, crypto may not be available — test the fallback path
|
|
1781
|
-
// This is a smoke test: the UUID should still be valid v4 format
|
|
1782
|
-
const uuid = Uuid.generate();
|
|
1783
|
-
assertTrue(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid), "fallback UUID should match v4 format");
|
|
1784
|
-
},
|
|
1785
|
-
"uses GAS Utilities.getUuid when available": () => {
|
|
1786
|
-
// In GAS runtime, Utilities.getUuid() is available natively
|
|
1787
|
-
// This handler simply verifies the primary path produces a valid UUID
|
|
1788
|
-
const uuid = Uuid.generate();
|
|
1789
|
-
assertTrue(typeof uuid === "string" && uuid.length > 0, "GAS Utilities UUID should be a non-empty string");
|
|
1790
|
-
},
|
|
1791
|
-
"uses crypto.getRandomValues when available": () => {
|
|
1792
|
-
const g = globalThis;
|
|
1793
|
-
const originalCrypto = g.crypto;
|
|
1794
|
-
const originalUtilities = g.Utilities;
|
|
1795
|
-
const fakeCrypto = {
|
|
1796
|
-
getRandomValues: (buf) => {
|
|
1797
|
-
for (let i = 0; i < buf.length; i++)
|
|
1798
|
-
buf[i] = i;
|
|
1799
|
-
return buf;
|
|
1800
|
-
},
|
|
1801
|
-
};
|
|
1802
|
-
// Remove Utilities so the GAS path is bypassed and crypto path is taken
|
|
1803
|
-
Object.defineProperty(globalThis, "Utilities", {
|
|
1804
|
-
value: undefined,
|
|
1805
|
-
writable: true,
|
|
1806
|
-
configurable: true,
|
|
1807
|
-
});
|
|
1808
|
-
Object.defineProperty(globalThis, "crypto", {
|
|
1809
|
-
value: fakeCrypto,
|
|
1810
|
-
writable: true,
|
|
1811
|
-
configurable: true,
|
|
1812
|
-
});
|
|
1813
|
-
try {
|
|
1814
|
-
const uuid = Uuid.generate();
|
|
1815
|
-
assertEqual(uuid, "00010203-0405-4607-8809-0a0b0c0d0e0f", "UUID should come from crypto bytes with RFC4122 v4 bits applied");
|
|
1816
|
-
}
|
|
1817
|
-
finally {
|
|
1818
|
-
Object.defineProperty(globalThis, "Utilities", {
|
|
1819
|
-
value: originalUtilities,
|
|
1820
|
-
writable: true,
|
|
1821
|
-
configurable: true,
|
|
1822
|
-
});
|
|
1823
|
-
Object.defineProperty(globalThis, "crypto", {
|
|
1824
|
-
value: originalCrypto,
|
|
1825
|
-
writable: true,
|
|
1826
|
-
configurable: true,
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
},
|
|
1830
|
-
},
|
|
1831
|
-
"record.test.ts": (() => {
|
|
1832
|
-
// Build Record subclasses inside a factory to avoid top-level side-effects
|
|
1833
|
-
function createRecordClasses(adapter, suffix) {
|
|
1834
|
-
Registry.reset();
|
|
1835
|
-
resetDecoratorCaches();
|
|
1836
|
-
Registry.getInstance().configure({ adapter });
|
|
1837
|
-
class Car extends BaseRecord {
|
|
1838
|
-
static get tableName() {
|
|
1839
|
-
return `Cars_${suffix}`;
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
__decorate([
|
|
1843
|
-
Indexed()
|
|
1844
|
-
], Car.prototype, "make", void 0);
|
|
1845
|
-
__decorate([
|
|
1846
|
-
Required()
|
|
1847
|
-
], Car.prototype, "model", void 0);
|
|
1848
|
-
class Product extends BaseRecord {
|
|
1849
|
-
static get tableName() {
|
|
1850
|
-
return `Products_${suffix}`;
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
__decorate([
|
|
1854
|
-
Required()
|
|
1855
|
-
], Product.prototype, "price", void 0);
|
|
1856
|
-
__decorate([
|
|
1857
|
-
Indexed()
|
|
1858
|
-
], Product.prototype, "category", void 0);
|
|
1859
|
-
return { Car, Product };
|
|
1860
|
-
}
|
|
1861
|
-
function setup(ctx) {
|
|
1862
|
-
const adapter = ctx.state.getAdapter();
|
|
1863
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
1864
|
-
return createRecordClasses(adapter, suffix);
|
|
1865
|
-
}
|
|
1866
|
-
return {
|
|
1867
|
-
"creates a new entity with auto-generated ID": (ctx) => {
|
|
1868
|
-
const { Car } = setup(ctx);
|
|
1869
|
-
const car = new Car();
|
|
1870
|
-
car.make = "Toyota";
|
|
1871
|
-
car.model = "Corolla";
|
|
1872
|
-
car.year = 2024;
|
|
1873
|
-
car.save();
|
|
1874
|
-
assertTrue(Boolean(car.__id), "car should have __id after save");
|
|
1875
|
-
assertTrue(Boolean(car.__createdAt), "car should have __createdAt");
|
|
1876
|
-
assertTrue(Boolean(car.__updatedAt), "car should have __updatedAt");
|
|
1877
|
-
assertEqual(car.make, "Toyota", "make should be Toyota");
|
|
1878
|
-
},
|
|
1879
|
-
"auto-creates the table on first save": (ctx) => {
|
|
1880
|
-
const { Car } = setup(ctx);
|
|
1881
|
-
const car = new Car();
|
|
1882
|
-
car.make = "Honda";
|
|
1883
|
-
car.model = "Civic";
|
|
1884
|
-
car.save();
|
|
1885
|
-
assertTrue(Boolean(car.__id), "table should be auto-created on save");
|
|
1886
|
-
},
|
|
1887
|
-
"updates an existing entity": (ctx) => {
|
|
1888
|
-
const { Car } = setup(ctx);
|
|
1889
|
-
const car = new Car();
|
|
1890
|
-
car.make = "Toyota";
|
|
1891
|
-
car.model = "Corolla";
|
|
1892
|
-
car.year = 2024;
|
|
1893
|
-
car.color = "blue";
|
|
1894
|
-
car.save();
|
|
1895
|
-
car.color = "red";
|
|
1896
|
-
car.save();
|
|
1897
|
-
assertEqual(car.color, "red", "color should be updated to red");
|
|
1898
|
-
},
|
|
1899
|
-
"returns this for chaining": (ctx) => {
|
|
1900
|
-
const { Car } = setup(ctx);
|
|
1901
|
-
const car = new Car();
|
|
1902
|
-
car.make = "Toyota";
|
|
1903
|
-
car.model = "Corolla";
|
|
1904
|
-
const result = car.save();
|
|
1905
|
-
assertTrue(result === car, "save() should return this");
|
|
1906
|
-
},
|
|
1907
|
-
"persists update via findById round-trip": (ctx) => {
|
|
1908
|
-
const { Car } = setup(ctx);
|
|
1909
|
-
const car = new Car();
|
|
1910
|
-
car.make = "Toyota";
|
|
1911
|
-
car.model = "Corolla";
|
|
1912
|
-
car.color = "blue";
|
|
1913
|
-
car.save();
|
|
1914
|
-
const loaded = Car.findById(car.__id);
|
|
1915
|
-
assertTrue(loaded !== null, "should find car by id");
|
|
1916
|
-
assertEqual(loaded.color, "blue", "loaded color should be blue");
|
|
1917
|
-
loaded.color = "red";
|
|
1918
|
-
loaded.save();
|
|
1919
|
-
const reloaded = Car.findById(car.__id);
|
|
1920
|
-
assertEqual(reloaded.color, "red", "reloaded color should be red after update");
|
|
1921
|
-
},
|
|
1922
|
-
"throws on missing required field": (ctx) => {
|
|
1923
|
-
const { Car } = setup(ctx);
|
|
1924
|
-
const car = new Car();
|
|
1925
|
-
car.color = "blue";
|
|
1926
|
-
assertThrows(() => car.save(), /required/i, "should throw for missing required field");
|
|
1927
|
-
},
|
|
1928
|
-
"sets a field value and returns this": (ctx) => {
|
|
1929
|
-
const { Car } = setup(ctx);
|
|
1930
|
-
const car = new Car();
|
|
1931
|
-
const result = car.set("make", "BMW");
|
|
1932
|
-
assertTrue(result === car, "set() should return this");
|
|
1933
|
-
assertEqual(car.make, "BMW", "field should be set");
|
|
1934
|
-
},
|
|
1935
|
-
"supports chaining set calls": (ctx) => {
|
|
1936
|
-
const { Car } = setup(ctx);
|
|
1937
|
-
const car = new Car();
|
|
1938
|
-
car.set("make", "BMW").set("model", "M3").set("year", 2024);
|
|
1939
|
-
assertEqual(car.make, "BMW", "make should be BMW");
|
|
1940
|
-
assertEqual(car.model, "M3", "model should be M3");
|
|
1941
|
-
assertEqual(car.year, 2024, "year should be 2024");
|
|
1942
|
-
},
|
|
1943
|
-
"get() retrieves a field value": (ctx) => {
|
|
1944
|
-
const { Car } = setup(ctx);
|
|
1945
|
-
const car = new Car();
|
|
1946
|
-
car.make = "Toyota";
|
|
1947
|
-
assertEqual(car.get("make"), "Toyota", "get should return field value");
|
|
1948
|
-
},
|
|
1949
|
-
"deletes a saved entity": (ctx) => {
|
|
1950
|
-
const { Car } = setup(ctx);
|
|
1951
|
-
const car = new Car();
|
|
1952
|
-
car.make = "Toyota";
|
|
1953
|
-
car.model = "Corolla";
|
|
1954
|
-
car.save();
|
|
1955
|
-
const id = car.__id;
|
|
1956
|
-
const deleted = car.delete();
|
|
1957
|
-
assertTrue(deleted, "delete should return true");
|
|
1958
|
-
const found = Car.findById(id);
|
|
1959
|
-
assertTrue(found === null, "deleted entity should not be found");
|
|
1960
|
-
},
|
|
1961
|
-
"returns false for unsaved entity": (ctx) => {
|
|
1962
|
-
const { Car } = setup(ctx);
|
|
1963
|
-
const car = new Car();
|
|
1964
|
-
const deleted = car.delete();
|
|
1965
|
-
assertTrue(!deleted, "delete should return false for unsaved entity");
|
|
1966
|
-
},
|
|
1967
|
-
"returns a plain object with all fields": (ctx) => {
|
|
1968
|
-
const { Car } = setup(ctx);
|
|
1969
|
-
const car = new Car();
|
|
1970
|
-
car.make = "Toyota";
|
|
1971
|
-
car.model = "Corolla";
|
|
1972
|
-
car.year = 2024;
|
|
1973
|
-
car.color = "blue";
|
|
1974
|
-
car.save();
|
|
1975
|
-
const json = car.toJSON();
|
|
1976
|
-
assertEqual(json.make, "Toyota", "toJSON make should match");
|
|
1977
|
-
assertEqual(json.model, "Corolla", "toJSON model should match");
|
|
1978
|
-
assertEqual(json.year, 2024, "toJSON year should match");
|
|
1979
|
-
assertTrue(Boolean(json.__id), "toJSON should include __id");
|
|
1980
|
-
},
|
|
1981
|
-
"creates instance with data via static create()": (ctx) => {
|
|
1982
|
-
const { Car } = setup(ctx);
|
|
1983
|
-
const car = Car.create({ make: "BMW", model: "X5", year: 2023 });
|
|
1984
|
-
assertEqual(car.make, "BMW", "create should set make");
|
|
1985
|
-
assertEqual(car.model, "X5", "create should set model");
|
|
1986
|
-
},
|
|
1987
|
-
"finds a saved entity by ID": (ctx) => {
|
|
1988
|
-
const { Car } = setup(ctx);
|
|
1989
|
-
const car = new Car();
|
|
1990
|
-
car.make = "Toyota";
|
|
1991
|
-
car.model = "Corolla";
|
|
1992
|
-
car.save();
|
|
1993
|
-
const found = Car.findById(car.__id);
|
|
1994
|
-
assertTrue(found !== null, "findById should return the entity");
|
|
1995
|
-
assertEqual(found.make, "Toyota", "found entity should match");
|
|
1996
|
-
},
|
|
1997
|
-
"returns null for non-existent ID": (ctx) => {
|
|
1998
|
-
const { Car } = setup(ctx);
|
|
1999
|
-
// Ensure table exists
|
|
2000
|
-
const car = new Car();
|
|
2001
|
-
car.make = "Test";
|
|
2002
|
-
car.model = "Test";
|
|
2003
|
-
car.save();
|
|
2004
|
-
const found = Car.findById("non-existent-id");
|
|
2005
|
-
assertTrue(found === null, "findById should return null for non-existent id");
|
|
2006
|
-
},
|
|
2007
|
-
"returns all entities": (ctx) => {
|
|
2008
|
-
const { Car } = setup(ctx);
|
|
2009
|
-
const c1 = new Car();
|
|
2010
|
-
c1.make = "Toyota";
|
|
2011
|
-
c1.model = "Corolla";
|
|
2012
|
-
c1.save();
|
|
2013
|
-
const c2 = new Car();
|
|
2014
|
-
c2.make = "Honda";
|
|
2015
|
-
c2.model = "Civic";
|
|
2016
|
-
c2.save();
|
|
2017
|
-
const all = Car.find();
|
|
2018
|
-
assertEqual(all.length, 2, "find should return 2 entities");
|
|
2019
|
-
},
|
|
2020
|
-
"returns entities matching query": (ctx) => {
|
|
2021
|
-
const { Car } = setup(ctx);
|
|
2022
|
-
const c1 = new Car();
|
|
2023
|
-
c1.make = "Toyota";
|
|
2024
|
-
c1.model = "Corolla";
|
|
2025
|
-
c1.save();
|
|
2026
|
-
const c2 = new Car();
|
|
2027
|
-
c2.make = "Honda";
|
|
2028
|
-
c2.model = "Civic";
|
|
2029
|
-
c2.save();
|
|
2030
|
-
const toyotas = Car.find({ where: [{ field: "make", operator: "=", value: "Toyota" }] });
|
|
2031
|
-
assertEqual(toyotas.length, 1, "find with filter should return 1");
|
|
2032
|
-
assertEqual(toyotas[0].make, "Toyota", "filtered entity should be Toyota");
|
|
2033
|
-
},
|
|
2034
|
-
"returns first matching entity": (ctx) => {
|
|
2035
|
-
const { Car } = setup(ctx);
|
|
2036
|
-
const c1 = new Car();
|
|
2037
|
-
c1.make = "Toyota";
|
|
2038
|
-
c1.model = "Corolla";
|
|
2039
|
-
c1.save();
|
|
2040
|
-
const c2 = new Car();
|
|
2041
|
-
c2.make = "Toyota";
|
|
2042
|
-
c2.model = "Camry";
|
|
2043
|
-
c2.save();
|
|
2044
|
-
const found = Car.findOne({ where: [{ field: "make", operator: "=", value: "Toyota" }] });
|
|
2045
|
-
assertTrue(found !== null, "findOne should find a match");
|
|
2046
|
-
assertEqual(found.make, "Toyota", "findOne should return Toyota");
|
|
2047
|
-
},
|
|
2048
|
-
"returns null when no match": (ctx) => {
|
|
2049
|
-
const { Car } = setup(ctx);
|
|
2050
|
-
const c = new Car();
|
|
2051
|
-
c.make = "Toyota";
|
|
2052
|
-
c.model = "Corolla";
|
|
2053
|
-
c.save();
|
|
2054
|
-
const found = Car.findOne({ where: [{ field: "make", operator: "=", value: "BMW" }] });
|
|
2055
|
-
assertTrue(found === null, "findOne should return null when no match");
|
|
2056
|
-
},
|
|
2057
|
-
"returns a Query and chains": (ctx) => {
|
|
2058
|
-
const { Car } = setup(ctx);
|
|
2059
|
-
const c1 = new Car();
|
|
2060
|
-
c1.make = "Toyota";
|
|
2061
|
-
c1.model = "Corolla";
|
|
2062
|
-
c1.year = 2020;
|
|
2063
|
-
c1.save();
|
|
2064
|
-
const c2 = new Car();
|
|
2065
|
-
c2.make = "Toyota";
|
|
2066
|
-
c2.model = "Camry";
|
|
2067
|
-
c2.year = 2024;
|
|
2068
|
-
c2.save();
|
|
2069
|
-
const results = Car.where("make", "=", "Toyota").orderBy("year", "desc").execute();
|
|
2070
|
-
assertEqual(results.length, 2, "where query should return 2 results");
|
|
2071
|
-
assertEqual(results[0].year, 2024, "first result should be 2024");
|
|
2072
|
-
},
|
|
2073
|
-
"returns a Query": (ctx) => {
|
|
2074
|
-
const { Car } = setup(ctx);
|
|
2075
|
-
const c = new Car();
|
|
2076
|
-
c.make = "Toyota";
|
|
2077
|
-
c.model = "Corolla";
|
|
2078
|
-
c.save();
|
|
2079
|
-
const qb = Car.query();
|
|
2080
|
-
const results = qb.execute();
|
|
2081
|
-
assertEqual(results.length, 1, "query() should return builder that executes");
|
|
2082
|
-
},
|
|
2083
|
-
"counts all entities": (ctx) => {
|
|
2084
|
-
const { Car } = setup(ctx);
|
|
2085
|
-
const c1 = new Car();
|
|
2086
|
-
c1.make = "Toyota";
|
|
2087
|
-
c1.model = "Corolla";
|
|
2088
|
-
c1.save();
|
|
2089
|
-
assertEqual(Car.count(), 1, "count should be 1");
|
|
2090
|
-
},
|
|
2091
|
-
"counts with filter": (ctx) => {
|
|
2092
|
-
const { Car } = setup(ctx);
|
|
2093
|
-
const c1 = new Car();
|
|
2094
|
-
c1.make = "Toyota";
|
|
2095
|
-
c1.model = "Corolla";
|
|
2096
|
-
c1.save();
|
|
2097
|
-
const c2 = new Car();
|
|
2098
|
-
c2.make = "Honda";
|
|
2099
|
-
c2.model = "Civic";
|
|
2100
|
-
c2.save();
|
|
2101
|
-
assertEqual(Car.count({ where: [{ field: "make", operator: "=", value: "Toyota" }] }), 1, "count with filter should be 1");
|
|
2102
|
-
},
|
|
2103
|
-
"deletes matching entities": (ctx) => {
|
|
2104
|
-
const { Car } = setup(ctx);
|
|
2105
|
-
const c1 = new Car();
|
|
2106
|
-
c1.make = "Toyota";
|
|
2107
|
-
c1.model = "Corolla";
|
|
2108
|
-
c1.save();
|
|
2109
|
-
const c2 = new Car();
|
|
2110
|
-
c2.make = "Honda";
|
|
2111
|
-
c2.model = "Civic";
|
|
2112
|
-
c2.save();
|
|
2113
|
-
const deleted = Car.deleteAll({ where: [{ field: "make", operator: "=", value: "Toyota" }] });
|
|
2114
|
-
assertEqual(deleted, 1, "deleteAll should return 1");
|
|
2115
|
-
assertEqual(Car.count(), 1, "count after deleteAll should be 1");
|
|
2116
|
-
},
|
|
2117
|
-
"returns paginated results": (ctx) => {
|
|
2118
|
-
const { Car } = setup(ctx);
|
|
2119
|
-
for (let i = 0; i < 5; i++) {
|
|
2120
|
-
const c = new Car();
|
|
2121
|
-
c.make = "Make" + i;
|
|
2122
|
-
c.model = "Model" + i;
|
|
2123
|
-
c.save();
|
|
2124
|
-
}
|
|
2125
|
-
const page = Car.select(0, 2);
|
|
2126
|
-
assertEqual(page.items.length, 2, "select should return 2 items");
|
|
2127
|
-
assertEqual(page.total, 5, "select total should be 5");
|
|
2128
|
-
},
|
|
2129
|
-
"groups entities by field": (ctx) => {
|
|
2130
|
-
const { Car } = setup(ctx);
|
|
2131
|
-
const c1 = new Car();
|
|
2132
|
-
c1.make = "Toyota";
|
|
2133
|
-
c1.model = "Corolla";
|
|
2134
|
-
c1.save();
|
|
2135
|
-
const c2 = new Car();
|
|
2136
|
-
c2.make = "Toyota";
|
|
2137
|
-
c2.model = "Camry";
|
|
2138
|
-
c2.save();
|
|
2139
|
-
const c3 = new Car();
|
|
2140
|
-
c3.make = "Honda";
|
|
2141
|
-
c3.model = "Civic";
|
|
2142
|
-
c3.save();
|
|
2143
|
-
const groups = Car.groupBy("make");
|
|
2144
|
-
assertTrue(groups.length >= 2, "groupBy should return at least 2 groups");
|
|
2145
|
-
},
|
|
2146
|
-
"creates separate tables for each class": (ctx) => {
|
|
2147
|
-
const { Car, Product } = setup(ctx);
|
|
2148
|
-
const car = new Car();
|
|
2149
|
-
car.make = "Toyota";
|
|
2150
|
-
car.model = "Corolla";
|
|
2151
|
-
car.save();
|
|
2152
|
-
const prod = new Product();
|
|
2153
|
-
prod.name = "Widget";
|
|
2154
|
-
prod.price = 9.99;
|
|
2155
|
-
prod.save();
|
|
2156
|
-
assertEqual(Car.count(), 1, "car count should be 1");
|
|
2157
|
-
assertEqual(Product.count(), 1, "product count should be 1");
|
|
2158
|
-
},
|
|
2159
|
-
"works with class reference (typed)": (ctx) => {
|
|
2160
|
-
const { Car } = setup(ctx);
|
|
2161
|
-
const c = new Car();
|
|
2162
|
-
c.make = "Toyota";
|
|
2163
|
-
c.model = "Corolla";
|
|
2164
|
-
c.save();
|
|
2165
|
-
const results = Query.from(Car).execute();
|
|
2166
|
-
assertEqual(results.length, 1, "Query.from(class) should work");
|
|
2167
|
-
},
|
|
2168
|
-
"works with string name": (ctx) => {
|
|
2169
|
-
const { Car } = setup(ctx);
|
|
2170
|
-
const c = new Car();
|
|
2171
|
-
c.make = "Toyota";
|
|
2172
|
-
c.model = "Corolla";
|
|
2173
|
-
c.save();
|
|
2174
|
-
const results = Query.from("Car").execute();
|
|
2175
|
-
assertEqual(results.length, 1, "Query.from(string) should work");
|
|
2176
|
-
},
|
|
2177
|
-
"works with table name string": (ctx) => {
|
|
2178
|
-
const { Car } = setup(ctx);
|
|
2179
|
-
const c = new Car();
|
|
2180
|
-
c.make = "Toyota";
|
|
2181
|
-
c.model = "Corolla";
|
|
2182
|
-
c.save();
|
|
2183
|
-
const results = Query.from(Car.tableName).execute();
|
|
2184
|
-
assertEqual(results.length, 1, "Query.from(tableName) should work");
|
|
2185
|
-
},
|
|
2186
|
-
"supports full fluent chain": (ctx) => {
|
|
2187
|
-
const { Car } = setup(ctx);
|
|
2188
|
-
const c1 = new Car();
|
|
2189
|
-
c1.make = "Toyota";
|
|
2190
|
-
c1.model = "Corolla";
|
|
2191
|
-
c1.year = 2020;
|
|
2192
|
-
c1.save();
|
|
2193
|
-
const c2 = new Car();
|
|
2194
|
-
c2.make = "Toyota";
|
|
2195
|
-
c2.model = "Camry";
|
|
2196
|
-
c2.year = 2024;
|
|
2197
|
-
c2.save();
|
|
2198
|
-
const c3 = new Car();
|
|
2199
|
-
c3.make = "Honda";
|
|
2200
|
-
c3.model = "Civic";
|
|
2201
|
-
c3.year = 2022;
|
|
2202
|
-
c3.save();
|
|
2203
|
-
const results = Query.from(Car)
|
|
2204
|
-
.where("make", "=", "Toyota")
|
|
2205
|
-
.orderBy("year", "desc")
|
|
2206
|
-
.limit(1)
|
|
2207
|
-
.execute();
|
|
2208
|
-
assertEqual(results.length, 1, "fluent chain should return 1 result");
|
|
2209
|
-
assertEqual(results[0].year, 2024, "fluent chain should return newest Toyota");
|
|
2210
|
-
},
|
|
2211
|
-
"throws for unknown class name": (ctx) => {
|
|
2212
|
-
setup(ctx);
|
|
2213
|
-
assertThrows(() => Query.from("UnknownClassName"), /unknown|not found|not registered/i, "Query.from with unknown name should throw");
|
|
2214
|
-
},
|
|
2215
|
-
"create → query → update → delete cycle": (ctx) => {
|
|
2216
|
-
const { Car } = setup(ctx);
|
|
2217
|
-
const car = new Car();
|
|
2218
|
-
car.make = "Toyota";
|
|
2219
|
-
car.model = "Corolla";
|
|
2220
|
-
car.year = 2024;
|
|
2221
|
-
car.color = "blue";
|
|
2222
|
-
car.save();
|
|
2223
|
-
const found = Car.findById(car.__id);
|
|
2224
|
-
assertTrue(found !== null, "should find created car");
|
|
2225
|
-
assertEqual(found.color, "blue", "color should be blue");
|
|
2226
|
-
found.color = "red";
|
|
2227
|
-
found.save();
|
|
2228
|
-
const updated = Car.findById(car.__id);
|
|
2229
|
-
assertEqual(updated.color, "red", "color should be updated to red");
|
|
2230
|
-
updated.delete();
|
|
2231
|
-
const deleted = Car.findById(car.__id);
|
|
2232
|
-
assertTrue(deleted === null, "deleted car should not be found");
|
|
2233
|
-
},
|
|
2234
|
-
"works with Query.from() end-to-end": (ctx) => {
|
|
2235
|
-
const { Car } = setup(ctx);
|
|
2236
|
-
const c1 = new Car();
|
|
2237
|
-
c1.make = "Toyota";
|
|
2238
|
-
c1.model = "Corolla";
|
|
2239
|
-
c1.year = 2024;
|
|
2240
|
-
c1.save();
|
|
2241
|
-
const c2 = new Car();
|
|
2242
|
-
c2.make = "Honda";
|
|
2243
|
-
c2.model = "Civic";
|
|
2244
|
-
c2.year = 2023;
|
|
2245
|
-
c2.save();
|
|
2246
|
-
const results = Query.from(Car).where("make", "=", "Toyota").execute();
|
|
2247
|
-
assertEqual(results.length, 1, "Query.from e2e should return 1");
|
|
2248
|
-
assertEqual(results[0].make, "Toyota", "result should be Toyota");
|
|
2249
|
-
},
|
|
2250
|
-
"count() with whereGroups counts only matching entities": (ctx) => {
|
|
2251
|
-
const { Car } = setup(ctx);
|
|
2252
|
-
const c1 = new Car();
|
|
2253
|
-
c1.make = "Toyota";
|
|
2254
|
-
c1.model = "Corolla";
|
|
2255
|
-
c1.save();
|
|
2256
|
-
const c2 = new Car();
|
|
2257
|
-
c2.make = "Honda";
|
|
2258
|
-
c2.model = "Civic";
|
|
2259
|
-
c2.save();
|
|
2260
|
-
const c3 = new Car();
|
|
2261
|
-
c3.make = "Toyota";
|
|
2262
|
-
c3.model = "Camry";
|
|
2263
|
-
c3.save();
|
|
2264
|
-
const count = Car.count({
|
|
2265
|
-
whereGroups: [
|
|
2266
|
-
[{ field: "make", operator: "=", value: "Honda" }],
|
|
2267
|
-
[{ field: "model", operator: "=", value: "Camry" }],
|
|
2268
|
-
],
|
|
2269
|
-
});
|
|
2270
|
-
assertEqual(count, 2, "count with whereGroups should be 2");
|
|
2271
|
-
},
|
|
2272
|
-
"deleteAll() with whereGroups deletes only matching entities": (ctx) => {
|
|
2273
|
-
const { Car } = setup(ctx);
|
|
2274
|
-
const c1 = new Car();
|
|
2275
|
-
c1.make = "Toyota";
|
|
2276
|
-
c1.model = "Corolla";
|
|
2277
|
-
c1.save();
|
|
2278
|
-
const c2 = new Car();
|
|
2279
|
-
c2.make = "Honda";
|
|
2280
|
-
c2.model = "Civic";
|
|
2281
|
-
c2.save();
|
|
2282
|
-
const c3 = new Car();
|
|
2283
|
-
c3.make = "Toyota";
|
|
2284
|
-
c3.model = "Camry";
|
|
2285
|
-
c3.save();
|
|
2286
|
-
const deleted = Car.deleteAll({
|
|
2287
|
-
whereGroups: [
|
|
2288
|
-
[{ field: "make", operator: "=", value: "Honda" }],
|
|
2289
|
-
[{ field: "model", operator: "=", value: "Camry" }],
|
|
2290
|
-
],
|
|
2291
|
-
});
|
|
2292
|
-
assertEqual(deleted, 2, "deleteAll with whereGroups should delete 2");
|
|
2293
|
-
assertEqual(Car.count(), 1, "one entity should remain");
|
|
2294
|
-
},
|
|
2295
|
-
"select() with whereGroups paginates only matching entities": (ctx) => {
|
|
2296
|
-
const { Car } = setup(ctx);
|
|
2297
|
-
const c1 = new Car();
|
|
2298
|
-
c1.make = "Toyota";
|
|
2299
|
-
c1.model = "Corolla";
|
|
2300
|
-
c1.save();
|
|
2301
|
-
const c2 = new Car();
|
|
2302
|
-
c2.make = "Honda";
|
|
2303
|
-
c2.model = "Civic";
|
|
2304
|
-
c2.save();
|
|
2305
|
-
const c3 = new Car();
|
|
2306
|
-
c3.make = "Toyota";
|
|
2307
|
-
c3.model = "Camry";
|
|
2308
|
-
c3.save();
|
|
2309
|
-
const page = Car.select(0, 10, {
|
|
2310
|
-
whereGroups: [
|
|
2311
|
-
[{ field: "make", operator: "=", value: "Honda" }],
|
|
2312
|
-
[{ field: "model", operator: "=", value: "Camry" }],
|
|
2313
|
-
],
|
|
2314
|
-
});
|
|
2315
|
-
assertEqual(page.total, 2, "select total with whereGroups should be 2");
|
|
2316
|
-
assertEqual(page.items.length, 2, "select items with whereGroups should be 2");
|
|
2317
|
-
},
|
|
2318
|
-
"groupBy() with whereGroups groups only matching entities": (ctx) => {
|
|
2319
|
-
const { Car } = setup(ctx);
|
|
2320
|
-
const c1 = new Car();
|
|
2321
|
-
c1.make = "Toyota";
|
|
2322
|
-
c1.model = "Corolla";
|
|
2323
|
-
c1.save();
|
|
2324
|
-
const c2 = new Car();
|
|
2325
|
-
c2.make = "Honda";
|
|
2326
|
-
c2.model = "Civic";
|
|
2327
|
-
c2.save();
|
|
2328
|
-
const c3 = new Car();
|
|
2329
|
-
c3.make = "Toyota";
|
|
2330
|
-
c3.model = "Camry";
|
|
2331
|
-
c3.save();
|
|
2332
|
-
const groups = Car.groupBy("make", {
|
|
2333
|
-
whereGroups: [
|
|
2334
|
-
[{ field: "make", operator: "=", value: "Honda" }],
|
|
2335
|
-
[{ field: "model", operator: "=", value: "Camry" }],
|
|
2336
|
-
],
|
|
2337
|
-
});
|
|
2338
|
-
assertEqual(groups.length, 2, "groupBy with whereGroups should have 2 groups");
|
|
2339
|
-
},
|
|
2340
|
-
"invalidates cache when saveAll() fails mid-operation": (ctx) => {
|
|
2341
|
-
const { Car } = setup(ctx);
|
|
2342
|
-
const car = new Car();
|
|
2343
|
-
car.make = "Toyota";
|
|
2344
|
-
car.model = "Corolla";
|
|
2345
|
-
car.save();
|
|
2346
|
-
let threw = false;
|
|
2347
|
-
try {
|
|
2348
|
-
Car.saveAll([
|
|
2349
|
-
{ make: "Honda", model: "Civic" },
|
|
2350
|
-
{ make: "BMW" }, // missing required 'model'
|
|
2351
|
-
]);
|
|
2352
|
-
}
|
|
2353
|
-
catch {
|
|
2354
|
-
threw = true;
|
|
2355
|
-
}
|
|
2356
|
-
assertTrue(threw, "saveAll should throw on missing required field");
|
|
2357
|
-
const all = Car.find();
|
|
2358
|
-
assertEqual(all.length, 1, "only the original car should remain after failed saveAll");
|
|
2359
|
-
},
|
|
2360
|
-
"does not persist earlier updates when a later saveAll entry fails": (ctx) => {
|
|
2361
|
-
const { Car } = setup(ctx);
|
|
2362
|
-
const car = new Car();
|
|
2363
|
-
car.make = "Toyota";
|
|
2364
|
-
car.model = "Corolla";
|
|
2365
|
-
car.year = 2024;
|
|
2366
|
-
car.color = "blue";
|
|
2367
|
-
car.save();
|
|
2368
|
-
let threw = false;
|
|
2369
|
-
try {
|
|
2370
|
-
Car.saveAll([
|
|
2371
|
-
{ __id: car.__id, make: "Toyota", model: "Camry", year: 2025, color: "black" },
|
|
2372
|
-
{ make: "BMW" },
|
|
2373
|
-
]);
|
|
2374
|
-
}
|
|
2375
|
-
catch {
|
|
2376
|
-
threw = true;
|
|
2377
|
-
}
|
|
2378
|
-
assertTrue(threw, "saveAll should throw when a later entry is invalid");
|
|
2379
|
-
Registry.getInstance().clearCache();
|
|
2380
|
-
const reloaded = Car.findById(car.__id);
|
|
2381
|
-
assertTrue(reloaded !== null, "original entity should still exist");
|
|
2382
|
-
assertEqual(reloaded.model, "Corolla", "earlier update should not persist after failure");
|
|
2383
|
-
assertEqual(reloaded.year, 2024, "year should remain unchanged after failed saveAll");
|
|
2384
|
-
assertEqual(reloaded.color, "blue", "color should remain unchanged after failed saveAll");
|
|
2385
|
-
},
|
|
2386
|
-
"invalidates cache and re-throws on mid-batch failure": (ctx) => {
|
|
2387
|
-
const { Car } = setup(ctx);
|
|
2388
|
-
const car = new Car();
|
|
2389
|
-
car.make = "Toyota";
|
|
2390
|
-
car.model = "Corolla";
|
|
2391
|
-
car.save();
|
|
2392
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2393
|
-
repo.beginBatch();
|
|
2394
|
-
repo.save({ make: "Honda", model: "Civic" });
|
|
2395
|
-
repo.save({ make: "BMW" });
|
|
2396
|
-
let threw = false;
|
|
2397
|
-
try {
|
|
2398
|
-
repo.commitBatch();
|
|
2399
|
-
}
|
|
2400
|
-
catch {
|
|
2401
|
-
threw = true;
|
|
2402
|
-
}
|
|
2403
|
-
assertTrue(threw, "commitBatch should throw on missing required field");
|
|
2404
|
-
const all = Car.find();
|
|
2405
|
-
assertEqual(all.length, 2, "partially committed data should be readable");
|
|
2406
|
-
},
|
|
2407
|
-
"batch is no longer active after commitBatch error": (ctx) => {
|
|
2408
|
-
const { Car } = setup(ctx);
|
|
2409
|
-
const car = new Car();
|
|
2410
|
-
car.make = "Toyota";
|
|
2411
|
-
car.model = "Corolla";
|
|
2412
|
-
car.save();
|
|
2413
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2414
|
-
repo.beginBatch();
|
|
2415
|
-
repo.save({ make: "Fail" });
|
|
2416
|
-
try {
|
|
2417
|
-
repo.commitBatch();
|
|
2418
|
-
}
|
|
2419
|
-
catch {
|
|
2420
|
-
// expected
|
|
2421
|
-
}
|
|
2422
|
-
assertEqual(repo.isBatchActive(), false, "batch should not be active after error");
|
|
2423
|
-
},
|
|
2424
|
-
"clears class maps on re-configure": (ctx) => {
|
|
2425
|
-
const { Car } = setup(ctx);
|
|
2426
|
-
const car = new Car();
|
|
2427
|
-
car.make = "Test";
|
|
2428
|
-
car.model = "Model";
|
|
2429
|
-
car.save();
|
|
2430
|
-
const registry = Registry.getInstance();
|
|
2431
|
-
assertTrue(registry.getClassByName(Car.name) !== undefined, "class should be registered");
|
|
2432
|
-
registry.configure({ adapter: ctx.state.getAdapter() });
|
|
2433
|
-
assertEqual(registry.getClassByName(Car.name), undefined, "class should be cleared after re-configure");
|
|
2434
|
-
},
|
|
2435
|
-
"getClassByName resolves class by table name fallback": (ctx) => {
|
|
2436
|
-
const { Car } = setup(ctx);
|
|
2437
|
-
Car.create({ make: "Toyota", model: "Corolla" }).save();
|
|
2438
|
-
const registry = Registry.getInstance();
|
|
2439
|
-
const resolved = registry.getClassByName(Car.tableName);
|
|
2440
|
-
assertTrue(resolved !== undefined, "class should resolve by table name fallback");
|
|
2441
|
-
assertTrue(resolved === Car, "resolved class should be the Car constructor");
|
|
2442
|
-
},
|
|
2443
|
-
"clears entity cache and allows re-read from sheet": (ctx) => {
|
|
2444
|
-
const { Car } = setup(ctx);
|
|
2445
|
-
const car = new Car();
|
|
2446
|
-
car.make = "Toyota";
|
|
2447
|
-
car.model = "Corolla";
|
|
2448
|
-
car.save();
|
|
2449
|
-
Car.find(); // populate cache
|
|
2450
|
-
Registry.getInstance().clearCache();
|
|
2451
|
-
const all = Car.find();
|
|
2452
|
-
assertEqual(all.length, 1, "should re-read from sheet after cache clear");
|
|
2453
|
-
assertEqual(all[0].make, "Toyota", "re-read data should match");
|
|
2454
|
-
},
|
|
2455
|
-
"falls back to sheet scan when cache entry is null": (ctx) => {
|
|
2456
|
-
const { Car } = setup(ctx);
|
|
2457
|
-
const car = new Car();
|
|
2458
|
-
car.make = "Toyota";
|
|
2459
|
-
car.model = "Corolla";
|
|
2460
|
-
car.year = 2020;
|
|
2461
|
-
car.save();
|
|
2462
|
-
const id = car.__id;
|
|
2463
|
-
// Tamper with cache entry to simulate stale/null slot
|
|
2464
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2465
|
-
const cache = repo.cache;
|
|
2466
|
-
const cacheKey = repo.dataCacheKey;
|
|
2467
|
-
const cached = cache.get(cacheKey);
|
|
2468
|
-
if (cached)
|
|
2469
|
-
cached[0] = undefined;
|
|
2470
|
-
// Update should fall through to sheet scan
|
|
2471
|
-
const updatedCar = new Car();
|
|
2472
|
-
updatedCar.__id = id;
|
|
2473
|
-
updatedCar.make = "Toyota";
|
|
2474
|
-
updatedCar.model = "Corolla";
|
|
2475
|
-
updatedCar.year = 2025;
|
|
2476
|
-
updatedCar.save();
|
|
2477
|
-
Registry.getInstance().clearCache();
|
|
2478
|
-
const refetched = Car.findById(id);
|
|
2479
|
-
assertTrue(refetched !== null, "should find via fallback scan");
|
|
2480
|
-
assertEqual(refetched.year, 2025, "year should be updated after fallback save");
|
|
2481
|
-
},
|
|
2482
|
-
"skips rows with empty __id": (ctx) => {
|
|
2483
|
-
const { Car } = setup(ctx);
|
|
2484
|
-
const car = new Car();
|
|
2485
|
-
car.make = "Honda";
|
|
2486
|
-
car.model = "Civic";
|
|
2487
|
-
car.year = 2022;
|
|
2488
|
-
car.save();
|
|
2489
|
-
// Inject empty row directly into sheet
|
|
2490
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2491
|
-
const sheet = repo.getSheet();
|
|
2492
|
-
sheet.appendRow(["", "", "", "", "", ""]);
|
|
2493
|
-
Registry.getInstance().clearCache();
|
|
2494
|
-
const all = Car.find();
|
|
2495
|
-
assertEqual(all.length, 1, "empty __id row should be filtered out");
|
|
2496
|
-
assertEqual(all[0].make, "Honda", "valid entity should remain");
|
|
2497
|
-
},
|
|
2498
|
-
"persists both new and updated entities in a single batch": (ctx) => {
|
|
2499
|
-
const { Car } = setup(ctx);
|
|
2500
|
-
const car = new Car();
|
|
2501
|
-
car.make = "Honda";
|
|
2502
|
-
car.model = "Civic";
|
|
2503
|
-
car.year = 2020;
|
|
2504
|
-
car.color = "Red";
|
|
2505
|
-
car.save();
|
|
2506
|
-
const existingId = car.__id;
|
|
2507
|
-
Car.saveAll([
|
|
2508
|
-
{ __id: existingId, make: "Honda", model: "Accord", year: 2021, color: "Blue" },
|
|
2509
|
-
{ make: "Toyota", model: "Camry", year: 2022, color: "White" },
|
|
2510
|
-
{ make: "Ford", model: "Focus", year: 2023, color: "Black" },
|
|
2511
|
-
]);
|
|
2512
|
-
assertEqual(Car.count(), 3, "saveAll mixed batch should result in 3 entities");
|
|
2513
|
-
const updated = Car.findById(existingId);
|
|
2514
|
-
assertTrue(updated !== null, "updated entity should be found");
|
|
2515
|
-
assertEqual(updated.model, "Accord", "existing entity model should be updated");
|
|
2516
|
-
assertEqual(updated.color, "Blue", "existing entity color should be updated");
|
|
2517
|
-
},
|
|
2518
|
-
"returns an entity when called with no filter": (ctx) => {
|
|
2519
|
-
const { Car } = setup(ctx);
|
|
2520
|
-
const c1 = new Car();
|
|
2521
|
-
c1.make = "Honda";
|
|
2522
|
-
c1.model = "Civic";
|
|
2523
|
-
c1.year = 2020;
|
|
2524
|
-
c1.save();
|
|
2525
|
-
const c2 = new Car();
|
|
2526
|
-
c2.make = "Toyota";
|
|
2527
|
-
c2.model = "Camry";
|
|
2528
|
-
c2.year = 2021;
|
|
2529
|
-
c2.save();
|
|
2530
|
-
const result = Car.findOne();
|
|
2531
|
-
assertTrue(result !== null, "findOne() without args should return an entity");
|
|
2532
|
-
assertTrue(!!result.__id, "returned entity should have an __id");
|
|
2533
|
-
},
|
|
2534
|
-
"deletes all entities and returns the count": (ctx) => {
|
|
2535
|
-
const { Car } = setup(ctx);
|
|
2536
|
-
for (let i = 0; i < 3; i++) {
|
|
2537
|
-
const c = new Car();
|
|
2538
|
-
c.make = "Brand";
|
|
2539
|
-
c.model = "Model" + i;
|
|
2540
|
-
c.year = 2020 + i;
|
|
2541
|
-
c.save();
|
|
2542
|
-
}
|
|
2543
|
-
assertEqual(Car.count(), 3, "should have 3 entities before deleteAll");
|
|
2544
|
-
const deleted = Car.deleteAll();
|
|
2545
|
-
assertEqual(deleted, 3, "deleteAll without args should return 3");
|
|
2546
|
-
assertEqual(Car.count(), 0, "count should be 0 after deleteAll");
|
|
2547
|
-
},
|
|
2548
|
-
"does not overwrite an existing entity when a gap row is present": (ctx) => {
|
|
2549
|
-
const { Car } = setup(ctx);
|
|
2550
|
-
const car1 = new Car();
|
|
2551
|
-
car1.make = "Honda";
|
|
2552
|
-
car1.model = "Civic";
|
|
2553
|
-
car1.year = 2020;
|
|
2554
|
-
car1.save();
|
|
2555
|
-
const id1 = car1.__id;
|
|
2556
|
-
// Inject a gap row directly into the sheet
|
|
2557
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2558
|
-
const sheet = repo.getSheet();
|
|
2559
|
-
sheet.appendRow(["", "", "", "", "", ""]);
|
|
2560
|
-
const car2 = new Car();
|
|
2561
|
-
car2.make = "Toyota";
|
|
2562
|
-
car2.model = "Camry";
|
|
2563
|
-
car2.year = 2021;
|
|
2564
|
-
car2.save();
|
|
2565
|
-
Registry.getInstance().clearCache();
|
|
2566
|
-
assertEqual(Car.count(), 2, "both valid entities should exist after gap-row save");
|
|
2567
|
-
const found1 = Car.findById(id1);
|
|
2568
|
-
assertTrue(found1 !== null, "first entity should not be overwritten");
|
|
2569
|
-
assertEqual(found1.make, "Honda", "first entity make should remain Honda");
|
|
2570
|
-
},
|
|
2571
|
-
"findById returns correct entity after gap row": (ctx) => {
|
|
2572
|
-
const { Car } = setup(ctx);
|
|
2573
|
-
const car1 = new Car();
|
|
2574
|
-
car1.make = "Honda";
|
|
2575
|
-
car1.model = "Civic";
|
|
2576
|
-
car1.year = 2020;
|
|
2577
|
-
car1.save();
|
|
2578
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2579
|
-
const sheet = repo.getSheet();
|
|
2580
|
-
sheet.appendRow(["", "", "", "", "", ""]);
|
|
2581
|
-
const car2 = new Car();
|
|
2582
|
-
car2.make = "Toyota";
|
|
2583
|
-
car2.model = "Camry";
|
|
2584
|
-
car2.year = 2021;
|
|
2585
|
-
car2.save();
|
|
2586
|
-
Registry.getInstance().clearCache();
|
|
2587
|
-
const found2 = Car.findById(car2.__id);
|
|
2588
|
-
assertTrue(found2 !== null, "findById should return the correct entity after gap row");
|
|
2589
|
-
assertEqual(found2.make, "Toyota", "entity should have correct make after gap row");
|
|
2590
|
-
assertEqual(found2.__id, car2.__id, "entity should have correct __id after gap row");
|
|
2591
|
-
},
|
|
2592
|
-
"assigns correct row indices so delete targets the right entity": (ctx) => {
|
|
2593
|
-
const { Car } = setup(ctx);
|
|
2594
|
-
Car.saveAll([
|
|
2595
|
-
{ make: "Alpha", model: "A1", year: 2020, color: "Red" },
|
|
2596
|
-
{ make: "Beta", model: "B1", year: 2021, color: "Blue" },
|
|
2597
|
-
]);
|
|
2598
|
-
assertEqual(Car.count(), 2, "saveAll should create two entities");
|
|
2599
|
-
const alpha = Car.findOne({ where: [{ field: "make", operator: "=", value: "Alpha" }] });
|
|
2600
|
-
const beta = Car.findOne({ where: [{ field: "make", operator: "=", value: "Beta" }] });
|
|
2601
|
-
assertTrue(alpha !== null, "Alpha should exist");
|
|
2602
|
-
assertTrue(beta !== null, "Beta should exist");
|
|
2603
|
-
beta.delete();
|
|
2604
|
-
assertEqual(Car.count(), 1, "count should be 1 after deleting Beta");
|
|
2605
|
-
const remaining = Car.findById(alpha.__id);
|
|
2606
|
-
assertTrue(remaining !== null, "Alpha should still exist after deleting Beta");
|
|
2607
|
-
assertEqual(remaining.make, "Alpha", "remaining entity should be Alpha");
|
|
2608
|
-
assertEqual(Car.findById(beta.__id), null, "Beta should be deleted");
|
|
2609
|
-
},
|
|
2610
|
-
"discards buffered operations without writing": (ctx) => {
|
|
2611
|
-
const { Car } = setup(ctx);
|
|
2612
|
-
const car = new Car();
|
|
2613
|
-
car.make = "Toyota";
|
|
2614
|
-
car.model = "Corolla";
|
|
2615
|
-
car.save();
|
|
2616
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2617
|
-
repo.beginBatch();
|
|
2618
|
-
repo.save({ make: "Ghost", model: "Phantom" });
|
|
2619
|
-
repo.rollbackBatch();
|
|
2620
|
-
assertEqual(Car.count(), 1, "count should still be 1 after rollback");
|
|
2621
|
-
const ghost = Car.findOne({ where: [{ field: "make", operator: "=", value: "Ghost" }] });
|
|
2622
|
-
assertEqual(ghost, null, "rolled back entity should not exist");
|
|
2623
|
-
},
|
|
2624
|
-
"beginBatch \u2192 save \u2192 delete \u2192 commitBatch applies all operations": (ctx) => {
|
|
2625
|
-
const { Car } = setup(ctx);
|
|
2626
|
-
const car = new Car();
|
|
2627
|
-
car.make = "Toyota";
|
|
2628
|
-
car.model = "Corolla";
|
|
2629
|
-
car.save();
|
|
2630
|
-
const id = car.__id;
|
|
2631
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2632
|
-
repo.beginBatch();
|
|
2633
|
-
repo.save({ make: "Honda", model: "Civic" });
|
|
2634
|
-
repo.delete(id);
|
|
2635
|
-
repo.commitBatch();
|
|
2636
|
-
assertEqual(Car.count(), 1, "should have 1 entity after commit");
|
|
2637
|
-
const honda = Car.findOne({ where: [{ field: "make", operator: "=", value: "Honda" }] });
|
|
2638
|
-
assertTrue(honda !== null, "Honda should be present");
|
|
2639
|
-
assertEqual(Car.findById(id), null, "Toyota should be deleted");
|
|
2640
|
-
},
|
|
2641
|
-
"count() during batch returns sheet state, not buffered state": (ctx) => {
|
|
2642
|
-
const { Car } = setup(ctx);
|
|
2643
|
-
const car = new Car();
|
|
2644
|
-
car.make = "Toyota";
|
|
2645
|
-
car.model = "Corolla";
|
|
2646
|
-
car.save();
|
|
2647
|
-
const repo = Registry.getInstance().ensureRepository(Car);
|
|
2648
|
-
repo.beginBatch();
|
|
2649
|
-
repo.save({ make: "Honda", model: "Civic" });
|
|
2650
|
-
assertEqual(Car.count(), 1, "count during batch should reflect sheet, not buffer");
|
|
2651
|
-
repo.commitBatch();
|
|
2652
|
-
assertEqual(Car.count(), 2, "count after commit should include new entity");
|
|
2653
|
-
},
|
|
2654
|
-
"returns correct entity without re-reading sheet data": (ctx) => {
|
|
2655
|
-
const { Car } = setup(ctx);
|
|
2656
|
-
const c1 = new Car();
|
|
2657
|
-
c1.make = "Alpha";
|
|
2658
|
-
c1.model = "A1";
|
|
2659
|
-
c1.save();
|
|
2660
|
-
const c2 = new Car();
|
|
2661
|
-
c2.make = "Beta";
|
|
2662
|
-
c2.model = "B1";
|
|
2663
|
-
c2.save();
|
|
2664
|
-
// Force cache load
|
|
2665
|
-
Car.find();
|
|
2666
|
-
// findById should use cached data
|
|
2667
|
-
const found = Car.findById(c2.__id);
|
|
2668
|
-
assertTrue(found !== null, "findById should return the entity from cache");
|
|
2669
|
-
assertEqual(found.make, "Beta", "cached entity should have correct make");
|
|
2670
|
-
},
|
|
2671
|
-
"delete() returns false on unsaved record (no __id)": (ctx) => {
|
|
2672
|
-
const { Car } = setup(ctx);
|
|
2673
|
-
const car = new Car();
|
|
2674
|
-
car.make = "Ghost";
|
|
2675
|
-
car.model = "Phantom";
|
|
2676
|
-
assertTrue(!car.delete(), "delete() should return false when __id is missing");
|
|
2677
|
-
},
|
|
2678
|
-
"toJSON() returns undefined __id for unsaved entity": (ctx) => {
|
|
2679
|
-
const { Car } = setup(ctx);
|
|
2680
|
-
const car = Car.create({ make: "Ghost", model: "Phantom", year: 2024, color: "black" });
|
|
2681
|
-
const json = car.toJSON();
|
|
2682
|
-
assertEqual(json.__id, undefined, "unsaved entity toJSON should have undefined __id");
|
|
2683
|
-
assertEqual(json.make, "Ghost", "toJSON make should match");
|
|
2684
|
-
assertEqual(json.model, "Phantom", "toJSON model should match");
|
|
2685
|
-
},
|
|
2686
|
-
"saveAll() with empty array returns empty array": (ctx) => {
|
|
2687
|
-
const { Car } = setup(ctx);
|
|
2688
|
-
const result = Car.saveAll([]);
|
|
2689
|
-
assertDeepEqual(result, [], "saveAll with empty array should return empty array");
|
|
2690
|
-
},
|
|
2691
|
-
"deleteAll() returns zero when no entities match filter": (ctx) => {
|
|
2692
|
-
const { Car } = setup(ctx);
|
|
2693
|
-
Car.create({ make: "Toyota", model: "Supra", year: 2020, color: "white" }).save();
|
|
2694
|
-
const count = Car.deleteAll({ where: [{ field: "make", operator: "=", value: "NonExistent" }] });
|
|
2695
|
-
assertEqual(count, 0, "deleteAll with non-matching filter should return 0");
|
|
2696
|
-
assertEqual(Car.count(), 1, "original entity should still exist");
|
|
2697
|
-
},
|
|
2698
|
-
"save() includes null fields but excludes undefined fields": (ctx) => {
|
|
2699
|
-
const { Car } = setup(ctx);
|
|
2700
|
-
const car = new Car();
|
|
2701
|
-
car.make = "Toyota";
|
|
2702
|
-
car.model = "Corolla";
|
|
2703
|
-
car.year = 2024;
|
|
2704
|
-
car.color = null;
|
|
2705
|
-
car.save();
|
|
2706
|
-
const found = Car.findById(car.__id);
|
|
2707
|
-
assertTrue(found !== null, "saved entity should be found");
|
|
2708
|
-
assertEqual(found.color, null, "null field should be preserved as null");
|
|
2709
|
-
},
|
|
2710
|
-
"Query.from(Class) auto-registers class without prior save": (ctx) => {
|
|
2711
|
-
const { Car } = setup(ctx);
|
|
2712
|
-
const query = Query.from(Car);
|
|
2713
|
-
assertTrue(query !== undefined && query !== null, "Query.from should return a query");
|
|
2714
|
-
const results = query.execute();
|
|
2715
|
-
assertDeepEqual(results, [], "query on empty table should return empty array");
|
|
2716
|
-
},
|
|
2717
|
-
"deleteAll uses individual deletes for 2 entities and bulk for 3": (ctx) => {
|
|
2718
|
-
const { Car } = setup(ctx);
|
|
2719
|
-
// Create exactly 3 entities — triggers bulk path (> 2)
|
|
2720
|
-
Car.create({ make: "A", model: "A1", year: 2020, color: "red" }).save();
|
|
2721
|
-
Car.create({ make: "B", model: "B1", year: 2021, color: "blue" }).save();
|
|
2722
|
-
Car.create({ make: "C", model: "C1", year: 2022, color: "green" }).save();
|
|
2723
|
-
assertEqual(Car.count(), 3, "should have 3 entities before bulk delete");
|
|
2724
|
-
const deletedBulk = Car.deleteAll();
|
|
2725
|
-
assertEqual(deletedBulk, 3, "deleteAll should return 3 for bulk path");
|
|
2726
|
-
assertEqual(Car.count(), 0, "count should be 0 after bulk delete");
|
|
2727
|
-
// Create exactly 2 entities — triggers individual path (<= 2)
|
|
2728
|
-
Car.create({ make: "D", model: "D1", year: 2023, color: "black" }).save();
|
|
2729
|
-
Car.create({ make: "E", model: "E1", year: 2024, color: "white" }).save();
|
|
2730
|
-
assertEqual(Car.count(), 2, "should have 2 entities before individual delete");
|
|
2731
|
-
const deletedIndiv = Car.deleteAll();
|
|
2732
|
-
assertEqual(deletedIndiv, 2, "deleteAll should return 2 for individual path");
|
|
2733
|
-
assertEqual(Car.count(), 0, "count should be 0 after individual delete");
|
|
2734
|
-
},
|
|
2735
|
-
"bulk deleteAll removes gap rows from sheet as expected": (ctx) => {
|
|
2736
|
-
const { Car } = setup(ctx);
|
|
2737
|
-
Car.create({ make: "Ford", model: "Fiesta", year: 2020 }).save();
|
|
2738
|
-
Car.create({ make: "Ford", model: "Focus", year: 2021 }).save();
|
|
2739
|
-
Car.create({ make: "Ford", model: "Mondeo", year: 2022 }).save();
|
|
2740
|
-
Car.create({ make: "Tesla", model: "Model S", year: 2023 }).save();
|
|
2741
|
-
assertEqual(Car.count(), 4, "should have 4 entities before deletion");
|
|
2742
|
-
const deleted = Car.deleteAll({ where: [{ field: "make", operator: "=", value: "Ford" }] });
|
|
2743
|
-
assertEqual(deleted, 3, "should delete 3 Ford entities via bulk path");
|
|
2744
|
-
assertEqual(Car.count(), 1, "should have 1 Tesla remaining after bulk deleteAll");
|
|
2745
|
-
const remaining = Car.find();
|
|
2746
|
-
assertEqual(remaining[0].make, "Tesla", "remaining entity should be Tesla");
|
|
2747
|
-
},
|
|
2748
|
-
"indexed search with additional where filter narrows results": (ctx) => {
|
|
2749
|
-
const { Car } = setup(ctx);
|
|
2750
|
-
Car.create({ make: "Toyota", model: "Corolla", year: 2020 }).save();
|
|
2751
|
-
Car.create({ make: "Toyota", model: "Supra", year: 2024 }).save();
|
|
2752
|
-
Car.create({ make: "Toymaster", model: "X1", year: 2023 }).save();
|
|
2753
|
-
Car.create({ make: "Honda", model: "Civic", year: 2022 }).save();
|
|
2754
|
-
// "toy" matches Toyota (x2) and Toymaster (x1)
|
|
2755
|
-
// year >= 2023 keeps Toyota Supra (2024) + Toymaster X1 (2023)
|
|
2756
|
-
const results = Car.find({
|
|
2757
|
-
where: [
|
|
2758
|
-
{ field: "make", operator: "search", value: "toy" },
|
|
2759
|
-
{ field: "year", operator: ">=", value: 2023 },
|
|
2760
|
-
],
|
|
2761
|
-
});
|
|
2762
|
-
assertEqual(results.length, 2, "indexed search with year >= 2023 should return 2 results");
|
|
2763
|
-
const models = results.map((c) => c.model).sort();
|
|
2764
|
-
assertEqual(models[0], "Supra", "first sorted model should be Supra");
|
|
2765
|
-
assertEqual(models[1], "X1", "second sorted model should be X1");
|
|
2766
|
-
},
|
|
2767
|
-
"Field defaultValue fills missing property on save": (ctx) => {
|
|
2768
|
-
const adapter = ctx.state.getAdapter();
|
|
2769
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2770
|
-
Registry.reset();
|
|
2771
|
-
resetDecoratorCaches();
|
|
2772
|
-
Registry.getInstance().configure({ adapter });
|
|
2773
|
-
class Ticket extends BaseRecord {
|
|
2774
|
-
static get tableName() {
|
|
2775
|
-
return `Tickets_${suffix}`;
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
__decorate([
|
|
2779
|
-
Field({ defaultValue: "open" })
|
|
2780
|
-
], Ticket.prototype, "status", void 0);
|
|
2781
|
-
const t = new Ticket();
|
|
2782
|
-
t.title = "Bug report";
|
|
2783
|
-
t.save();
|
|
2784
|
-
const found = Ticket.findById(t.__id);
|
|
2785
|
-
assertTrue(found !== null, "should find the saved Ticket");
|
|
2786
|
-
assertEqual(found.status, "open", "status should default to open when not provided");
|
|
2787
|
-
},
|
|
2788
|
-
"Field() without options still registers and persists the field": (ctx) => {
|
|
2789
|
-
const adapter = ctx.state.getAdapter();
|
|
2790
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2791
|
-
Registry.reset();
|
|
2792
|
-
resetDecoratorCaches();
|
|
2793
|
-
Registry.getInstance().configure({ adapter });
|
|
2794
|
-
class Note extends BaseRecord {
|
|
2795
|
-
static get tableName() {
|
|
2796
|
-
return `Notes_${suffix}`;
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
2799
|
-
__decorate([
|
|
2800
|
-
Field()
|
|
2801
|
-
], Note.prototype, "body", void 0);
|
|
2802
|
-
const n = new Note();
|
|
2803
|
-
n.body = "remember me";
|
|
2804
|
-
n.save();
|
|
2805
|
-
Registry.getInstance().clearCache();
|
|
2806
|
-
const found = Note.findById(n.__id);
|
|
2807
|
-
assertTrue(found !== null, "should find the saved Note");
|
|
2808
|
-
assertEqual(found.body, "remember me", "body should round-trip for @Field() without options");
|
|
2809
|
-
},
|
|
2810
|
-
"Field type date stores Date as ISO string in sheet round-trip": (ctx) => {
|
|
2811
|
-
const adapter = ctx.state.getAdapter();
|
|
2812
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2813
|
-
Registry.reset();
|
|
2814
|
-
resetDecoratorCaches();
|
|
2815
|
-
Registry.getInstance().configure({ adapter });
|
|
2816
|
-
class Article extends BaseRecord {
|
|
2817
|
-
static get tableName() {
|
|
2818
|
-
return `Articles_${suffix}`;
|
|
2819
|
-
}
|
|
2820
|
-
}
|
|
2821
|
-
__decorate([
|
|
2822
|
-
Field({ type: "date" })
|
|
2823
|
-
], Article.prototype, "publishedAt", void 0);
|
|
2824
|
-
const isoString = "2024-06-15T12:00:00.000Z";
|
|
2825
|
-
const date = new Date(isoString);
|
|
2826
|
-
const a = new Article();
|
|
2827
|
-
a.title = "Hello World";
|
|
2828
|
-
a.publishedAt = date;
|
|
2829
|
-
a.save();
|
|
2830
|
-
Registry.getInstance().clearCache();
|
|
2831
|
-
const found = Article.findById(a.__id);
|
|
2832
|
-
assertTrue(found !== null, "should find the saved Article");
|
|
2833
|
-
assertEqual(found.publishedAt, isoString, "Date should be stored and retrieved as ISO string after sheet read");
|
|
2834
|
-
},
|
|
2835
|
-
"resetDecoratorCaches() keeps @Field metadata effective": (ctx) => {
|
|
2836
|
-
const adapter = ctx.state.getAdapter();
|
|
2837
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2838
|
-
Registry.reset();
|
|
2839
|
-
resetDecoratorCaches();
|
|
2840
|
-
Registry.getInstance().configure({ adapter });
|
|
2841
|
-
class TicketReset extends BaseRecord {
|
|
2842
|
-
static get tableName() {
|
|
2843
|
-
return `Tickets_${suffix}`;
|
|
2844
|
-
}
|
|
2845
|
-
}
|
|
2846
|
-
__decorate([
|
|
2847
|
-
Field({ defaultValue: "open" })
|
|
2848
|
-
], TicketReset.prototype, "status", void 0);
|
|
2849
|
-
const t1 = new TicketReset();
|
|
2850
|
-
t1.title = "first";
|
|
2851
|
-
t1.save();
|
|
2852
|
-
const first = TicketReset.findById(t1.__id);
|
|
2853
|
-
assertTrue(first !== null, "first Ticket should exist");
|
|
2854
|
-
assertEqual(first.status, "open", "default value should apply before cache reset");
|
|
2855
|
-
resetDecoratorCaches();
|
|
2856
|
-
const t2 = new TicketReset();
|
|
2857
|
-
t2.title = "second";
|
|
2858
|
-
t2.save();
|
|
2859
|
-
Registry.getInstance().clearCache();
|
|
2860
|
-
const second = TicketReset.findById(t2.__id);
|
|
2861
|
-
assertTrue(second !== null, "second Ticket should exist");
|
|
2862
|
-
assertEqual(second.status, "open", "default value should still apply after cache reset");
|
|
2863
|
-
},
|
|
2864
|
-
"Field({ required: true }) enforces required validation": (ctx) => {
|
|
2865
|
-
const adapter = ctx.state.getAdapter();
|
|
2866
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2867
|
-
Registry.reset();
|
|
2868
|
-
resetDecoratorCaches();
|
|
2869
|
-
Registry.getInstance().configure({ adapter });
|
|
2870
|
-
class TaskWithFieldRequired extends BaseRecord {
|
|
2871
|
-
static get tableName() {
|
|
2872
|
-
return `Tasks_${suffix}`;
|
|
2873
|
-
}
|
|
2874
|
-
}
|
|
2875
|
-
__decorate([
|
|
2876
|
-
Field({ required: true })
|
|
2877
|
-
], TaskWithFieldRequired.prototype, "title", void 0);
|
|
2878
|
-
const task = new TaskWithFieldRequired();
|
|
2879
|
-
let threw = false;
|
|
2880
|
-
try {
|
|
2881
|
-
task.save();
|
|
2882
|
-
}
|
|
2883
|
-
catch (e) {
|
|
2884
|
-
threw = true;
|
|
2885
|
-
assertTrue(e.message.includes('Required field "title" is missing'), "error should mention missing required title");
|
|
2886
|
-
}
|
|
2887
|
-
assertTrue(threw, "save should throw when @Field({ required: true }) field is missing");
|
|
2888
|
-
},
|
|
2889
|
-
"returns the shared IndexStore instance": (ctx) => {
|
|
2890
|
-
const adapter = ctx.state.getAdapter();
|
|
2891
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2892
|
-
Registry.reset();
|
|
2893
|
-
resetDecoratorCaches();
|
|
2894
|
-
Registry.getInstance().configure({ adapter });
|
|
2895
|
-
class CarForIndexStore extends BaseRecord {
|
|
2896
|
-
static get tableName() {
|
|
2897
|
-
return `Cars_${suffix}`;
|
|
2898
|
-
}
|
|
2899
|
-
}
|
|
2900
|
-
__decorate([
|
|
2901
|
-
Required()
|
|
2902
|
-
], CarForIndexStore.prototype, "model", void 0);
|
|
2903
|
-
const c = new CarForIndexStore();
|
|
2904
|
-
c.make = "Toyota";
|
|
2905
|
-
c.model = "Corolla";
|
|
2906
|
-
c.save();
|
|
2907
|
-
const registry = Registry.getInstance();
|
|
2908
|
-
const indexStore = registry.getIndexStore();
|
|
2909
|
-
assertTrue(indexStore !== undefined && indexStore !== null, "getIndexStore() should return an IndexStore");
|
|
2910
|
-
assertTrue(registry.getIndexStore() === indexStore, "getIndexStore() should return the same instance on repeated calls");
|
|
2911
|
-
},
|
|
2912
|
-
"batch save updates existing entity when committed": (ctx) => {
|
|
2913
|
-
const adapter = ctx.state.getAdapter();
|
|
2914
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2915
|
-
Registry.reset();
|
|
2916
|
-
resetDecoratorCaches();
|
|
2917
|
-
Registry.getInstance().configure({ adapter });
|
|
2918
|
-
class CarBatch extends BaseRecord {
|
|
2919
|
-
static get tableName() {
|
|
2920
|
-
return `Cars_${suffix}`;
|
|
2921
|
-
}
|
|
2922
|
-
}
|
|
2923
|
-
__decorate([
|
|
2924
|
-
Required()
|
|
2925
|
-
], CarBatch.prototype, "model", void 0);
|
|
2926
|
-
const c = new CarBatch();
|
|
2927
|
-
c.make = "Honda";
|
|
2928
|
-
c.model = "Civic";
|
|
2929
|
-
c.year = 2020;
|
|
2930
|
-
c.save();
|
|
2931
|
-
const savedId = c.__id;
|
|
2932
|
-
const createdAt = c.__createdAt;
|
|
2933
|
-
const repo = Registry.getInstance().ensureRepository(CarBatch);
|
|
2934
|
-
repo.beginBatch();
|
|
2935
|
-
repo.save({ __id: savedId, __createdAt: createdAt, make: "Toyota", model: "Camry", year: 2024 });
|
|
2936
|
-
// Sheet not yet updated — cache still shows original
|
|
2937
|
-
const beforeCommit = CarBatch.findById(savedId);
|
|
2938
|
-
assertTrue(beforeCommit !== null, "entity should still be visible before commit");
|
|
2939
|
-
assertEqual(beforeCommit.make, "Honda", "make should still be Honda before commit");
|
|
2940
|
-
repo.commitBatch();
|
|
2941
|
-
Registry.getInstance().clearCache();
|
|
2942
|
-
const updated = CarBatch.findById(savedId);
|
|
2943
|
-
assertTrue(updated !== null, "entity should be found after commit");
|
|
2944
|
-
assertEqual(updated.make, "Toyota", "make should be updated to Toyota");
|
|
2945
|
-
assertEqual(updated.model, "Camry", "model should be updated to Camry");
|
|
2946
|
-
assertEqual(updated.year, 2024, "year should be updated to 2024");
|
|
2947
|
-
assertEqual(updated.__createdAt, createdAt, "__createdAt should be preserved");
|
|
2948
|
-
},
|
|
2949
|
-
"throws when indexed model has no indexTableName": (ctx) => {
|
|
2950
|
-
const adapter = ctx.state.getAdapter();
|
|
2951
|
-
const suffix = ctx.state.nextTableName("rec");
|
|
2952
|
-
Registry.reset();
|
|
2953
|
-
resetDecoratorCaches();
|
|
2954
|
-
Registry.getInstance().configure({ adapter });
|
|
2955
|
-
class BrokenIndexedRecord extends BaseRecord {
|
|
2956
|
-
static get tableName() {
|
|
2957
|
-
return `BrokenIndexed_${suffix}`;
|
|
2958
|
-
}
|
|
2959
|
-
static get indexTableName() {
|
|
2960
|
-
return undefined;
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
2963
|
-
__decorate([
|
|
2964
|
-
Indexed()
|
|
2965
|
-
], BrokenIndexedRecord.prototype, "code", void 0);
|
|
2966
|
-
const entity = new BrokenIndexedRecord();
|
|
2967
|
-
entity.code = "X-1";
|
|
2968
|
-
let threw = false;
|
|
2969
|
-
try {
|
|
2970
|
-
entity.save();
|
|
2971
|
-
}
|
|
2972
|
-
catch (e) {
|
|
2973
|
-
threw = true;
|
|
2974
|
-
assertTrue(e.message.includes("has no indexTableName"), "error should mention missing indexTableName");
|
|
2975
|
-
}
|
|
2976
|
-
assertTrue(threw, "save() should throw when indexed schema has no indexTableName");
|
|
2977
|
-
},
|
|
2978
|
-
"protects sheet on first save when isProtected returns true": (ctx) => {
|
|
2979
|
-
const adapter = ctx.state.getAdapter();
|
|
2980
|
-
const suffix = ctx.state.nextTableName("prot");
|
|
2981
|
-
Registry.reset();
|
|
2982
|
-
resetDecoratorCaches();
|
|
2983
|
-
Registry.getInstance().configure({ adapter });
|
|
2984
|
-
class ProtectedNote extends BaseRecord {
|
|
2985
|
-
static get tableName() {
|
|
2986
|
-
return `ProtNote_${suffix}`;
|
|
2987
|
-
}
|
|
2988
|
-
static isProtected() {
|
|
2989
|
-
return true;
|
|
2990
|
-
}
|
|
2991
|
-
static protectedFor() {
|
|
2992
|
-
return ["alice@example.com", "bob@example.com"];
|
|
2993
|
-
}
|
|
2994
|
-
}
|
|
2995
|
-
const note = new ProtectedNote();
|
|
2996
|
-
note.body = "secret";
|
|
2997
|
-
note.save();
|
|
2998
|
-
// Verify the sheet was created and protected
|
|
2999
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3000
|
-
const sheet = spreadsheet.getSheetByName(`ProtNote_${suffix}`);
|
|
3001
|
-
assertTrue(sheet !== null, "protected sheet should exist");
|
|
3002
|
-
const protections = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
|
|
3003
|
-
assertTrue(protections.length > 0, "sheet should have at least one protection");
|
|
3004
|
-
const editors = protections[0].getEditors().map((e) => e.getEmail());
|
|
3005
|
-
assertTrue(editors.includes("alice@example.com"), "alice should be an editor");
|
|
3006
|
-
assertTrue(editors.includes("bob@example.com"), "bob should be an editor");
|
|
3007
|
-
},
|
|
3008
|
-
"does not protect sheet when isProtected returns false": (ctx) => {
|
|
3009
|
-
const { Car } = setup(ctx);
|
|
3010
|
-
const car = new Car();
|
|
3011
|
-
car.make = "Toyota";
|
|
3012
|
-
car.model = "Corolla";
|
|
3013
|
-
car.save();
|
|
3014
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3015
|
-
const sheet = spreadsheet.getSheetByName(Car.tableName);
|
|
3016
|
-
assertTrue(sheet !== null, "sheet should exist");
|
|
3017
|
-
const protections = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
|
|
3018
|
-
assertEqual(protections.length, 0, "unprotected sheet should have no protections");
|
|
3019
|
-
},
|
|
3020
|
-
"does not re-protect sheet on subsequent saves": (ctx) => {
|
|
3021
|
-
const adapter = ctx.state.getAdapter();
|
|
3022
|
-
const suffix = ctx.state.nextTableName("reprot");
|
|
3023
|
-
Registry.reset();
|
|
3024
|
-
resetDecoratorCaches();
|
|
3025
|
-
Registry.getInstance().configure({ adapter });
|
|
3026
|
-
class TrackedProtected extends BaseRecord {
|
|
3027
|
-
static get tableName() {
|
|
3028
|
-
return `Tracked_${suffix}`;
|
|
3029
|
-
}
|
|
3030
|
-
static isProtected() {
|
|
3031
|
-
return true;
|
|
3032
|
-
}
|
|
3033
|
-
static protectedFor() {
|
|
3034
|
-
return ["admin@example.com"];
|
|
3035
|
-
}
|
|
3036
|
-
}
|
|
3037
|
-
const item1 = new TrackedProtected();
|
|
3038
|
-
item1.value = "first";
|
|
3039
|
-
item1.save();
|
|
3040
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3041
|
-
const sheet = spreadsheet.getSheetByName(`Tracked_${suffix}`);
|
|
3042
|
-
assertTrue(sheet !== null, "sheet should exist");
|
|
3043
|
-
const protsBefore = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
|
|
3044
|
-
assertEqual(protsBefore.length, 1, "should have exactly one protection after first save");
|
|
3045
|
-
const item2 = new TrackedProtected();
|
|
3046
|
-
item2.value = "second";
|
|
3047
|
-
item2.save();
|
|
3048
|
-
const protsAfter = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
|
|
3049
|
-
assertEqual(protsAfter.length, 1, "should still have exactly one protection after second save");
|
|
3050
|
-
},
|
|
3051
|
-
"protects sheet with empty editors list": (ctx) => {
|
|
3052
|
-
const adapter = ctx.state.getAdapter();
|
|
3053
|
-
const suffix = ctx.state.nextTableName("locked");
|
|
3054
|
-
Registry.reset();
|
|
3055
|
-
resetDecoratorCaches();
|
|
3056
|
-
Registry.getInstance().configure({ adapter });
|
|
3057
|
-
class LockedSheet extends BaseRecord {
|
|
3058
|
-
static get tableName() {
|
|
3059
|
-
return `Locked_${suffix}`;
|
|
3060
|
-
}
|
|
3061
|
-
static isProtected() {
|
|
3062
|
-
return true;
|
|
3063
|
-
}
|
|
3064
|
-
}
|
|
3065
|
-
const item = new LockedSheet();
|
|
3066
|
-
item.data = "locked";
|
|
3067
|
-
item.save();
|
|
3068
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3069
|
-
const sheet = spreadsheet.getSheetByName(`Locked_${suffix}`);
|
|
3070
|
-
assertTrue(sheet !== null, "sheet should exist");
|
|
3071
|
-
const protections = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
|
|
3072
|
-
assertTrue(protections.length > 0, "sheet should be protected even with no editors");
|
|
3073
|
-
},
|
|
3074
|
-
"hides sheet on first save when isHidden returns true": (ctx) => {
|
|
3075
|
-
const adapter = ctx.state.getAdapter();
|
|
3076
|
-
const suffix = ctx.state.nextTableName("hidden");
|
|
3077
|
-
Registry.reset();
|
|
3078
|
-
resetDecoratorCaches();
|
|
3079
|
-
Registry.getInstance().configure({ adapter });
|
|
3080
|
-
class HiddenLog extends BaseRecord {
|
|
3081
|
-
static get tableName() {
|
|
3082
|
-
return `HidLog_${suffix}`;
|
|
3083
|
-
}
|
|
3084
|
-
static isHidden() {
|
|
3085
|
-
return true;
|
|
3086
|
-
}
|
|
3087
|
-
}
|
|
3088
|
-
const log = new HiddenLog();
|
|
3089
|
-
log.entry = "secret log";
|
|
3090
|
-
log.save();
|
|
3091
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3092
|
-
const sheet = spreadsheet.getSheetByName(`HidLog_${suffix}`);
|
|
3093
|
-
assertTrue(sheet !== null, "hidden sheet should exist");
|
|
3094
|
-
assertTrue(sheet.isSheetHidden(), "sheet should be hidden");
|
|
3095
|
-
},
|
|
3096
|
-
"does not hide sheet when isHidden returns false": (ctx) => {
|
|
3097
|
-
const { Car } = setup(ctx);
|
|
3098
|
-
const car = new Car();
|
|
3099
|
-
car.make = "Toyota";
|
|
3100
|
-
car.model = "Corolla";
|
|
3101
|
-
car.save();
|
|
3102
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3103
|
-
const sheet = spreadsheet.getSheetByName(Car.tableName);
|
|
3104
|
-
assertTrue(sheet !== null, "sheet should exist");
|
|
3105
|
-
assertTrue(!sheet.isSheetHidden(), "sheet should not be hidden");
|
|
3106
|
-
},
|
|
3107
|
-
"does not re-hide sheet on subsequent saves": (ctx) => {
|
|
3108
|
-
const adapter = ctx.state.getAdapter();
|
|
3109
|
-
const suffix = ctx.state.nextTableName("rehid");
|
|
3110
|
-
Registry.reset();
|
|
3111
|
-
resetDecoratorCaches();
|
|
3112
|
-
Registry.getInstance().configure({ adapter });
|
|
3113
|
-
class TrackedHidden extends BaseRecord {
|
|
3114
|
-
static get tableName() {
|
|
3115
|
-
return `TrkHid_${suffix}`;
|
|
3116
|
-
}
|
|
3117
|
-
static isHidden() {
|
|
3118
|
-
return true;
|
|
3119
|
-
}
|
|
3120
|
-
}
|
|
3121
|
-
const item1 = new TrackedHidden();
|
|
3122
|
-
item1.value = "first";
|
|
3123
|
-
item1.save();
|
|
3124
|
-
const spreadsheet = ctx.state.getSpreadsheet();
|
|
3125
|
-
const sheet = spreadsheet.getSheetByName(`TrkHid_${suffix}`);
|
|
3126
|
-
assertTrue(sheet !== null, "sheet should exist");
|
|
3127
|
-
assertTrue(sheet.isSheetHidden(), "sheet should be hidden after first save");
|
|
3128
|
-
const item2 = new TrackedHidden();
|
|
3129
|
-
item2.value = "second";
|
|
3130
|
-
item2.save();
|
|
3131
|
-
assertTrue(sheet.isSheetHidden(), "sheet should still be hidden after second save");
|
|
3132
|
-
},
|
|
3133
|
-
};
|
|
3134
|
-
})(),
|
|
3135
|
-
"sheet-repository.test.ts": {
|
|
3136
|
-
"onValidate rejects save when validation errors returned": (ctx) => {
|
|
3137
|
-
const adapter = ctx.state.getAdapter();
|
|
3138
|
-
const tableName = ctx.state.nextTableName("tbl_ValItems");
|
|
3139
|
-
const schema = {
|
|
3140
|
-
tableName,
|
|
3141
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3142
|
-
indexes: [],
|
|
3143
|
-
};
|
|
3144
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3145
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3146
|
-
const hooks = {
|
|
3147
|
-
onValidate: (entity) => {
|
|
3148
|
-
const errors = [];
|
|
3149
|
-
if (!entity.name)
|
|
3150
|
-
errors.push("name is required");
|
|
3151
|
-
return errors;
|
|
3152
|
-
},
|
|
3153
|
-
};
|
|
3154
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3155
|
-
let threw = false;
|
|
3156
|
-
try {
|
|
3157
|
-
repo.save({ name: "", price: 1, category: "x" });
|
|
3158
|
-
}
|
|
3159
|
-
catch (e) {
|
|
3160
|
-
threw = true;
|
|
3161
|
-
assertTrue(e.message.includes("Validation failed"), "should mention Validation failed");
|
|
3162
|
-
}
|
|
3163
|
-
assertTrue(threw, "onValidate should reject save");
|
|
3164
|
-
},
|
|
3165
|
-
"beforeSave mutates entity payload": (ctx) => {
|
|
3166
|
-
const adapter = ctx.state.getAdapter();
|
|
3167
|
-
const tableName = ctx.state.nextTableName("tbl_MutItems");
|
|
3168
|
-
const schema = {
|
|
3169
|
-
tableName,
|
|
3170
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3171
|
-
indexes: [],
|
|
3172
|
-
};
|
|
3173
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3174
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3175
|
-
const hooks = {
|
|
3176
|
-
beforeSave: (entity) => ({ ...entity, name: String(entity.name ?? "").toUpperCase() }),
|
|
3177
|
-
};
|
|
3178
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3179
|
-
const saved = repo.save({ name: "widget", price: 10, category: "tools" });
|
|
3180
|
-
assertEqual(saved.name, "WIDGET", "beforeSave should mutate name to uppercase");
|
|
3181
|
-
},
|
|
3182
|
-
"afterSave receives saved entity and isNew flag": (ctx) => {
|
|
3183
|
-
const adapter = ctx.state.getAdapter();
|
|
3184
|
-
const tableName = ctx.state.nextTableName("tbl_AfterItems");
|
|
3185
|
-
const schema = {
|
|
3186
|
-
tableName,
|
|
3187
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3188
|
-
indexes: [],
|
|
3189
|
-
};
|
|
3190
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3191
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3192
|
-
const calls = [];
|
|
3193
|
-
const hooks = {
|
|
3194
|
-
afterSave: (_entity, isNew) => {
|
|
3195
|
-
calls.push({ isNew });
|
|
3196
|
-
},
|
|
3197
|
-
};
|
|
3198
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3199
|
-
const saved = repo.save({ name: "A", price: 1, category: "x" });
|
|
3200
|
-
assertEqual(calls.length, 1, "afterSave should be called once");
|
|
3201
|
-
assertTrue(calls[0].isNew, "first save should be isNew=true");
|
|
3202
|
-
repo.save({ ...saved, price: 2 });
|
|
3203
|
-
assertEqual(calls.length, 2, "afterSave should be called twice");
|
|
3204
|
-
assertTrue(!calls[1].isNew, "update should be isNew=false");
|
|
3205
|
-
},
|
|
3206
|
-
"beforeDelete returning false blocks deletion": (ctx) => {
|
|
3207
|
-
const adapter = ctx.state.getAdapter();
|
|
3208
|
-
const tableName = ctx.state.nextTableName("tbl_NoDelItems");
|
|
3209
|
-
const schema = {
|
|
3210
|
-
tableName,
|
|
3211
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3212
|
-
indexes: [],
|
|
3213
|
-
};
|
|
3214
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3215
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3216
|
-
const hooks = { beforeDelete: () => false };
|
|
3217
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3218
|
-
const saved = repo.save({ name: "keep", price: 5, category: "x" });
|
|
3219
|
-
const deleted = repo.delete(saved.__id);
|
|
3220
|
-
assertEqual(deleted, false, "beforeDelete returning false should block deletion");
|
|
3221
|
-
assertEqual(repo.count(), 1, "entity should still exist");
|
|
3222
|
-
},
|
|
3223
|
-
"beforeDelete veto on deleteAll returns zero and preserves data": (ctx) => {
|
|
3224
|
-
const adapter = ctx.state.getAdapter();
|
|
3225
|
-
const tableName = ctx.state.nextTableName("tbl_VetoDelItems");
|
|
3226
|
-
const schema = {
|
|
3227
|
-
tableName,
|
|
3228
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3229
|
-
indexes: [],
|
|
3230
|
-
};
|
|
3231
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3232
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3233
|
-
const hooks = { beforeDelete: () => false };
|
|
3234
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3235
|
-
repo.save({ name: "A", price: 1, category: "x" });
|
|
3236
|
-
repo.save({ name: "B", price: 2, category: "x" });
|
|
3237
|
-
repo.save({ name: "C", price: 3, category: "x" });
|
|
3238
|
-
const count = repo.deleteAll();
|
|
3239
|
-
assertEqual(count, 0, "deleteAll should return 0 when beforeDelete vetoes");
|
|
3240
|
-
assertEqual(repo.count(), 3, "all entities should be preserved");
|
|
3241
|
-
},
|
|
3242
|
-
"beforeDelete veto on deleteAll can preserve selected entities": (ctx) => {
|
|
3243
|
-
const adapter = ctx.state.getAdapter();
|
|
3244
|
-
const tableName = ctx.state.nextTableName("tbl_PartialVetoDelItems");
|
|
3245
|
-
const schema = {
|
|
3246
|
-
tableName,
|
|
3247
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3248
|
-
indexes: [],
|
|
3249
|
-
};
|
|
3250
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3251
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3252
|
-
let vetoId = null;
|
|
3253
|
-
const hooks = {
|
|
3254
|
-
beforeDelete: (id) => id !== vetoId,
|
|
3255
|
-
};
|
|
3256
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3257
|
-
const a = repo.save({ name: "A", price: 1, category: "x" });
|
|
3258
|
-
const b = repo.save({ name: "B", price: 2, category: "x" });
|
|
3259
|
-
const c = repo.save({ name: "C", price: 3, category: "x" });
|
|
3260
|
-
vetoId = b.__id;
|
|
3261
|
-
const count = repo.deleteAll();
|
|
3262
|
-
assertEqual(count, 2, "deleteAll should remove only non-vetoed entities");
|
|
3263
|
-
assertEqual(repo.count(), 1, "one vetoed entity should remain");
|
|
3264
|
-
const remaining = repo.find();
|
|
3265
|
-
assertEqual(remaining.length, 1, "one entity should remain after partial veto");
|
|
3266
|
-
assertEqual(remaining[0].__id, vetoId, "remaining entity should be the vetoed one");
|
|
3267
|
-
const ids = [a.__id, b.__id, c.__id];
|
|
3268
|
-
assertTrue(ids.includes(remaining[0].__id), "remaining entity should be one of the original entities");
|
|
3269
|
-
},
|
|
3270
|
-
"beforeDelete partial veto works for deleteAll small-batch path": (ctx) => {
|
|
3271
|
-
const adapter = ctx.state.getAdapter();
|
|
3272
|
-
const tableName = ctx.state.nextTableName("tbl_PartialVetoSmallDelItems");
|
|
3273
|
-
const schema = {
|
|
3274
|
-
tableName,
|
|
3275
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3276
|
-
indexes: [],
|
|
3277
|
-
};
|
|
3278
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3279
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3280
|
-
let vetoId = null;
|
|
3281
|
-
const deletedIds = [];
|
|
3282
|
-
const hooks = {
|
|
3283
|
-
beforeDelete: (id) => id !== vetoId,
|
|
3284
|
-
afterDelete: (id) => {
|
|
3285
|
-
deletedIds.push(id);
|
|
3286
|
-
},
|
|
3287
|
-
};
|
|
3288
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3289
|
-
const a = repo.save({ name: "A", price: 1, category: "x" });
|
|
3290
|
-
const b = repo.save({ name: "B", price: 2, category: "x" });
|
|
3291
|
-
vetoId = b.__id;
|
|
3292
|
-
// Exactly 2 entities -> individual delete path (<=2)
|
|
3293
|
-
const count = repo.deleteAll();
|
|
3294
|
-
assertEqual(count, 1, "exactly one entity should be deleted when one is vetoed");
|
|
3295
|
-
assertEqual(repo.count(), 1, "one vetoed entity should remain");
|
|
3296
|
-
const remaining = repo.find();
|
|
3297
|
-
assertEqual(remaining.length, 1, "one entity should remain after partial veto in small batch");
|
|
3298
|
-
assertEqual(remaining[0].__id, vetoId, "remaining entity should be the vetoed one");
|
|
3299
|
-
assertEqual(deletedIds.length, 1, "afterDelete should be called once");
|
|
3300
|
-
assertEqual(deletedIds[0], a.__id, "afterDelete should be called for deleted (non-vetoed) entity");
|
|
3301
|
-
},
|
|
3302
|
-
"afterDelete is called with deleted entity ID": (ctx) => {
|
|
3303
|
-
const adapter = ctx.state.getAdapter();
|
|
3304
|
-
const tableName = ctx.state.nextTableName("tbl_AfterDelItems");
|
|
3305
|
-
const schema = {
|
|
3306
|
-
tableName,
|
|
3307
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3308
|
-
indexes: [],
|
|
3309
|
-
};
|
|
3310
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3311
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3312
|
-
const deletedIds = [];
|
|
3313
|
-
const hooks = {
|
|
3314
|
-
afterDelete: (id) => {
|
|
3315
|
-
deletedIds.push(id);
|
|
3316
|
-
},
|
|
3317
|
-
};
|
|
3318
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3319
|
-
const saved = repo.save({ name: "gone", price: 0, category: "x" });
|
|
3320
|
-
repo.delete(saved.__id);
|
|
3321
|
-
assertDeepEqual(deletedIds, [saved.__id], "afterDelete should receive deleted ID");
|
|
3322
|
-
},
|
|
3323
|
-
"afterDelete is called for each entity removed by deleteAll": (ctx) => {
|
|
3324
|
-
const adapter = ctx.state.getAdapter();
|
|
3325
|
-
const tableName = ctx.state.nextTableName("tbl_AfterDelAllItems");
|
|
3326
|
-
const schema = {
|
|
3327
|
-
tableName,
|
|
3328
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3329
|
-
indexes: [],
|
|
3330
|
-
};
|
|
3331
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3332
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3333
|
-
const deletedIds = [];
|
|
3334
|
-
const hooks = {
|
|
3335
|
-
afterDelete: (id) => {
|
|
3336
|
-
deletedIds.push(id);
|
|
3337
|
-
},
|
|
3338
|
-
};
|
|
3339
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3340
|
-
const a = repo.save({ name: "A", price: 1, category: "x" });
|
|
3341
|
-
const b = repo.save({ name: "B", price: 2, category: "x" });
|
|
3342
|
-
const c = repo.save({ name: "C", price: 3, category: "x" });
|
|
3343
|
-
const count = repo.deleteAll();
|
|
3344
|
-
assertEqual(count, 3, "deleteAll should remove all entities");
|
|
3345
|
-
assertEqual(repo.count(), 0, "no entities should remain after deleteAll");
|
|
3346
|
-
const expected = new Set([a.__id, b.__id, c.__id]);
|
|
3347
|
-
const actual = new Set(deletedIds);
|
|
3348
|
-
assertEqual(actual.size, expected.size, "afterDelete should be called exactly once per deleted entity");
|
|
3349
|
-
for (const id of expected) {
|
|
3350
|
-
assertTrue(actual.has(id), "afterDelete should include every deleted ID");
|
|
3351
|
-
}
|
|
3352
|
-
},
|
|
3353
|
-
"afterDelete is called for each entity removed by deleteAll small-batch path": (ctx) => {
|
|
3354
|
-
const adapter = ctx.state.getAdapter();
|
|
3355
|
-
const tableName = ctx.state.nextTableName("tbl_AfterDelAllSmallItems");
|
|
3356
|
-
const schema = {
|
|
3357
|
-
tableName,
|
|
3358
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3359
|
-
indexes: [],
|
|
3360
|
-
};
|
|
3361
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3362
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3363
|
-
const deletedIds = [];
|
|
3364
|
-
const hooks = {
|
|
3365
|
-
afterDelete: (id) => {
|
|
3366
|
-
deletedIds.push(id);
|
|
3367
|
-
},
|
|
3368
|
-
};
|
|
3369
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3370
|
-
const a = repo.save({ name: "A", price: 1, category: "x" });
|
|
3371
|
-
const b = repo.save({ name: "B", price: 2, category: "x" });
|
|
3372
|
-
// Exactly 2 entities -> individual delete path (<=2)
|
|
3373
|
-
const count = repo.deleteAll();
|
|
3374
|
-
assertEqual(count, 2, "deleteAll should remove both entities in small-batch path");
|
|
3375
|
-
assertEqual(repo.count(), 0, "no entities should remain after deleteAll");
|
|
3376
|
-
const expected = new Set([a.__id, b.__id]);
|
|
3377
|
-
const actual = new Set(deletedIds);
|
|
3378
|
-
assertEqual(actual.size, expected.size, "afterDelete should be called once per deleted entity");
|
|
3379
|
-
for (const id of expected) {
|
|
3380
|
-
assertTrue(actual.has(id), "afterDelete should include each deleted ID in small-batch path");
|
|
3381
|
-
}
|
|
3382
|
-
},
|
|
3383
|
-
"getSheet throws when sheet does not exist": (ctx) => {
|
|
3384
|
-
const adapter = ctx.state.getAdapter();
|
|
3385
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3386
|
-
const schema = { tableName: "tbl_NonExistent_" + Date.now(), fields: [{ name: "name" }], indexes: [] };
|
|
3387
|
-
const repo = new SheetRepository(adapter, schema, indexStore);
|
|
3388
|
-
let threw = false;
|
|
3389
|
-
try {
|
|
3390
|
-
repo.find();
|
|
3391
|
-
}
|
|
3392
|
-
catch (e) {
|
|
3393
|
-
threw = true;
|
|
3394
|
-
assertTrue(e.message.includes("not found"), "error should mention not found");
|
|
3395
|
-
}
|
|
3396
|
-
assertTrue(threw, "find() on non-existent sheet should throw");
|
|
3397
|
-
},
|
|
3398
|
-
"loadAllEntities throws during active saveAll entity batch": (ctx) => {
|
|
3399
|
-
const adapter = ctx.state.getAdapter();
|
|
3400
|
-
const tableName = ctx.state.nextTableName("tbl_BatchItems");
|
|
3401
|
-
const schema = {
|
|
3402
|
-
tableName,
|
|
3403
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3404
|
-
indexes: [],
|
|
3405
|
-
};
|
|
3406
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3407
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3408
|
-
let repoRef = null;
|
|
3409
|
-
const hooks = {
|
|
3410
|
-
beforeSave: () => {
|
|
3411
|
-
try {
|
|
3412
|
-
repoRef.count();
|
|
3413
|
-
}
|
|
3414
|
-
catch {
|
|
3415
|
-
throw new Error("re-entrant read blocked");
|
|
3416
|
-
}
|
|
3417
|
-
},
|
|
3418
|
-
};
|
|
3419
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3420
|
-
repoRef = repo;
|
|
3421
|
-
let threw = false;
|
|
3422
|
-
try {
|
|
3423
|
-
repo.saveAll([
|
|
3424
|
-
{ name: "A", price: 1, category: "x" },
|
|
3425
|
-
{ name: "B", price: 2, category: "y" },
|
|
3426
|
-
]);
|
|
3427
|
-
}
|
|
3428
|
-
catch {
|
|
3429
|
-
threw = true;
|
|
3430
|
-
}
|
|
3431
|
-
assertTrue(threw, "saveAll with re-entrant read should throw");
|
|
3432
|
-
},
|
|
3433
|
-
"findById returns a detached copy from cache": (ctx) => {
|
|
3434
|
-
const adapter = ctx.state.getAdapter();
|
|
3435
|
-
const tableName = ctx.state.nextTableName("tbl_DetachedByIdItems");
|
|
3436
|
-
const schema = {
|
|
3437
|
-
tableName,
|
|
3438
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3439
|
-
indexes: [],
|
|
3440
|
-
};
|
|
3441
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3442
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3443
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3444
|
-
const saved = repo.save({ name: "Widget", price: 10, category: "tools" });
|
|
3445
|
-
const found = repo.findById(saved.__id);
|
|
3446
|
-
assertTrue(found !== null, "findById should return saved entity");
|
|
3447
|
-
found.name = "CHANGED";
|
|
3448
|
-
const reread = repo.findById(saved.__id);
|
|
3449
|
-
assertTrue(reread !== null, "entity should still be found after local mutation");
|
|
3450
|
-
assertEqual(reread.name, "Widget", "mutating returned entity should not affect cached state");
|
|
3451
|
-
},
|
|
3452
|
-
"query() returns detached copies from cache-backed reads": (ctx) => {
|
|
3453
|
-
const adapter = ctx.state.getAdapter();
|
|
3454
|
-
const tableName = ctx.state.nextTableName("tbl_DetachedQueryItems");
|
|
3455
|
-
const schema = {
|
|
3456
|
-
tableName,
|
|
3457
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3458
|
-
indexes: [],
|
|
3459
|
-
};
|
|
3460
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3461
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3462
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3463
|
-
const saved = repo.save({ name: "Widget", price: 10, category: "tools" });
|
|
3464
|
-
const first = repo.query().first();
|
|
3465
|
-
assertTrue(first !== null, "query().first() should return saved entity");
|
|
3466
|
-
first.name = "CHANGED";
|
|
3467
|
-
const reread = repo.findById(saved.__id);
|
|
3468
|
-
assertTrue(reread !== null, "entity should still be readable after query() mutation");
|
|
3469
|
-
assertEqual(reread.name, "Widget", "query() results should be detached from cache-backed state");
|
|
3470
|
-
},
|
|
3471
|
-
"commitBatch() is a no-op when no batch is active": (ctx) => {
|
|
3472
|
-
const adapter = ctx.state.getAdapter();
|
|
3473
|
-
const tableName = ctx.state.nextTableName("tbl_CommitNoOp");
|
|
3474
|
-
const schema = {
|
|
3475
|
-
tableName,
|
|
3476
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3477
|
-
indexes: [],
|
|
3478
|
-
};
|
|
3479
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3480
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3481
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3482
|
-
assertTrue(!repo.isBatchActive(), "batch should not be active initially");
|
|
3483
|
-
let threw = false;
|
|
3484
|
-
try {
|
|
3485
|
-
repo.commitBatch();
|
|
3486
|
-
}
|
|
3487
|
-
catch {
|
|
3488
|
-
threw = true;
|
|
3489
|
-
}
|
|
3490
|
-
assertTrue(!threw, "commitBatch() with no active batch should not throw");
|
|
3491
|
-
assertTrue(!repo.isBatchActive(), "batch should still be inactive after no-op commitBatch");
|
|
3492
|
-
},
|
|
3493
|
-
"rollbackBatch() is a no-op when no batch is active": (ctx) => {
|
|
3494
|
-
const adapter = ctx.state.getAdapter();
|
|
3495
|
-
const tableName = ctx.state.nextTableName("tbl_RollbackNoOp");
|
|
3496
|
-
const schema = {
|
|
3497
|
-
tableName,
|
|
3498
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3499
|
-
indexes: [],
|
|
3500
|
-
};
|
|
3501
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3502
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3503
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3504
|
-
assertTrue(!repo.isBatchActive(), "batch should not be active initially");
|
|
3505
|
-
let threw = false;
|
|
3506
|
-
try {
|
|
3507
|
-
repo.rollbackBatch();
|
|
3508
|
-
}
|
|
3509
|
-
catch {
|
|
3510
|
-
threw = true;
|
|
3511
|
-
}
|
|
3512
|
-
assertTrue(!threw, "rollbackBatch() with no active batch should not throw");
|
|
3513
|
-
assertTrue(!repo.isBatchActive(), "batch should still be inactive after no-op rollbackBatch");
|
|
3514
|
-
},
|
|
3515
|
-
"rollbackBatch discards queued operations and does not trigger save/delete hooks": (ctx) => {
|
|
3516
|
-
const adapter = ctx.state.getAdapter();
|
|
3517
|
-
const tableName = ctx.state.nextTableName("tbl_RollbackDiscardHooks");
|
|
3518
|
-
const schema = {
|
|
3519
|
-
tableName,
|
|
3520
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3521
|
-
indexes: [],
|
|
3522
|
-
};
|
|
3523
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3524
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3525
|
-
const afterSaveCalls = [];
|
|
3526
|
-
const afterDeleteCalls = [];
|
|
3527
|
-
const hooks = {
|
|
3528
|
-
afterSave: (entity) => {
|
|
3529
|
-
afterSaveCalls.push(entity.__id);
|
|
3530
|
-
},
|
|
3531
|
-
afterDelete: (id) => {
|
|
3532
|
-
afterDeleteCalls.push(id);
|
|
3533
|
-
},
|
|
3534
|
-
};
|
|
3535
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3536
|
-
const existing = repo.save({ name: "Existing", price: 1, category: "x" });
|
|
3537
|
-
// Ignore setup-side save hook calls; verify rollback side effects only.
|
|
3538
|
-
afterSaveCalls.length = 0;
|
|
3539
|
-
afterDeleteCalls.length = 0;
|
|
3540
|
-
repo.beginBatch();
|
|
3541
|
-
repo.save({ name: "Queued", price: 2, category: "y" });
|
|
3542
|
-
repo.delete(existing.__id);
|
|
3543
|
-
assertEqual(repo.count(), 1, "queued save/delete should not affect persisted count before commit");
|
|
3544
|
-
repo.rollbackBatch();
|
|
3545
|
-
assertEqual(repo.count(), 1, "rollback should keep only the original entity");
|
|
3546
|
-
const found = repo.findById(existing.__id);
|
|
3547
|
-
assertTrue(found !== null, "existing entity should remain after rollback");
|
|
3548
|
-
assertEqual(found.name, "Existing", "existing entity should remain unchanged after rollback");
|
|
3549
|
-
assertEqual(afterSaveCalls.length, 0, "afterSave should not fire for rolled-back queued save");
|
|
3550
|
-
assertEqual(afterDeleteCalls.length, 0, "afterDelete should not fire for rolled-back queued delete");
|
|
3551
|
-
},
|
|
3552
|
-
"beginBatch() resets the buffer when batch is already active": (ctx) => {
|
|
3553
|
-
const adapter = ctx.state.getAdapter();
|
|
3554
|
-
const tableName = ctx.state.nextTableName("tbl_BeginReset");
|
|
3555
|
-
const schema = {
|
|
3556
|
-
tableName,
|
|
3557
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3558
|
-
indexes: [],
|
|
3559
|
-
};
|
|
3560
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3561
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3562
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3563
|
-
repo.beginBatch();
|
|
3564
|
-
repo.save({ name: "First", price: 1, category: "x" }); // buffered
|
|
3565
|
-
repo.beginBatch(); // resets the buffer, discarding the buffered save
|
|
3566
|
-
repo.commitBatch(); // commits an empty buffer
|
|
3567
|
-
assertEqual(repo.count(), 0, "entity buffered before reset should NOT have been saved");
|
|
3568
|
-
},
|
|
3569
|
-
"queued save applies beforeSave and triggers afterSave on commit": (ctx) => {
|
|
3570
|
-
const adapter = ctx.state.getAdapter();
|
|
3571
|
-
const tableName = ctx.state.nextTableName("tbl_BatchQueuedSaveHooks");
|
|
3572
|
-
const schema = {
|
|
3573
|
-
tableName,
|
|
3574
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3575
|
-
indexes: [],
|
|
3576
|
-
};
|
|
3577
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3578
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3579
|
-
const calls = [];
|
|
3580
|
-
const hooks = {
|
|
3581
|
-
beforeSave: (entity) => ({
|
|
3582
|
-
...entity,
|
|
3583
|
-
name: String(entity.name ?? "").toUpperCase(),
|
|
3584
|
-
}),
|
|
3585
|
-
afterSave: (entity, isNew) => {
|
|
3586
|
-
calls.push({ isNew, name: String(entity.name ?? "") });
|
|
3587
|
-
},
|
|
3588
|
-
};
|
|
3589
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3590
|
-
repo.beginBatch();
|
|
3591
|
-
repo.save({ name: "alpha", price: 1, category: "x" });
|
|
3592
|
-
assertEqual(repo.count(), 0, "queued save should not be visible before commit");
|
|
3593
|
-
assertEqual(calls.length, 0, "afterSave should not fire before commit");
|
|
3594
|
-
repo.commitBatch();
|
|
3595
|
-
assertEqual(repo.count(), 1, "entity should be persisted after commit");
|
|
3596
|
-
const all = repo.find();
|
|
3597
|
-
assertEqual(all.length, 1, "one entity should exist after commit");
|
|
3598
|
-
assertEqual(all[0].name, "ALPHA", "beforeSave should uppercase queued entity on commit");
|
|
3599
|
-
assertEqual(calls.length, 1, "afterSave should fire once on commit");
|
|
3600
|
-
assertTrue(calls[0].isNew, "queued save should be treated as a new entity");
|
|
3601
|
-
assertEqual(calls[0].name, "ALPHA", "afterSave should receive transformed entity data");
|
|
3602
|
-
},
|
|
3603
|
-
"queued update applies beforeSave and triggers afterSave with isNew=false": (ctx) => {
|
|
3604
|
-
const adapter = ctx.state.getAdapter();
|
|
3605
|
-
const tableName = ctx.state.nextTableName("tbl_BatchQueuedUpdateHooks");
|
|
3606
|
-
const schema = {
|
|
3607
|
-
tableName,
|
|
3608
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3609
|
-
indexes: [],
|
|
3610
|
-
};
|
|
3611
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3612
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3613
|
-
const calls = [];
|
|
3614
|
-
const hooks = {
|
|
3615
|
-
beforeSave: (entity) => ({
|
|
3616
|
-
...entity,
|
|
3617
|
-
name: String(entity.name ?? "").toUpperCase(),
|
|
3618
|
-
}),
|
|
3619
|
-
afterSave: (entity, isNew) => {
|
|
3620
|
-
calls.push({ isNew, name: String(entity.name ?? "") });
|
|
3621
|
-
},
|
|
3622
|
-
};
|
|
3623
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3624
|
-
const original = repo.save({ name: "alpha", price: 1, category: "x" });
|
|
3625
|
-
calls.length = 0;
|
|
3626
|
-
repo.beginBatch();
|
|
3627
|
-
repo.save({ ...original, name: "beta" });
|
|
3628
|
-
const beforeCommit = repo.findById(original.__id);
|
|
3629
|
-
assertTrue(beforeCommit !== null, "original entity should exist before commit");
|
|
3630
|
-
assertEqual(beforeCommit.name, "ALPHA", "queued update should not apply before commit");
|
|
3631
|
-
assertEqual(calls.length, 0, "afterSave should not fire before commit for queued update");
|
|
3632
|
-
repo.commitBatch();
|
|
3633
|
-
const updated = repo.findById(original.__id);
|
|
3634
|
-
assertTrue(updated !== null, "entity should still exist after queued update commit");
|
|
3635
|
-
assertEqual(updated.name, "BETA", "beforeSave should transform queued update on commit");
|
|
3636
|
-
assertEqual(calls.length, 1, "afterSave should fire once for queued update");
|
|
3637
|
-
assertTrue(!calls[0].isNew, "queued update should be reported as isNew=false");
|
|
3638
|
-
assertEqual(calls[0].name, "BETA", "afterSave should receive transformed updated entity");
|
|
3639
|
-
},
|
|
3640
|
-
"queued update by __id without __createdAt updates existing entity and preserves createdAt": (ctx) => {
|
|
3641
|
-
const adapter = ctx.state.getAdapter();
|
|
3642
|
-
const tableName = ctx.state.nextTableName("tbl_BatchQueuedUpdateByIdNoCreatedAt");
|
|
3643
|
-
const schema = {
|
|
3644
|
-
tableName,
|
|
3645
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3646
|
-
indexes: [],
|
|
3647
|
-
};
|
|
3648
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3649
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3650
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3651
|
-
const original = repo.save({ name: "alpha", price: 1, category: "x" });
|
|
3652
|
-
const originalCreatedAt = original.__createdAt;
|
|
3653
|
-
repo.beginBatch();
|
|
3654
|
-
const placeholder = repo.save({ __id: original.__id, name: "beta", price: 2, category: "x" });
|
|
3655
|
-
assertTrue(placeholder.__createdAt !== undefined, "placeholder should include __createdAt for likely-new heuristic");
|
|
3656
|
-
assertEqual(repo.count(), 1, "queued update should not create visible duplicate before commit");
|
|
3657
|
-
repo.commitBatch();
|
|
3658
|
-
assertEqual(repo.count(), 1, "commit should keep a single updated entity");
|
|
3659
|
-
const updated = repo.findById(original.__id);
|
|
3660
|
-
assertTrue(updated !== null, "updated entity should still be found by original id");
|
|
3661
|
-
assertEqual(updated.name, "beta", "name should be updated");
|
|
3662
|
-
assertEqual(updated.price, 2, "price should be updated");
|
|
3663
|
-
assertEqual(updated.__createdAt, originalCreatedAt, "original createdAt should be preserved on update");
|
|
3664
|
-
const all = repo.find();
|
|
3665
|
-
assertEqual(all.length, 1, "only one row should exist after queued update commit");
|
|
3666
|
-
assertEqual(all[0].__id, original.__id, "row id should remain unchanged");
|
|
3667
|
-
},
|
|
3668
|
-
"save() in batch mode with explicit __id sets __createdAt for new entity": (ctx) => {
|
|
3669
|
-
const adapter = ctx.state.getAdapter();
|
|
3670
|
-
const tableName = ctx.state.nextTableName("tbl_BatchCreatedAt");
|
|
3671
|
-
const schema = {
|
|
3672
|
-
tableName,
|
|
3673
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3674
|
-
indexes: [],
|
|
3675
|
-
};
|
|
3676
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3677
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3678
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3679
|
-
repo.beginBatch();
|
|
3680
|
-
const placeholder = repo.save({ __id: "brand-new-id", name: "X", price: 5, category: "tools" });
|
|
3681
|
-
assertTrue(placeholder.__createdAt !== undefined, "batch save with explicit __id and no __createdAt should include __createdAt");
|
|
3682
|
-
repo.rollbackBatch();
|
|
3683
|
-
},
|
|
3684
|
-
"commitBatch persists queued save with explicit __id for new entity": (ctx) => {
|
|
3685
|
-
const adapter = ctx.state.getAdapter();
|
|
3686
|
-
const tableName = ctx.state.nextTableName("tbl_BatchCreatedAtCommit");
|
|
3687
|
-
const schema = {
|
|
3688
|
-
tableName,
|
|
3689
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3690
|
-
indexes: [],
|
|
3691
|
-
};
|
|
3692
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3693
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3694
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3695
|
-
repo.beginBatch();
|
|
3696
|
-
repo.save({ __id: "brand-new-id", name: "X", price: 5, category: "tools" });
|
|
3697
|
-
assertEqual(repo.count(), 0, "queued save should not persist before commit");
|
|
3698
|
-
repo.commitBatch();
|
|
3699
|
-
assertEqual(repo.count(), 1, "queued save should persist on commit");
|
|
3700
|
-
const found = repo.findById("brand-new-id");
|
|
3701
|
-
assertTrue(found !== null, "entity with explicit id should be persisted");
|
|
3702
|
-
assertEqual(found.__id, "brand-new-id", "persisted entity id should match explicit __id");
|
|
3703
|
-
assertEqual(found.name, "X", "persisted entity name should match payload");
|
|
3704
|
-
assertTrue(found.__createdAt !== undefined, "persisted entity should have __createdAt set");
|
|
3705
|
-
},
|
|
3706
|
-
"two queued saves with same explicit __id produce one updated entity after commit": (ctx) => {
|
|
3707
|
-
const adapter = ctx.state.getAdapter();
|
|
3708
|
-
const tableName = ctx.state.nextTableName("tbl_BatchTwoSavesSameId");
|
|
3709
|
-
const schema = {
|
|
3710
|
-
tableName,
|
|
3711
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3712
|
-
indexes: [],
|
|
3713
|
-
};
|
|
3714
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3715
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3716
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3717
|
-
repo.beginBatch();
|
|
3718
|
-
repo.save({ __id: "same-id", name: "First", price: 1, category: "x" });
|
|
3719
|
-
repo.save({
|
|
3720
|
-
__id: "same-id",
|
|
3721
|
-
name: "Second",
|
|
3722
|
-
price: 2,
|
|
3723
|
-
category: "y",
|
|
3724
|
-
__createdAt: "2020-01-01T00:00:00.000Z",
|
|
3725
|
-
});
|
|
3726
|
-
assertEqual(repo.count(), 0, "queued saves should not persist before commit");
|
|
3727
|
-
repo.commitBatch();
|
|
3728
|
-
assertEqual(repo.count(), 1, "same-id queued saves should end with one persisted entity");
|
|
3729
|
-
const found = repo.findById("same-id");
|
|
3730
|
-
assertTrue(found !== null, "entity with same-id should exist after commit");
|
|
3731
|
-
assertEqual(found.name, "Second", "final name should come from second queued save");
|
|
3732
|
-
assertEqual(found.price, 2, "final price should come from second queued save");
|
|
3733
|
-
assertEqual(found.category, "y", "final category should come from second queued save");
|
|
3734
|
-
assertTrue(found.__createdAt !== undefined, "persisted entity should have __createdAt");
|
|
3735
|
-
const all = repo.find();
|
|
3736
|
-
assertEqual(all.length, 1, "there should be exactly one row after commit");
|
|
3737
|
-
assertEqual(all[0].__id, "same-id", "row id should be the explicit same-id");
|
|
3738
|
-
},
|
|
3739
|
-
"two queued saves with same explicit __id trigger afterSave as create then update": (ctx) => {
|
|
3740
|
-
const adapter = ctx.state.getAdapter();
|
|
3741
|
-
const tableName = ctx.state.nextTableName("tbl_BatchTwoSavesSameIdHooks");
|
|
3742
|
-
const schema = {
|
|
3743
|
-
tableName,
|
|
3744
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3745
|
-
indexes: [],
|
|
3746
|
-
};
|
|
3747
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3748
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3749
|
-
const calls = [];
|
|
3750
|
-
const hooks = {
|
|
3751
|
-
afterSave: (entity, isNew) => {
|
|
3752
|
-
calls.push({ isNew, name: String(entity.name ?? "") });
|
|
3753
|
-
},
|
|
3754
|
-
};
|
|
3755
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3756
|
-
repo.beginBatch();
|
|
3757
|
-
repo.save({ __id: "same-id", name: "First", price: 1, category: "x" });
|
|
3758
|
-
repo.save({
|
|
3759
|
-
__id: "same-id",
|
|
3760
|
-
name: "Second",
|
|
3761
|
-
price: 2,
|
|
3762
|
-
category: "y",
|
|
3763
|
-
__createdAt: "2020-01-01T00:00:00.000Z",
|
|
3764
|
-
});
|
|
3765
|
-
assertEqual(calls.length, 0, "afterSave should not fire before commit");
|
|
3766
|
-
repo.commitBatch();
|
|
3767
|
-
assertEqual(calls.length, 2, "afterSave should fire for both queued saves on commit");
|
|
3768
|
-
assertTrue(calls[0].isNew, "first queued save should be treated as create");
|
|
3769
|
-
assertEqual(calls[0].name, "First", "first callback should contain first payload");
|
|
3770
|
-
assertTrue(!calls[1].isNew, "second queued save should be treated as update");
|
|
3771
|
-
assertEqual(calls[1].name, "Second", "second callback should contain second payload");
|
|
3772
|
-
},
|
|
3773
|
-
"deleteAll() in batch mode queues deletes for deferred execution": (ctx) => {
|
|
3774
|
-
const adapter = ctx.state.getAdapter();
|
|
3775
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDelAll");
|
|
3776
|
-
const schema = {
|
|
3777
|
-
tableName,
|
|
3778
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3779
|
-
indexes: [],
|
|
3780
|
-
};
|
|
3781
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3782
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3783
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3784
|
-
repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3785
|
-
repo.save({ name: "Beta", price: 2, category: "x" });
|
|
3786
|
-
assertEqual(repo.count(), 2, "should have 2 entities before batch");
|
|
3787
|
-
repo.beginBatch();
|
|
3788
|
-
const queued = repo.deleteAll();
|
|
3789
|
-
assertEqual(queued, 2, "deleteAll() in batch mode should return the number of queued deletes");
|
|
3790
|
-
assertEqual(repo.count(), 2, "count() should still reflect sheet state during batch");
|
|
3791
|
-
repo.commitBatch();
|
|
3792
|
-
assertEqual(repo.count(), 0, "all entities should be deleted after commitBatch");
|
|
3793
|
-
},
|
|
3794
|
-
"deleteAll(options) in batch mode queues only matching deletes": (ctx) => {
|
|
3795
|
-
const adapter = ctx.state.getAdapter();
|
|
3796
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDelAllFiltered");
|
|
3797
|
-
const schema = {
|
|
3798
|
-
tableName,
|
|
3799
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3800
|
-
indexes: [],
|
|
3801
|
-
};
|
|
3802
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3803
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3804
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3805
|
-
repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3806
|
-
repo.save({ name: "Beta", price: 2, category: "y" });
|
|
3807
|
-
repo.save({ name: "Gamma", price: 3, category: "x" });
|
|
3808
|
-
assertEqual(repo.count(), 3, "should have 3 entities before filtered batch deleteAll");
|
|
3809
|
-
repo.beginBatch();
|
|
3810
|
-
const queued = repo.deleteAll({ where: [{ field: "category", operator: "=", value: "x" }] });
|
|
3811
|
-
assertEqual(queued, 2, "should queue exactly the two matching entities");
|
|
3812
|
-
assertEqual(repo.count(), 3, "count should remain unchanged until commit");
|
|
3813
|
-
repo.commitBatch();
|
|
3814
|
-
const remaining = repo.find();
|
|
3815
|
-
assertEqual(remaining.length, 1, "one entity should remain after filtered deletes");
|
|
3816
|
-
assertEqual(remaining[0].name, "Beta", "remaining entity should be Beta");
|
|
3817
|
-
},
|
|
3818
|
-
"delete() in batch mode with non-existent ID is a deferred no-op after commit": (ctx) => {
|
|
3819
|
-
const adapter = ctx.state.getAdapter();
|
|
3820
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDeleteMissing");
|
|
3821
|
-
const schema = {
|
|
3822
|
-
tableName,
|
|
3823
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3824
|
-
indexes: [],
|
|
3825
|
-
};
|
|
3826
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3827
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3828
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3829
|
-
repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3830
|
-
assertEqual(repo.count(), 1, "should have one entity before batch delete missing ID");
|
|
3831
|
-
repo.beginBatch();
|
|
3832
|
-
const accepted = repo.delete("missing-id");
|
|
3833
|
-
assertTrue(accepted, "delete should be accepted in batch mode");
|
|
3834
|
-
assertEqual(repo.count(), 1, "count should remain unchanged before commit");
|
|
3835
|
-
repo.commitBatch();
|
|
3836
|
-
assertEqual(repo.count(), 1, "missing-id delete should be a no-op after commit");
|
|
3837
|
-
const all = repo.find();
|
|
3838
|
-
assertEqual(all.length, 1, "entity should still exist");
|
|
3839
|
-
assertEqual(all[0].name, "Alpha", "remaining entity should be Alpha");
|
|
3840
|
-
},
|
|
3841
|
-
"afterDelete is called for queued delete when batch is committed": (ctx) => {
|
|
3842
|
-
const adapter = ctx.state.getAdapter();
|
|
3843
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDeleteAfterDeleteHook");
|
|
3844
|
-
const schema = {
|
|
3845
|
-
tableName,
|
|
3846
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3847
|
-
indexes: [],
|
|
3848
|
-
};
|
|
3849
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3850
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3851
|
-
const deletedIds = [];
|
|
3852
|
-
const hooks = {
|
|
3853
|
-
afterDelete: (id) => {
|
|
3854
|
-
deletedIds.push(id);
|
|
3855
|
-
},
|
|
3856
|
-
};
|
|
3857
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3858
|
-
const saved = repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3859
|
-
repo.beginBatch();
|
|
3860
|
-
repo.delete(saved.__id);
|
|
3861
|
-
assertEqual(deletedIds.length, 0, "afterDelete should not fire before commit");
|
|
3862
|
-
repo.commitBatch();
|
|
3863
|
-
assertDeepEqual(deletedIds, [saved.__id], "afterDelete should fire with saved ID on commit");
|
|
3864
|
-
assertEqual(repo.count(), 0, "entity should be deleted after commit");
|
|
3865
|
-
},
|
|
3866
|
-
"afterDelete is not called for missing queued delete on commit": (ctx) => {
|
|
3867
|
-
const adapter = ctx.state.getAdapter();
|
|
3868
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDeleteAfterDeleteMissing");
|
|
3869
|
-
const schema = {
|
|
3870
|
-
tableName,
|
|
3871
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3872
|
-
indexes: [],
|
|
3873
|
-
};
|
|
3874
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3875
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3876
|
-
const deletedIds = [];
|
|
3877
|
-
const hooks = {
|
|
3878
|
-
afterDelete: (id) => {
|
|
3879
|
-
deletedIds.push(id);
|
|
3880
|
-
},
|
|
3881
|
-
};
|
|
3882
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3883
|
-
repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3884
|
-
repo.beginBatch();
|
|
3885
|
-
repo.delete("missing-id");
|
|
3886
|
-
assertEqual(deletedIds.length, 0, "afterDelete should not fire before commit");
|
|
3887
|
-
repo.commitBatch();
|
|
3888
|
-
assertEqual(deletedIds.length, 0, "afterDelete should not fire for missing queued delete");
|
|
3889
|
-
assertEqual(repo.count(), 1, "existing entity should remain untouched");
|
|
3890
|
-
},
|
|
3891
|
-
"missing delete in batch does not block valid queued operations": (ctx) => {
|
|
3892
|
-
const adapter = ctx.state.getAdapter();
|
|
3893
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDeleteMissingMixed");
|
|
3894
|
-
const schema = {
|
|
3895
|
-
tableName,
|
|
3896
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3897
|
-
indexes: [],
|
|
3898
|
-
};
|
|
3899
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3900
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3901
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3902
|
-
const alpha = repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3903
|
-
assertEqual(repo.count(), 1, "should start with a single Alpha entity");
|
|
3904
|
-
repo.beginBatch();
|
|
3905
|
-
repo.delete("missing-id");
|
|
3906
|
-
repo.save({ name: "Beta", price: 2, category: "y" });
|
|
3907
|
-
repo.delete(alpha.__id);
|
|
3908
|
-
repo.commitBatch();
|
|
3909
|
-
const all = repo.find();
|
|
3910
|
-
assertEqual(all.length, 1, "exactly one entity should remain after mixed batch commit");
|
|
3911
|
-
assertEqual(all[0].name, "Beta", "Beta should be persisted despite missing delete op");
|
|
3912
|
-
},
|
|
3913
|
-
"delete() outside batch returns false for non-existent ID": (ctx) => {
|
|
3914
|
-
const adapter = ctx.state.getAdapter();
|
|
3915
|
-
const tableName = ctx.state.nextTableName("tbl_DeleteMissingNoBatch");
|
|
3916
|
-
const schema = {
|
|
3917
|
-
tableName,
|
|
3918
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3919
|
-
indexes: [],
|
|
3920
|
-
};
|
|
3921
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3922
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3923
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3924
|
-
repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3925
|
-
assertEqual(repo.count(), 1, "should have one entity before non-batch delete attempt");
|
|
3926
|
-
const deleted = repo.delete("missing-id");
|
|
3927
|
-
assertTrue(!deleted, "delete should return false for non-existent ID outside batch");
|
|
3928
|
-
assertEqual(repo.count(), 1, "count should remain unchanged after failed delete");
|
|
3929
|
-
assertEqual(repo.find()[0].name, "Alpha", "remaining entity should be Alpha");
|
|
3930
|
-
},
|
|
3931
|
-
"delete() falls back to full scan when cached row index is stale": (ctx) => {
|
|
3932
|
-
const adapter = ctx.state.getAdapter();
|
|
3933
|
-
const tableName = ctx.state.nextTableName("tbl_DeleteStaleIndexFallback");
|
|
3934
|
-
const schema = {
|
|
3935
|
-
tableName,
|
|
3936
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3937
|
-
indexes: [],
|
|
3938
|
-
};
|
|
3939
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3940
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3941
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3942
|
-
const alpha = repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3943
|
-
repo.save({ name: "Beta", price: 2, category: "y" });
|
|
3944
|
-
// Prime map and then force stale row index to trigger fallback scan path.
|
|
3945
|
-
assertTrue(repo.findById(alpha.__id) !== null, "alpha should exist before delete");
|
|
3946
|
-
repo.idToRowIndex.set(alpha.__id, 9999);
|
|
3947
|
-
const deleted = repo.delete(alpha.__id);
|
|
3948
|
-
assertTrue(deleted, "delete should succeed via fallback full scan");
|
|
3949
|
-
assertTrue(repo.findById(alpha.__id) === null, "alpha should be deleted");
|
|
3950
|
-
assertEqual(repo.count(), 1, "only one entity should remain");
|
|
3951
|
-
assertEqual(repo.find()[0].name, "Beta", "remaining entity should be Beta");
|
|
3952
|
-
},
|
|
3953
|
-
"deleteAll(options) in batch mode returns zero when nothing matches": (ctx) => {
|
|
3954
|
-
const adapter = ctx.state.getAdapter();
|
|
3955
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDeleteNoMatch");
|
|
3956
|
-
const schema = {
|
|
3957
|
-
tableName,
|
|
3958
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3959
|
-
indexes: [],
|
|
3960
|
-
};
|
|
3961
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3962
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3963
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), undefined, sheet, 0, true);
|
|
3964
|
-
repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3965
|
-
repo.save({ name: "Beta", price: 2, category: "y" });
|
|
3966
|
-
assertEqual(repo.count(), 2, "should have 2 entities before filtered no-match deleteAll");
|
|
3967
|
-
repo.beginBatch();
|
|
3968
|
-
const queued = repo.deleteAll({ where: [{ field: "category", operator: "=", value: "z" }] });
|
|
3969
|
-
assertEqual(queued, 0, "deleteAll should queue zero deletes when filter has no matches");
|
|
3970
|
-
assertEqual(repo.count(), 2, "count should remain unchanged during batch");
|
|
3971
|
-
repo.commitBatch();
|
|
3972
|
-
assertEqual(repo.count(), 2, "count should remain unchanged after committing zero-op deleteAll");
|
|
3973
|
-
},
|
|
3974
|
-
"beforeDelete veto is respected when queued deleteAll is committed": (ctx) => {
|
|
3975
|
-
const adapter = ctx.state.getAdapter();
|
|
3976
|
-
const tableName = ctx.state.nextTableName("tbl_BatchDeleteVetoCommit");
|
|
3977
|
-
const schema = {
|
|
3978
|
-
tableName,
|
|
3979
|
-
fields: [{ name: "name" }, { name: "price" }, { name: "category" }],
|
|
3980
|
-
indexes: [],
|
|
3981
|
-
};
|
|
3982
|
-
const sheet = adapter.insertSheet(tableName);
|
|
3983
|
-
const indexStore = new IndexStore(adapter, new MemoryCache());
|
|
3984
|
-
let vetoId = null;
|
|
3985
|
-
const hooks = {
|
|
3986
|
-
beforeDelete: (id) => id !== vetoId,
|
|
3987
|
-
};
|
|
3988
|
-
const repo = new SheetRepository(adapter, schema, indexStore, new MemoryCache(), hooks, sheet, 0, true);
|
|
3989
|
-
const a = repo.save({ name: "Alpha", price: 1, category: "x" });
|
|
3990
|
-
const b = repo.save({ name: "Beta", price: 2, category: "x" });
|
|
3991
|
-
vetoId = b.__id;
|
|
3992
|
-
assertEqual(repo.count(), 2, "should have 2 entities before queued deleteAll");
|
|
3993
|
-
repo.beginBatch();
|
|
3994
|
-
const queued = repo.deleteAll();
|
|
3995
|
-
assertEqual(queued, 2, "queued deleteAll should include both entities before hooks run");
|
|
3996
|
-
assertEqual(repo.count(), 2, "count should remain unchanged before commit");
|
|
3997
|
-
repo.commitBatch();
|
|
3998
|
-
const remaining = repo.find();
|
|
3999
|
-
assertEqual(remaining.length, 1, "one vetoed entity should remain after commit");
|
|
4000
|
-
assertEqual(remaining[0].__id, vetoId, "remaining entity should be vetoed one");
|
|
4001
|
-
const ids = [a.__id, b.__id];
|
|
4002
|
-
assertTrue(ids.includes(remaining[0].__id), "remaining entity should come from original set");
|
|
4003
|
-
},
|
|
4004
|
-
},
|
|
4005
|
-
};
|
|
4006
|
-
/**
|
|
4007
|
-
* Look up a runtime test handler by its parity case ID (format: `file::testName`).
|
|
4008
|
-
*
|
|
4009
|
-
* @param id - Composite case ID, e.g. `"cache.test.ts::stores and retrieves values"`.
|
|
4010
|
-
* @returns The handler function for that test case.
|
|
4011
|
-
* @throws If the ID format is invalid or no handler is registered.
|
|
4012
|
-
*/
|
|
4013
|
-
function getRuntimeCaseHandler(id) {
|
|
4014
|
-
const separator = "::";
|
|
4015
|
-
const separatorIndex = id.indexOf(separator);
|
|
4016
|
-
if (separatorIndex < 0) {
|
|
4017
|
-
throw new Error(`Invalid parity case ID format: ${id}`);
|
|
4018
|
-
}
|
|
4019
|
-
const file = id.slice(0, separatorIndex);
|
|
4020
|
-
const testName = id.slice(separatorIndex + separator.length);
|
|
4021
|
-
const suiteHandlers = runtimeSuiteHandlers[file];
|
|
4022
|
-
const caseHandler = suiteHandlers?.[testName];
|
|
4023
|
-
if (!caseHandler) {
|
|
4024
|
-
throw new Error(`No runtime handler found for parity case: ${id}`);
|
|
4025
|
-
}
|
|
4026
|
-
return caseHandler;
|
|
4027
|
-
}
|
|
4028
|
-
const RUNTIME_PARITY_CASE_IDS = Object.entries(runtimeSuiteHandlers)
|
|
4029
|
-
.flatMap(([file, testMap]) => Object.keys(testMap).map((testName) => ParityCatalog.toCaseId(file, testName)))
|
|
4030
|
-
.sort();
|
|
4031
|
-
/**
|
|
4032
|
-
* Validate that every Jest test case in {@link ParityCatalog} has a
|
|
4033
|
-
* corresponding runtime handler, and vice versa.
|
|
4034
|
-
*
|
|
4035
|
-
* Throws a descriptive error if any test is missing in either direction,
|
|
4036
|
-
* preventing silent parity drift between Jest and GAS test suites.
|
|
4037
|
-
*/
|
|
4038
|
-
function validateTests() {
|
|
4039
|
-
const expected = new Set(ParityCatalog.CASE_IDS);
|
|
4040
|
-
const actual = new Set(RUNTIME_PARITY_CASE_IDS);
|
|
4041
|
-
const missingInRuntime = ParityCatalog.CASE_IDS.filter((id) => !actual.has(id));
|
|
4042
|
-
const extraInRuntime = RUNTIME_PARITY_CASE_IDS.filter((id) => !expected.has(id));
|
|
4043
|
-
if (missingInRuntime.length > 0 || extraInRuntime.length > 0) {
|
|
4044
|
-
const parts = [];
|
|
4045
|
-
if (missingInRuntime.length > 0) {
|
|
4046
|
-
parts.push(`Missing in runtime (${missingInRuntime.length}): ${missingInRuntime.join(" | ")}`);
|
|
4047
|
-
}
|
|
4048
|
-
if (extraInRuntime.length > 0) {
|
|
4049
|
-
parts.push(`Extra in runtime (${extraInRuntime.length}): ${extraInRuntime.join(" | ")}`);
|
|
4050
|
-
}
|
|
4051
|
-
throw new Error(`Jest/runtime parity drift detected. ${parts.join(" ; ")}`);
|
|
4052
|
-
}
|
|
4053
|
-
}
|
|
4054
|
-
/**
|
|
4055
|
-
* Execute all runtime parity tests for the given suite subset.
|
|
4056
|
-
*
|
|
4057
|
-
* Clears all existing sheets, runs each test handler in sequence,
|
|
4058
|
-
* collects pass/fail results with timing, and returns a JSON report
|
|
4059
|
-
* string. Throws on any failure so the GAS execution log shows the
|
|
4060
|
-
* error summary.
|
|
4061
|
-
*/
|
|
4062
|
-
function runTestsForSuites(suites) {
|
|
4063
|
-
const runStartedAt = Date.now();
|
|
4064
|
-
const state = new RuntimeParityState();
|
|
4065
|
-
const results = [];
|
|
4066
|
-
const total = suites.reduce((sum, s) => sum + s.tests.length, 0);
|
|
4067
|
-
const log = (msg) => {
|
|
4068
|
-
if (typeof Logger !== "undefined" && typeof Logger.log === "function") {
|
|
4069
|
-
Logger.log(msg);
|
|
4070
|
-
}
|
|
4071
|
-
};
|
|
4072
|
-
log(`[SheetORM] Starting parity suite — ${total} test cases`);
|
|
4073
|
-
log("[SheetORM] Clearing all existing sheets from active spreadsheet before test run");
|
|
4074
|
-
state.clearAllSheets(log);
|
|
4075
|
-
for (const suite of suites) {
|
|
4076
|
-
log(`[Suite] ${suite.file} (${suite.tests.length} tests)`);
|
|
4077
|
-
for (const testName of suite.tests) {
|
|
4078
|
-
const id = ParityCatalog.toCaseId(suite.file, testName);
|
|
4079
|
-
const num = results.length + 1;
|
|
4080
|
-
const startedAt = Date.now();
|
|
4081
|
-
try {
|
|
4082
|
-
const handler = getRuntimeCaseHandler(id);
|
|
4083
|
-
handler({ state });
|
|
4084
|
-
const durationMs = Date.now() - startedAt;
|
|
4085
|
-
results.push({ id, ok: true, durationMs });
|
|
4086
|
-
log(` PASS [${num}/${total}] ${testName} (${durationMs} ms)`);
|
|
4087
|
-
}
|
|
4088
|
-
catch (error) {
|
|
4089
|
-
const durationMs = Date.now() - startedAt;
|
|
4090
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
4091
|
-
results.push({ id, ok: false, durationMs, error: errMsg });
|
|
4092
|
-
log(` FAIL [${num}/${total}] ${testName} (${durationMs} ms)`);
|
|
4093
|
-
log(` ${errMsg}`);
|
|
4094
|
-
}
|
|
4095
|
-
}
|
|
4096
|
-
}
|
|
4097
|
-
const failures = results.filter((result) => !result.ok);
|
|
4098
|
-
const passed = results.length - failures.length;
|
|
4099
|
-
const totalDurationMs = Date.now() - runStartedAt;
|
|
4100
|
-
const sumCaseDurationMs = results.reduce((sum, result) => sum + result.durationMs, 0);
|
|
4101
|
-
const avgDurationMs = results.length > 0 ? sumCaseDurationMs / results.length : 0;
|
|
4102
|
-
const fastest = results.reduce((best, current) => {
|
|
4103
|
-
if (!best)
|
|
4104
|
-
return current;
|
|
4105
|
-
return current.durationMs < best.durationMs ? current : best;
|
|
4106
|
-
}, null);
|
|
4107
|
-
const slowest = results.reduce((worst, current) => {
|
|
4108
|
-
if (!worst)
|
|
4109
|
-
return current;
|
|
4110
|
-
return current.durationMs > worst.durationMs ? current : worst;
|
|
4111
|
-
}, null);
|
|
4112
|
-
log(`[SheetORM] Done — ${passed}/${total} passed, ${failures.length} failed`);
|
|
4113
|
-
log(`[SheetORM] Timing summary — total: ${totalDurationMs} ms, avg/test: ${avgDurationMs.toFixed(2)} ms, fastest: ${fastest?.id ?? "n/a"} (${fastest?.durationMs ?? 0} ms), slowest: ${slowest?.id ?? "n/a"} (${slowest?.durationMs ?? 0} ms)`);
|
|
4114
|
-
if (failures.length > 0) {
|
|
4115
|
-
const summary = failures
|
|
4116
|
-
.slice(0, 10)
|
|
4117
|
-
.map((f) => `${f.id} => ${f.error}`)
|
|
4118
|
-
.join(" || ");
|
|
4119
|
-
throw new Error(`Runtime parity failed: ${failures.length}/${results.length} cases failed (${passed} passed). ${summary}`);
|
|
4120
|
-
}
|
|
4121
|
-
const spreadsheetUrl = state.getSpreadsheet().getUrl();
|
|
4122
|
-
const report = {
|
|
4123
|
-
status: "ok",
|
|
4124
|
-
total: results.length,
|
|
4125
|
-
passed,
|
|
4126
|
-
failed: 0,
|
|
4127
|
-
timing: {
|
|
4128
|
-
totalDurationMs,
|
|
4129
|
-
averagePerTestMs: Number(avgDurationMs.toFixed(2)),
|
|
4130
|
-
fastest: fastest
|
|
4131
|
-
? {
|
|
4132
|
-
id: fastest.id,
|
|
4133
|
-
durationMs: fastest.durationMs,
|
|
4134
|
-
}
|
|
4135
|
-
: null,
|
|
4136
|
-
slowest: slowest
|
|
4137
|
-
? {
|
|
4138
|
-
id: slowest.id,
|
|
4139
|
-
durationMs: slowest.durationMs,
|
|
4140
|
-
}
|
|
4141
|
-
: null,
|
|
4142
|
-
},
|
|
4143
|
-
spreadsheetUrl,
|
|
4144
|
-
};
|
|
4145
|
-
log(JSON.stringify(report));
|
|
4146
|
-
return JSON.stringify(report);
|
|
4147
|
-
}
|
|
4148
|
-
/** Stage 1: cache, index-store, query, query-engine (~162 tests). */
|
|
4149
|
-
function runTestsStageOne() {
|
|
4150
|
-
SheetOrmLogger.verbose = false;
|
|
4151
|
-
validateTests();
|
|
4152
|
-
return runTestsForSuites(ParityCatalog.SUITES.slice(0, 4));
|
|
4153
|
-
}
|
|
4154
|
-
/** Stage 2: serialization, uuid (~52 tests, ~60s). */
|
|
4155
|
-
function runTestsStageTwo() {
|
|
4156
|
-
SheetOrmLogger.verbose = false;
|
|
4157
|
-
validateTests();
|
|
4158
|
-
return runTestsForSuites(ParityCatalog.SUITES.slice(4, 6));
|
|
4159
|
-
}
|
|
4160
|
-
/** Stage 3: record (~81 tests, ~250s). */
|
|
4161
|
-
function runTestsStageThree() {
|
|
4162
|
-
SheetOrmLogger.verbose = false;
|
|
4163
|
-
validateTests();
|
|
4164
|
-
return runTestsForSuites(ParityCatalog.SUITES.slice(6, 7));
|
|
4165
|
-
}
|
|
4166
|
-
/** Stage 4: sheet-repository (~35 tests, ~120s). */
|
|
4167
|
-
function runTestsStageFour() {
|
|
4168
|
-
SheetOrmLogger.verbose = false;
|
|
4169
|
-
validateTests();
|
|
4170
|
-
return runTestsForSuites(ParityCatalog.SUITES.slice(7));
|
|
4171
|
-
}
|
|
4172
|
-
/**
|
|
4173
|
-
* Public API for runtime parity testing.
|
|
4174
|
-
*
|
|
4175
|
-
* Provides static methods that GAS entry points delegate to.
|
|
4176
|
-
* Results are persisted to dedicated sheets and can be aggregated
|
|
4177
|
-
* via `validate()`.
|
|
4178
|
-
*/
|
|
4179
|
-
export class RuntimeParity {
|
|
4180
|
-
}
|
|
4181
|
-
/** Execute stage-one parity tests (cache, index-store, query, query-engine). */
|
|
4182
|
-
RuntimeParity.runStageOne = runTestsStageOne;
|
|
4183
|
-
/** Execute stage-two parity tests (serialization, uuid). */
|
|
4184
|
-
RuntimeParity.runStageTwo = runTestsStageTwo;
|
|
4185
|
-
/** Execute stage-three parity tests (record). */
|
|
4186
|
-
RuntimeParity.runStageThree = runTestsStageThree;
|
|
4187
|
-
/** Execute stage-four parity tests (sheet-repository). */
|
|
4188
|
-
RuntimeParity.runStageFour = runTestsStageFour;
|
|
4189
|
-
/** Cross-check that all ParityCatalog case IDs have runtime handlers. */
|
|
4190
|
-
RuntimeParity.validate = validateTests;
|
|
4191
|
-
/** Complete list of runtime parity case IDs (for external cross-reference). */
|
|
4192
|
-
RuntimeParity.CASE_IDS = RUNTIME_PARITY_CASE_IDS;
|
|
4193
|
-
//# sourceMappingURL=RuntimeParity.js.map
|