@malloy-publisher/server 0.0.181 → 0.0.183-dev
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/build.ts +7 -3
- package/dist/app/api-doc.yaml +505 -52
- package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
- package/dist/app/assets/{MainPage-B53xidTF.js → MainPage-BzB3yoqi.js} +2 -2
- package/dist/app/assets/{ModelPage-UMuQe8qY.js → ModelPage-C9O_sAXT.js} +1 -1
- package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
- package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
- package/dist/app/assets/{RouteError-Cv58zNpb.js → RouteError-DAShbVCG.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DZ1StqsX.js → WorkbookPage-Cs_XYEaB.js} +1 -1
- package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
- package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
- package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
- package/dist/app/assets/index-15BOvhp0.js +456 -0
- package/dist/app/assets/{index-DPThhVfX.js → index-Bb2jqquW.js} +1 -1
- package/dist/app/assets/{index-M3Zo817E.js → index-D68X76-7.js} +98 -98
- package/dist/app/assets/{index.umd-DnfBsVqO.js → index.umd-DGBekgSu.js} +1 -1
- package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
- package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
- package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
- package/dist/app/index.html +1 -1
- package/dist/{instrumentation.js → instrumentation.mjs} +10567 -10584
- package/dist/{server.js → server.mjs} +16959 -15357
- package/package.json +19 -17
- package/src/controller/connection.controller.ts +27 -20
- package/src/controller/manifest.controller.ts +29 -0
- package/src/controller/materialization.controller.ts +125 -0
- package/src/controller/model.controller.ts +4 -3
- package/src/controller/package.controller.ts +53 -2
- package/src/controller/query.controller.ts +5 -0
- package/src/errors.ts +24 -0
- package/src/mcp/prompts/handlers.ts +1 -1
- package/src/mcp/resources/model_resource.ts +12 -9
- package/src/mcp/resources/source_resource.ts +7 -6
- package/src/mcp/resources/view_resource.ts +0 -1
- package/src/mcp/tools/execute_query_tool.ts +9 -0
- package/src/server.ts +223 -15
- package/src/service/connection.ts +1 -4
- package/src/service/filter.spec.ts +447 -0
- package/src/service/filter.ts +337 -0
- package/src/service/filter_integration.spec.ts +825 -0
- package/src/service/manifest_service.spec.ts +201 -0
- package/src/service/manifest_service.ts +106 -0
- package/src/service/materialization_service.spec.ts +648 -0
- package/src/service/materialization_service.ts +929 -0
- package/src/service/materialized_table_gc.spec.ts +383 -0
- package/src/service/materialized_table_gc.ts +279 -0
- package/src/service/model.ts +227 -49
- package/src/service/package.ts +50 -0
- package/src/service/project_store.ts +21 -2
- package/src/service/quoting.ts +41 -0
- package/src/service/resolve_project.ts +13 -0
- package/src/storage/DatabaseInterface.ts +103 -1
- package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
- package/src/storage/StorageManager.ts +119 -1
- package/src/storage/duckdb/DuckDBConnection.ts +1 -1
- package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
- package/src/storage/duckdb/DuckDBRepository.ts +99 -9
- package/src/storage/duckdb/ManifestRepository.ts +119 -0
- package/src/storage/duckdb/MaterializationRepository.ts +249 -0
- package/src/storage/duckdb/manifest_store.spec.ts +133 -0
- package/src/storage/duckdb/schema.ts +59 -1
- package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
- package/tests/fixtures/persist-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-test/persist_test.malloy +11 -0
- package/tests/fixtures/persist-test/publisher.json +5 -0
- package/tests/fixtures/publisher.config.json +15 -0
- package/tests/harness/rest_e2e.ts +68 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
- package/tsconfig.json +1 -1
- package/dist/app/assets/HomePage-B0C6gwGj.js +0 -1
- package/dist/app/assets/PackagePage-BEDvm_je.js +0 -1
- package/dist/app/assets/ProjectPage-DzN4P86H.js +0 -1
- package/dist/app/assets/index-D-xPyBUA.js +0 -467
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
|
+
import { Connection } from "@malloydata/malloy";
|
|
3
|
+
import {
|
|
4
|
+
afterAll,
|
|
5
|
+
afterEach,
|
|
6
|
+
beforeAll,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
} from "bun:test";
|
|
12
|
+
import fs from "fs/promises";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { BadRequestError } from "../errors";
|
|
16
|
+
import { Model } from "./model";
|
|
17
|
+
|
|
18
|
+
const TEST_DIR = path.join(os.tmpdir(), "filter-integration-tests");
|
|
19
|
+
const TEST_DB_DIR = path.join(TEST_DIR, "db");
|
|
20
|
+
const TEST_DB_PATH = path.join(TEST_DB_DIR, "test.duckdb");
|
|
21
|
+
const TEST_PKG_DIR = path.join(TEST_DIR, "pkg");
|
|
22
|
+
|
|
23
|
+
let duckdbConnection: DuckDBConnection;
|
|
24
|
+
|
|
25
|
+
const SEED_SQL = `
|
|
26
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
27
|
+
order_id INTEGER,
|
|
28
|
+
region VARCHAR,
|
|
29
|
+
status VARCHAR,
|
|
30
|
+
customer_id VARCHAR,
|
|
31
|
+
amount DOUBLE
|
|
32
|
+
);
|
|
33
|
+
INSERT INTO orders VALUES
|
|
34
|
+
(1, 'US', 'active', 'cust_a', 100.0),
|
|
35
|
+
(2, 'US', 'active', 'cust_a', 200.0),
|
|
36
|
+
(3, 'EU', 'active', 'cust_b', 150.0),
|
|
37
|
+
(4, 'EU', 'cancelled', 'cust_b', 75.0),
|
|
38
|
+
(5, 'APAC', 'active', 'cust_c', 300.0),
|
|
39
|
+
(6, 'APAC', 'cancelled', 'cust_c', 50.0);
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const MODEL_WITH_REQUIRED = `
|
|
43
|
+
#(filter) dimension=region type=in
|
|
44
|
+
#(filter) dimension=status type=equal
|
|
45
|
+
#(filter) name=tenant dimension=customer_id type=equal implicit required
|
|
46
|
+
source: orders is duckdb.table('orders') extend {
|
|
47
|
+
primary_key: order_id
|
|
48
|
+
|
|
49
|
+
measure:
|
|
50
|
+
order_count is count()
|
|
51
|
+
total_amount is sum(amount)
|
|
52
|
+
|
|
53
|
+
view: summary is {
|
|
54
|
+
aggregate: order_count, total_amount
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
view: by_region is {
|
|
58
|
+
group_by: region
|
|
59
|
+
aggregate: order_count, total_amount
|
|
60
|
+
order_by: region
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const MODEL_OPTIONAL_ONLY = `
|
|
66
|
+
#(filter) dimension=region type=in
|
|
67
|
+
#(filter) dimension=status type=equal
|
|
68
|
+
source: orders is duckdb.table('orders') extend {
|
|
69
|
+
primary_key: order_id
|
|
70
|
+
|
|
71
|
+
measure:
|
|
72
|
+
order_count is count()
|
|
73
|
+
total_amount is sum(amount)
|
|
74
|
+
|
|
75
|
+
view: summary is {
|
|
76
|
+
aggregate: order_count, total_amount
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
view: by_region is {
|
|
80
|
+
group_by: region
|
|
81
|
+
aggregate: order_count, total_amount
|
|
82
|
+
order_by: region
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const NOTEBOOK_MALLOYNB = `>>>markdown
|
|
88
|
+
# Test Notebook
|
|
89
|
+
|
|
90
|
+
>>>malloy
|
|
91
|
+
import "orders_optional.malloy"
|
|
92
|
+
|
|
93
|
+
>>>malloy
|
|
94
|
+
run: orders -> summary
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
// Base source with 3 filters: region (in), status (equal), customer_id (equal, required)
|
|
98
|
+
const MODEL_BASE_FOR_EXTEND = `
|
|
99
|
+
#(filter) name=region dimension=region type=in
|
|
100
|
+
#(filter) name=status dimension=status type=equal
|
|
101
|
+
#(filter) name=tenant dimension=customer_id type=equal required
|
|
102
|
+
source: base_orders is duckdb.table('orders') extend {
|
|
103
|
+
primary_key: order_id
|
|
104
|
+
|
|
105
|
+
measure:
|
|
106
|
+
order_count is count()
|
|
107
|
+
total_amount is sum(amount)
|
|
108
|
+
|
|
109
|
+
view: summary is {
|
|
110
|
+
aggregate: order_count, total_amount
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
// Extending source: overrides region (in → equal), overrides tenant
|
|
116
|
+
// (removes required), keeps status from base unchanged
|
|
117
|
+
const MODEL_CHILD_EXTEND = `
|
|
118
|
+
import "base_orders.malloy"
|
|
119
|
+
|
|
120
|
+
#(filter) name=region dimension=region type=equal
|
|
121
|
+
#(filter) name=tenant dimension=customer_id type=equal
|
|
122
|
+
source: child_orders is base_orders extend {}
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
// Notebook against the extended source
|
|
126
|
+
const NOTEBOOK_EXTEND = `>>>markdown
|
|
127
|
+
# Extend Test
|
|
128
|
+
|
|
129
|
+
>>>malloy
|
|
130
|
+
import "child_orders.malloy"
|
|
131
|
+
|
|
132
|
+
>>>malloy
|
|
133
|
+
run: child_orders -> summary
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
beforeAll(async () => {
|
|
137
|
+
await fs.mkdir(TEST_DB_DIR, { recursive: true });
|
|
138
|
+
await fs.mkdir(TEST_PKG_DIR, { recursive: true });
|
|
139
|
+
duckdbConnection = new DuckDBConnection("duckdb", TEST_DB_PATH, TEST_DB_DIR);
|
|
140
|
+
for (const stmt of SEED_SQL.trim().split(";").filter(Boolean)) {
|
|
141
|
+
await duckdbConnection.runSQL(stmt.trim() + ";");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterAll(async () => {
|
|
146
|
+
try {
|
|
147
|
+
await duckdbConnection.close();
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
149
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore cleanup errors
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function getConnections(): Map<string, Connection> {
|
|
156
|
+
const map = new Map<string, Connection>();
|
|
157
|
+
map.set("duckdb", duckdbConnection);
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function writeFile(filename: string, content: string): Promise<void> {
|
|
162
|
+
await fs.writeFile(path.join(TEST_PKG_DIR, filename), content, "utf-8");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Row = Record<string, unknown>;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Malloy's compactResult (queryResults.data.value) is the raw array of row objects.
|
|
169
|
+
*/
|
|
170
|
+
function asRows(compactResult: unknown): Row[] {
|
|
171
|
+
return compactResult as Row[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse a notebook cell result (JSON-stringified Malloy result).
|
|
176
|
+
* The shape is: { schema, data: { kind, array_value: [{ record_value: { field_name: {kind, ...value} } }, ...] }, ... }
|
|
177
|
+
* We extract column values from the record structure.
|
|
178
|
+
*/
|
|
179
|
+
function parseNotebookResult(resultJson: string): Row[] {
|
|
180
|
+
const parsed = JSON.parse(resultJson);
|
|
181
|
+
const arrayValue = parsed?.data?.array_value;
|
|
182
|
+
if (!Array.isArray(arrayValue)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Cannot extract rows from notebook result: ${JSON.stringify(Object.keys(parsed?.data ?? {}))}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const schema = parsed.schema?.fields ?? [];
|
|
189
|
+
const fieldNames = schema.map((f: { name: string }) => f.name);
|
|
190
|
+
|
|
191
|
+
return arrayValue.map(
|
|
192
|
+
(record: { record_value?: Array<Record<string, unknown>> }) => {
|
|
193
|
+
const row: Row = {};
|
|
194
|
+
const cells = record.record_value ?? [];
|
|
195
|
+
for (let i = 0; i < fieldNames.length; i++) {
|
|
196
|
+
const cell = cells[i];
|
|
197
|
+
if (!cell) continue;
|
|
198
|
+
row[fieldNames[i]] =
|
|
199
|
+
cell.number_value ??
|
|
200
|
+
cell.string_value ??
|
|
201
|
+
cell.boolean_value ??
|
|
202
|
+
cell.timestamp_value ??
|
|
203
|
+
null;
|
|
204
|
+
}
|
|
205
|
+
return row;
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
describe("filter integration", () => {
|
|
211
|
+
beforeEach(async () => {
|
|
212
|
+
await writeFile("orders.malloy", MODEL_WITH_REQUIRED);
|
|
213
|
+
await writeFile("orders_optional.malloy", MODEL_OPTIONAL_ONLY);
|
|
214
|
+
await writeFile("test_notebook.malloynb", NOTEBOOK_MALLOYNB);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
afterEach(async () => {
|
|
218
|
+
const files = await fs.readdir(TEST_PKG_DIR);
|
|
219
|
+
for (const f of files) {
|
|
220
|
+
if (f.endsWith(".malloy") || f.endsWith(".malloynb")) {
|
|
221
|
+
await fs.unlink(path.join(TEST_PKG_DIR, f));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// -----------------------------------------------------------------------
|
|
227
|
+
// Model loading & filter metadata
|
|
228
|
+
// -----------------------------------------------------------------------
|
|
229
|
+
describe("model loading", () => {
|
|
230
|
+
it("parses filter annotations and exposes them via getSources()", async () => {
|
|
231
|
+
const model = await Model.create(
|
|
232
|
+
"test-pkg",
|
|
233
|
+
TEST_PKG_DIR,
|
|
234
|
+
"orders.malloy",
|
|
235
|
+
getConnections(),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const sources = model.getSources();
|
|
239
|
+
expect(sources).toBeDefined();
|
|
240
|
+
expect(sources!.length).toBeGreaterThanOrEqual(1);
|
|
241
|
+
|
|
242
|
+
const ordersSource = sources!.find((s) => s.name === "orders");
|
|
243
|
+
expect(ordersSource).toBeDefined();
|
|
244
|
+
expect(ordersSource!.filters).toBeDefined();
|
|
245
|
+
expect(ordersSource!.filters!.length).toBe(3);
|
|
246
|
+
|
|
247
|
+
const regionFilter = ordersSource!.filters!.find(
|
|
248
|
+
(f) => f.dimension === "region",
|
|
249
|
+
);
|
|
250
|
+
expect(regionFilter).toBeDefined();
|
|
251
|
+
expect(regionFilter!.type).toBe("in");
|
|
252
|
+
expect(regionFilter!.required).toBe(false);
|
|
253
|
+
expect(regionFilter!.implicit).toBe(false);
|
|
254
|
+
|
|
255
|
+
const statusFilter = ordersSource!.filters!.find(
|
|
256
|
+
(f) => f.dimension === "status",
|
|
257
|
+
);
|
|
258
|
+
expect(statusFilter).toBeDefined();
|
|
259
|
+
expect(statusFilter!.type).toBe("equal");
|
|
260
|
+
expect(statusFilter!.required).toBe(false);
|
|
261
|
+
|
|
262
|
+
const tenantFilter = ordersSource!.filters!.find(
|
|
263
|
+
(f) => f.dimension === "customer_id",
|
|
264
|
+
);
|
|
265
|
+
expect(tenantFilter).toBeDefined();
|
|
266
|
+
expect(tenantFilter!.name).toBe("tenant");
|
|
267
|
+
expect(tenantFilter!.type).toBe("equal");
|
|
268
|
+
expect(tenantFilter!.implicit).toBe(true);
|
|
269
|
+
expect(tenantFilter!.required).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("loads a model with optional-only filters", async () => {
|
|
273
|
+
const model = await Model.create(
|
|
274
|
+
"test-pkg",
|
|
275
|
+
TEST_PKG_DIR,
|
|
276
|
+
"orders_optional.malloy",
|
|
277
|
+
getConnections(),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const sources = model.getSources();
|
|
281
|
+
const ordersSource = sources!.find((s) => s.name === "orders");
|
|
282
|
+
expect(ordersSource!.filters!.length).toBe(2);
|
|
283
|
+
expect(ordersSource!.filters!.every((f) => f.required === false)).toBe(
|
|
284
|
+
true,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// -----------------------------------------------------------------------
|
|
290
|
+
// Query execution with optional filters
|
|
291
|
+
// -----------------------------------------------------------------------
|
|
292
|
+
describe("query execution with optional filters", () => {
|
|
293
|
+
it("runs unfiltered query (no filterParams provided)", async () => {
|
|
294
|
+
const model = await Model.create(
|
|
295
|
+
"test-pkg",
|
|
296
|
+
TEST_PKG_DIR,
|
|
297
|
+
"orders_optional.malloy",
|
|
298
|
+
getConnections(),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const { compactResult } = await model.getQueryResults(
|
|
302
|
+
"orders",
|
|
303
|
+
"summary",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const r = asRows(compactResult);
|
|
307
|
+
expect(r.length).toBe(1);
|
|
308
|
+
expect(Number(r[0].order_count)).toBe(6);
|
|
309
|
+
expect(Number(r[0].total_amount)).toBe(875);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("applies region=in filter with single value", async () => {
|
|
313
|
+
const model = await Model.create(
|
|
314
|
+
"test-pkg",
|
|
315
|
+
TEST_PKG_DIR,
|
|
316
|
+
"orders_optional.malloy",
|
|
317
|
+
getConnections(),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const { compactResult } = await model.getQueryResults(
|
|
321
|
+
"orders",
|
|
322
|
+
"summary",
|
|
323
|
+
undefined,
|
|
324
|
+
{ region: ["US"] },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const r = asRows(compactResult);
|
|
328
|
+
expect(r.length).toBe(1);
|
|
329
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
330
|
+
expect(Number(r[0].total_amount)).toBe(300);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("applies region=in filter with multiple values", async () => {
|
|
334
|
+
const model = await Model.create(
|
|
335
|
+
"test-pkg",
|
|
336
|
+
TEST_PKG_DIR,
|
|
337
|
+
"orders_optional.malloy",
|
|
338
|
+
getConnections(),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const { compactResult } = await model.getQueryResults(
|
|
342
|
+
"orders",
|
|
343
|
+
"summary",
|
|
344
|
+
undefined,
|
|
345
|
+
{ region: ["US", "EU"] },
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const r = asRows(compactResult);
|
|
349
|
+
expect(r.length).toBe(1);
|
|
350
|
+
expect(Number(r[0].order_count)).toBe(4);
|
|
351
|
+
expect(Number(r[0].total_amount)).toBe(525);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("applies status=equal filter", async () => {
|
|
355
|
+
const model = await Model.create(
|
|
356
|
+
"test-pkg",
|
|
357
|
+
TEST_PKG_DIR,
|
|
358
|
+
"orders_optional.malloy",
|
|
359
|
+
getConnections(),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const { compactResult } = await model.getQueryResults(
|
|
363
|
+
"orders",
|
|
364
|
+
"summary",
|
|
365
|
+
undefined,
|
|
366
|
+
{ status: "active" },
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const r = asRows(compactResult);
|
|
370
|
+
expect(r.length).toBe(1);
|
|
371
|
+
expect(Number(r[0].order_count)).toBe(4);
|
|
372
|
+
expect(Number(r[0].total_amount)).toBe(750);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("applies combined region + status filters", async () => {
|
|
376
|
+
const model = await Model.create(
|
|
377
|
+
"test-pkg",
|
|
378
|
+
TEST_PKG_DIR,
|
|
379
|
+
"orders_optional.malloy",
|
|
380
|
+
getConnections(),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const { compactResult } = await model.getQueryResults(
|
|
384
|
+
"orders",
|
|
385
|
+
"summary",
|
|
386
|
+
undefined,
|
|
387
|
+
{ region: ["EU"], status: "cancelled" },
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const r = asRows(compactResult);
|
|
391
|
+
expect(r.length).toBe(1);
|
|
392
|
+
expect(Number(r[0].order_count)).toBe(1);
|
|
393
|
+
expect(Number(r[0].total_amount)).toBe(75);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("works with by_region view and filters", async () => {
|
|
397
|
+
const model = await Model.create(
|
|
398
|
+
"test-pkg",
|
|
399
|
+
TEST_PKG_DIR,
|
|
400
|
+
"orders_optional.malloy",
|
|
401
|
+
getConnections(),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const { compactResult } = await model.getQueryResults(
|
|
405
|
+
"orders",
|
|
406
|
+
"by_region",
|
|
407
|
+
undefined,
|
|
408
|
+
{ status: "cancelled" },
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const r = asRows(compactResult);
|
|
412
|
+
expect(r.length).toBe(2);
|
|
413
|
+
const regions = r.map((row) => row.region);
|
|
414
|
+
expect(regions).toContain("EU");
|
|
415
|
+
expect(regions).toContain("APAC");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("works with ad-hoc query string and filters", async () => {
|
|
419
|
+
const model = await Model.create(
|
|
420
|
+
"test-pkg",
|
|
421
|
+
TEST_PKG_DIR,
|
|
422
|
+
"orders_optional.malloy",
|
|
423
|
+
getConnections(),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const { compactResult } = await model.getQueryResults(
|
|
427
|
+
undefined,
|
|
428
|
+
undefined,
|
|
429
|
+
"run: orders -> { aggregate: order_count is count() }",
|
|
430
|
+
{ region: ["APAC"] },
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const r = asRows(compactResult);
|
|
434
|
+
expect(r.length).toBe(1);
|
|
435
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// -----------------------------------------------------------------------
|
|
440
|
+
// Required filter enforcement
|
|
441
|
+
// -----------------------------------------------------------------------
|
|
442
|
+
describe("required filter enforcement", () => {
|
|
443
|
+
it("throws when required filter is missing", async () => {
|
|
444
|
+
const model = await Model.create(
|
|
445
|
+
"test-pkg",
|
|
446
|
+
TEST_PKG_DIR,
|
|
447
|
+
"orders.malloy",
|
|
448
|
+
getConnections(),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await expect(
|
|
452
|
+
model.getQueryResults("orders", "summary", undefined, {
|
|
453
|
+
region: ["US"],
|
|
454
|
+
}),
|
|
455
|
+
).rejects.toThrow(BadRequestError);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("throws descriptive error for missing required filter", async () => {
|
|
459
|
+
const model = await Model.create(
|
|
460
|
+
"test-pkg",
|
|
461
|
+
TEST_PKG_DIR,
|
|
462
|
+
"orders.malloy",
|
|
463
|
+
getConnections(),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
await model.getQueryResults("orders", "summary", undefined, {});
|
|
468
|
+
throw new Error("Should have thrown");
|
|
469
|
+
} catch (error) {
|
|
470
|
+
expect(error).toBeInstanceOf(BadRequestError);
|
|
471
|
+
expect((error as Error).message).toContain("tenant");
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("succeeds when required filter is provided", async () => {
|
|
476
|
+
const model = await Model.create(
|
|
477
|
+
"test-pkg",
|
|
478
|
+
TEST_PKG_DIR,
|
|
479
|
+
"orders.malloy",
|
|
480
|
+
getConnections(),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const { compactResult } = await model.getQueryResults(
|
|
484
|
+
"orders",
|
|
485
|
+
"summary",
|
|
486
|
+
undefined,
|
|
487
|
+
{ tenant: "cust_a", region: ["US"] },
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const r = asRows(compactResult);
|
|
491
|
+
expect(r.length).toBe(1);
|
|
492
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
493
|
+
expect(Number(r[0].total_amount)).toBe(300);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("applies required + optional filters together", async () => {
|
|
497
|
+
const model = await Model.create(
|
|
498
|
+
"test-pkg",
|
|
499
|
+
TEST_PKG_DIR,
|
|
500
|
+
"orders.malloy",
|
|
501
|
+
getConnections(),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const { compactResult } = await model.getQueryResults(
|
|
505
|
+
"orders",
|
|
506
|
+
"summary",
|
|
507
|
+
undefined,
|
|
508
|
+
{ tenant: "cust_b", status: "cancelled" },
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const r = asRows(compactResult);
|
|
512
|
+
expect(r.length).toBe(1);
|
|
513
|
+
expect(Number(r[0].order_count)).toBe(1);
|
|
514
|
+
expect(Number(r[0].total_amount)).toBe(75);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// -----------------------------------------------------------------------
|
|
519
|
+
// bypassFilters
|
|
520
|
+
// -----------------------------------------------------------------------
|
|
521
|
+
describe("bypassFilters", () => {
|
|
522
|
+
it("skips required filter validation when bypassFilters=true", async () => {
|
|
523
|
+
const model = await Model.create(
|
|
524
|
+
"test-pkg",
|
|
525
|
+
TEST_PKG_DIR,
|
|
526
|
+
"orders.malloy",
|
|
527
|
+
getConnections(),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const { compactResult } = await model.getQueryResults(
|
|
531
|
+
"orders",
|
|
532
|
+
"summary",
|
|
533
|
+
undefined,
|
|
534
|
+
{},
|
|
535
|
+
true,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const r = asRows(compactResult);
|
|
539
|
+
expect(r.length).toBe(1);
|
|
540
|
+
expect(Number(r[0].order_count)).toBe(6);
|
|
541
|
+
expect(Number(r[0].total_amount)).toBe(875);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("ignores provided filters when bypassFilters=true", async () => {
|
|
545
|
+
const model = await Model.create(
|
|
546
|
+
"test-pkg",
|
|
547
|
+
TEST_PKG_DIR,
|
|
548
|
+
"orders_optional.malloy",
|
|
549
|
+
getConnections(),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const { compactResult } = await model.getQueryResults(
|
|
553
|
+
"orders",
|
|
554
|
+
"summary",
|
|
555
|
+
undefined,
|
|
556
|
+
{ region: ["US"] },
|
|
557
|
+
true,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const r = asRows(compactResult);
|
|
561
|
+
expect(Number(r[0].order_count)).toBe(6);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// -----------------------------------------------------------------------
|
|
566
|
+
// Notebook cell execution with filters
|
|
567
|
+
// -----------------------------------------------------------------------
|
|
568
|
+
describe("notebook cell execution", () => {
|
|
569
|
+
it("executes notebook code cell without filters", async () => {
|
|
570
|
+
const model = await Model.create(
|
|
571
|
+
"test-pkg",
|
|
572
|
+
TEST_PKG_DIR,
|
|
573
|
+
"test_notebook.malloynb",
|
|
574
|
+
getConnections(),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// Cell 0 = markdown ("# Test Notebook")
|
|
578
|
+
// Cell 1 = code (model definition — no query, just source)
|
|
579
|
+
// Cell 2 = code (run: orders -> summary)
|
|
580
|
+
const codeCell = await model.executeNotebookCell(2);
|
|
581
|
+
expect(codeCell.type).toBe("code");
|
|
582
|
+
expect(codeCell.result).toBeDefined();
|
|
583
|
+
|
|
584
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
585
|
+
expect(notebookRows.length).toBe(1);
|
|
586
|
+
expect(Number(notebookRows[0].order_count)).toBe(6);
|
|
587
|
+
expect(Number(notebookRows[0].total_amount)).toBe(875);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("applies filterParams to notebook cell execution", async () => {
|
|
591
|
+
const model = await Model.create(
|
|
592
|
+
"test-pkg",
|
|
593
|
+
TEST_PKG_DIR,
|
|
594
|
+
"test_notebook.malloynb",
|
|
595
|
+
getConnections(),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const codeCell = await model.executeNotebookCell(2, {
|
|
599
|
+
region: ["US"],
|
|
600
|
+
});
|
|
601
|
+
expect(codeCell.result).toBeDefined();
|
|
602
|
+
|
|
603
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
604
|
+
expect(notebookRows.length).toBe(1);
|
|
605
|
+
expect(Number(notebookRows[0].order_count)).toBe(2);
|
|
606
|
+
expect(Number(notebookRows[0].total_amount)).toBe(300);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("applies status filter to notebook cell execution", async () => {
|
|
610
|
+
const model = await Model.create(
|
|
611
|
+
"test-pkg",
|
|
612
|
+
TEST_PKG_DIR,
|
|
613
|
+
"test_notebook.malloynb",
|
|
614
|
+
getConnections(),
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
const codeCell = await model.executeNotebookCell(2, {
|
|
618
|
+
status: "cancelled",
|
|
619
|
+
});
|
|
620
|
+
expect(codeCell.result).toBeDefined();
|
|
621
|
+
|
|
622
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
623
|
+
expect(notebookRows.length).toBe(1);
|
|
624
|
+
expect(Number(notebookRows[0].order_count)).toBe(2);
|
|
625
|
+
expect(Number(notebookRows[0].total_amount)).toBe(125);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("bypassFilters skips filter injection on notebook cells", async () => {
|
|
629
|
+
const model = await Model.create(
|
|
630
|
+
"test-pkg",
|
|
631
|
+
TEST_PKG_DIR,
|
|
632
|
+
"test_notebook.malloynb",
|
|
633
|
+
getConnections(),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const codeCell = await model.executeNotebookCell(
|
|
637
|
+
2,
|
|
638
|
+
{ region: ["US"] },
|
|
639
|
+
true,
|
|
640
|
+
);
|
|
641
|
+
expect(codeCell.result).toBeDefined();
|
|
642
|
+
|
|
643
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
644
|
+
expect(notebookRows.length).toBe(1);
|
|
645
|
+
expect(Number(notebookRows[0].order_count)).toBe(6);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("returns markdown cells unchanged", async () => {
|
|
649
|
+
const model = await Model.create(
|
|
650
|
+
"test-pkg",
|
|
651
|
+
TEST_PKG_DIR,
|
|
652
|
+
"test_notebook.malloynb",
|
|
653
|
+
getConnections(),
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const markdownCell = await model.executeNotebookCell(0);
|
|
657
|
+
expect(markdownCell.type).toBe("markdown");
|
|
658
|
+
expect(markdownCell.text).toContain("Test Notebook");
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// -----------------------------------------------------------------------
|
|
663
|
+
// Extended source filter inheritance
|
|
664
|
+
// -----------------------------------------------------------------------
|
|
665
|
+
describe("extended source filter inheritance", () => {
|
|
666
|
+
beforeEach(async () => {
|
|
667
|
+
await writeFile("base_orders.malloy", MODEL_BASE_FOR_EXTEND);
|
|
668
|
+
await writeFile("child_orders.malloy", MODEL_CHILD_EXTEND);
|
|
669
|
+
await writeFile("extend_notebook.malloynb", NOTEBOOK_EXTEND);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("inherits base-only filters on extended source", async () => {
|
|
673
|
+
const model = await Model.create(
|
|
674
|
+
"test-pkg",
|
|
675
|
+
TEST_PKG_DIR,
|
|
676
|
+
"child_orders.malloy",
|
|
677
|
+
getConnections(),
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
const sources = model.getSources();
|
|
681
|
+
const child = sources!.find((s) => s.name === "child_orders");
|
|
682
|
+
expect(child).toBeDefined();
|
|
683
|
+
expect(child!.filters).toBeDefined();
|
|
684
|
+
|
|
685
|
+
// status is defined only on the base — it should carry through
|
|
686
|
+
const statusFilter = child!.filters!.find((f) => f.name === "status");
|
|
687
|
+
expect(statusFilter).toBeDefined();
|
|
688
|
+
expect(statusFilter!.type).toBe("equal");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("child overrides base filter type", async () => {
|
|
692
|
+
const model = await Model.create(
|
|
693
|
+
"test-pkg",
|
|
694
|
+
TEST_PKG_DIR,
|
|
695
|
+
"child_orders.malloy",
|
|
696
|
+
getConnections(),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
const sources = model.getSources();
|
|
700
|
+
const child = sources!.find((s) => s.name === "child_orders");
|
|
701
|
+
expect(child).toBeDefined();
|
|
702
|
+
|
|
703
|
+
// region: base=in, child overrides to equal
|
|
704
|
+
const regionFilter = child!.filters!.find((f) => f.name === "region");
|
|
705
|
+
expect(regionFilter).toBeDefined();
|
|
706
|
+
expect(regionFilter!.type).toBe("equal");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("child can remove required flag by overriding", async () => {
|
|
710
|
+
const model = await Model.create(
|
|
711
|
+
"test-pkg",
|
|
712
|
+
TEST_PKG_DIR,
|
|
713
|
+
"child_orders.malloy",
|
|
714
|
+
getConnections(),
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const sources = model.getSources();
|
|
718
|
+
const child = sources!.find((s) => s.name === "child_orders");
|
|
719
|
+
expect(child).toBeDefined();
|
|
720
|
+
|
|
721
|
+
// tenant: base=required, child overrides without required
|
|
722
|
+
const tenantFilter = child!.filters!.find((f) => f.name === "tenant");
|
|
723
|
+
expect(tenantFilter).toBeDefined();
|
|
724
|
+
expect(tenantFilter!.required).toBeFalsy();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("has exactly the expected merged filter set", async () => {
|
|
728
|
+
const model = await Model.create(
|
|
729
|
+
"test-pkg",
|
|
730
|
+
TEST_PKG_DIR,
|
|
731
|
+
"child_orders.malloy",
|
|
732
|
+
getConnections(),
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const sources = model.getSources();
|
|
736
|
+
const child = sources!.find((s) => s.name === "child_orders");
|
|
737
|
+
expect(child).toBeDefined();
|
|
738
|
+
|
|
739
|
+
// 3 unique filter names: region, status (from base), tenant
|
|
740
|
+
const filterNames = child!.filters!.map((f) => f.name).sort();
|
|
741
|
+
expect(filterNames).toEqual(["region", "status", "tenant"]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("applies inherited filter to query on extended source", async () => {
|
|
745
|
+
const model = await Model.create(
|
|
746
|
+
"test-pkg",
|
|
747
|
+
TEST_PKG_DIR,
|
|
748
|
+
"child_orders.malloy",
|
|
749
|
+
getConnections(),
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
// status=active is inherited from the base; should work on child
|
|
753
|
+
const { compactResult } = await model.getQueryResults(
|
|
754
|
+
"child_orders",
|
|
755
|
+
"summary",
|
|
756
|
+
undefined,
|
|
757
|
+
{ status: "active" },
|
|
758
|
+
);
|
|
759
|
+
const rows = asRows(compactResult);
|
|
760
|
+
expect(rows.length).toBe(1);
|
|
761
|
+
// 4 active rows: US(2), EU(1), APAC(1)
|
|
762
|
+
expect(Number(rows[0].order_count)).toBe(4);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("applies overridden filter to query on extended source", async () => {
|
|
766
|
+
const model = await Model.create(
|
|
767
|
+
"test-pkg",
|
|
768
|
+
TEST_PKG_DIR,
|
|
769
|
+
"child_orders.malloy",
|
|
770
|
+
getConnections(),
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
// region is overridden to type=equal on the child
|
|
774
|
+
const { compactResult } = await model.getQueryResults(
|
|
775
|
+
"child_orders",
|
|
776
|
+
"summary",
|
|
777
|
+
undefined,
|
|
778
|
+
{ region: "US" },
|
|
779
|
+
);
|
|
780
|
+
const rows = asRows(compactResult);
|
|
781
|
+
expect(rows.length).toBe(1);
|
|
782
|
+
// 2 US rows
|
|
783
|
+
expect(Number(rows[0].order_count)).toBe(2);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("no longer requires base's required filter after child override", async () => {
|
|
787
|
+
const model = await Model.create(
|
|
788
|
+
"test-pkg",
|
|
789
|
+
TEST_PKG_DIR,
|
|
790
|
+
"child_orders.malloy",
|
|
791
|
+
getConnections(),
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
// On the base, tenant is required. On the child, it's not.
|
|
795
|
+
// Running without tenant should NOT throw.
|
|
796
|
+
const { compactResult } = await model.getQueryResults(
|
|
797
|
+
"child_orders",
|
|
798
|
+
"summary",
|
|
799
|
+
);
|
|
800
|
+
const rows = asRows(compactResult);
|
|
801
|
+
expect(rows.length).toBe(1);
|
|
802
|
+
expect(Number(rows[0].order_count)).toBe(6);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("applies inherited filters to notebook cells", async () => {
|
|
806
|
+
const model = await Model.create(
|
|
807
|
+
"test-pkg",
|
|
808
|
+
TEST_PKG_DIR,
|
|
809
|
+
"extend_notebook.malloynb",
|
|
810
|
+
getConnections(),
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
// Apply status=cancelled (inherited from base) via notebook cell
|
|
814
|
+
const codeCell = await model.executeNotebookCell(2, {
|
|
815
|
+
status: "cancelled",
|
|
816
|
+
});
|
|
817
|
+
expect(codeCell.result).toBeDefined();
|
|
818
|
+
|
|
819
|
+
const rows = parseNotebookResult(codeCell.result!);
|
|
820
|
+
expect(rows.length).toBe(1);
|
|
821
|
+
// 2 cancelled rows: EU(1), APAC(1)
|
|
822
|
+
expect(Number(rows[0].order_count)).toBe(2);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
});
|