@malloy-publisher/server 0.0.181 → 0.0.183
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/dist/app/api-doc.yaml +91 -1
- package/dist/app/assets/{HomePage-B0C6gwGj.js → HomePage-or6BbD5P.js} +1 -1
- package/dist/app/assets/{MainPage-B53xidTF.js → MainPage-DINuSDg0.js} +2 -2
- package/dist/app/assets/{ModelPage-UMuQe8qY.js → ModelPage-BMcaV1YQ.js} +1 -1
- package/dist/app/assets/{PackagePage-BEDvm_je.js → PackagePage-DXxlQcCj.js} +1 -1
- package/dist/app/assets/{ProjectPage-DzN4P86H.js → ProjectPage-vfZc_Kvu.js} +1 -1
- package/dist/app/assets/{RouteError-Cv58zNpb.js → RouteError-r14osUo0.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DZ1StqsX.js → WorkbookPage-HI39NTWs.js} +1 -1
- package/dist/app/assets/{index-D-xPyBUA.js → index-Bw1lh09G.js} +78 -78
- package/dist/app/assets/{index-DPThhVfX.js → index-Dd6uCk_C.js} +1 -1
- package/dist/app/assets/{index-M3Zo817E.js → index-JqHhhRqY.js} +1 -1
- package/dist/app/assets/{index.umd-DnfBsVqO.js → index.umd-lwkX_kFe.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.js +323 -31
- package/package.json +1 -1
- package/src/controller/model.controller.ts +4 -1
- package/src/controller/query.controller.ts +5 -0
- 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 +21 -0
- package/src/service/filter.spec.ts +392 -0
- package/src/service/filter.ts +332 -0
- package/src/service/filter_integration.spec.ts +622 -0
- package/src/service/model.ts +180 -43
|
@@ -0,0 +1,622 @@
|
|
|
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
|
+
beforeAll(async () => {
|
|
98
|
+
await fs.mkdir(TEST_DB_DIR, { recursive: true });
|
|
99
|
+
await fs.mkdir(TEST_PKG_DIR, { recursive: true });
|
|
100
|
+
duckdbConnection = new DuckDBConnection("duckdb", TEST_DB_PATH, TEST_DB_DIR);
|
|
101
|
+
for (const stmt of SEED_SQL.trim().split(";").filter(Boolean)) {
|
|
102
|
+
await duckdbConnection.runSQL(stmt.trim() + ";");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
try {
|
|
108
|
+
await duckdbConnection.close();
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
110
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore cleanup errors
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function getConnections(): Map<string, Connection> {
|
|
117
|
+
const map = new Map<string, Connection>();
|
|
118
|
+
map.set("duckdb", duckdbConnection);
|
|
119
|
+
return map;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function writeFile(filename: string, content: string): Promise<void> {
|
|
123
|
+
await fs.writeFile(path.join(TEST_PKG_DIR, filename), content, "utf-8");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Row = Record<string, unknown>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Malloy's compactResult (queryResults.data.value) is the raw array of row objects.
|
|
130
|
+
*/
|
|
131
|
+
function asRows(compactResult: unknown): Row[] {
|
|
132
|
+
return compactResult as Row[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse a notebook cell result (JSON-stringified Malloy result).
|
|
137
|
+
* The shape is: { schema, data: { kind, array_value: [{ record_value: { field_name: {kind, ...value} } }, ...] }, ... }
|
|
138
|
+
* We extract column values from the record structure.
|
|
139
|
+
*/
|
|
140
|
+
function parseNotebookResult(resultJson: string): Row[] {
|
|
141
|
+
const parsed = JSON.parse(resultJson);
|
|
142
|
+
const arrayValue = parsed?.data?.array_value;
|
|
143
|
+
if (!Array.isArray(arrayValue)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Cannot extract rows from notebook result: ${JSON.stringify(Object.keys(parsed?.data ?? {}))}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const schema = parsed.schema?.fields ?? [];
|
|
150
|
+
const fieldNames = schema.map((f: { name: string }) => f.name);
|
|
151
|
+
|
|
152
|
+
return arrayValue.map(
|
|
153
|
+
(record: { record_value?: Array<Record<string, unknown>> }) => {
|
|
154
|
+
const row: Row = {};
|
|
155
|
+
const cells = record.record_value ?? [];
|
|
156
|
+
for (let i = 0; i < fieldNames.length; i++) {
|
|
157
|
+
const cell = cells[i];
|
|
158
|
+
if (!cell) continue;
|
|
159
|
+
row[fieldNames[i]] =
|
|
160
|
+
cell.number_value ??
|
|
161
|
+
cell.string_value ??
|
|
162
|
+
cell.boolean_value ??
|
|
163
|
+
cell.timestamp_value ??
|
|
164
|
+
null;
|
|
165
|
+
}
|
|
166
|
+
return row;
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("filter integration", () => {
|
|
172
|
+
beforeEach(async () => {
|
|
173
|
+
await writeFile("orders.malloy", MODEL_WITH_REQUIRED);
|
|
174
|
+
await writeFile("orders_optional.malloy", MODEL_OPTIONAL_ONLY);
|
|
175
|
+
await writeFile("test_notebook.malloynb", NOTEBOOK_MALLOYNB);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(async () => {
|
|
179
|
+
const files = await fs.readdir(TEST_PKG_DIR);
|
|
180
|
+
for (const f of files) {
|
|
181
|
+
if (f.endsWith(".malloy") || f.endsWith(".malloynb")) {
|
|
182
|
+
await fs.unlink(path.join(TEST_PKG_DIR, f));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
// Model loading & filter metadata
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
190
|
+
describe("model loading", () => {
|
|
191
|
+
it("parses filter annotations and exposes them via getSources()", async () => {
|
|
192
|
+
const model = await Model.create(
|
|
193
|
+
"test-pkg",
|
|
194
|
+
TEST_PKG_DIR,
|
|
195
|
+
"orders.malloy",
|
|
196
|
+
getConnections(),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const sources = model.getSources();
|
|
200
|
+
expect(sources).toBeDefined();
|
|
201
|
+
expect(sources!.length).toBeGreaterThanOrEqual(1);
|
|
202
|
+
|
|
203
|
+
const ordersSource = sources!.find((s) => s.name === "orders");
|
|
204
|
+
expect(ordersSource).toBeDefined();
|
|
205
|
+
expect(ordersSource!.filters).toBeDefined();
|
|
206
|
+
expect(ordersSource!.filters!.length).toBe(3);
|
|
207
|
+
|
|
208
|
+
const regionFilter = ordersSource!.filters!.find(
|
|
209
|
+
(f) => f.dimension === "region",
|
|
210
|
+
);
|
|
211
|
+
expect(regionFilter).toBeDefined();
|
|
212
|
+
expect(regionFilter!.type).toBe("in");
|
|
213
|
+
expect(regionFilter!.required).toBe(false);
|
|
214
|
+
expect(regionFilter!.implicit).toBe(false);
|
|
215
|
+
|
|
216
|
+
const statusFilter = ordersSource!.filters!.find(
|
|
217
|
+
(f) => f.dimension === "status",
|
|
218
|
+
);
|
|
219
|
+
expect(statusFilter).toBeDefined();
|
|
220
|
+
expect(statusFilter!.type).toBe("equal");
|
|
221
|
+
expect(statusFilter!.required).toBe(false);
|
|
222
|
+
|
|
223
|
+
const tenantFilter = ordersSource!.filters!.find(
|
|
224
|
+
(f) => f.dimension === "customer_id",
|
|
225
|
+
);
|
|
226
|
+
expect(tenantFilter).toBeDefined();
|
|
227
|
+
expect(tenantFilter!.name).toBe("tenant");
|
|
228
|
+
expect(tenantFilter!.type).toBe("equal");
|
|
229
|
+
expect(tenantFilter!.implicit).toBe(true);
|
|
230
|
+
expect(tenantFilter!.required).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("loads a model with optional-only filters", async () => {
|
|
234
|
+
const model = await Model.create(
|
|
235
|
+
"test-pkg",
|
|
236
|
+
TEST_PKG_DIR,
|
|
237
|
+
"orders_optional.malloy",
|
|
238
|
+
getConnections(),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const sources = model.getSources();
|
|
242
|
+
const ordersSource = sources!.find((s) => s.name === "orders");
|
|
243
|
+
expect(ordersSource!.filters!.length).toBe(2);
|
|
244
|
+
expect(ordersSource!.filters!.every((f) => f.required === false)).toBe(
|
|
245
|
+
true,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// -----------------------------------------------------------------------
|
|
251
|
+
// Query execution with optional filters
|
|
252
|
+
// -----------------------------------------------------------------------
|
|
253
|
+
describe("query execution with optional filters", () => {
|
|
254
|
+
it("runs unfiltered query (no filterParams provided)", async () => {
|
|
255
|
+
const model = await Model.create(
|
|
256
|
+
"test-pkg",
|
|
257
|
+
TEST_PKG_DIR,
|
|
258
|
+
"orders_optional.malloy",
|
|
259
|
+
getConnections(),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const { compactResult } = await model.getQueryResults(
|
|
263
|
+
"orders",
|
|
264
|
+
"summary",
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const r = asRows(compactResult);
|
|
268
|
+
expect(r.length).toBe(1);
|
|
269
|
+
expect(Number(r[0].order_count)).toBe(6);
|
|
270
|
+
expect(Number(r[0].total_amount)).toBe(875);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("applies region=in filter with single value", async () => {
|
|
274
|
+
const model = await Model.create(
|
|
275
|
+
"test-pkg",
|
|
276
|
+
TEST_PKG_DIR,
|
|
277
|
+
"orders_optional.malloy",
|
|
278
|
+
getConnections(),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const { compactResult } = await model.getQueryResults(
|
|
282
|
+
"orders",
|
|
283
|
+
"summary",
|
|
284
|
+
undefined,
|
|
285
|
+
{ region: ["US"] },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const r = asRows(compactResult);
|
|
289
|
+
expect(r.length).toBe(1);
|
|
290
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
291
|
+
expect(Number(r[0].total_amount)).toBe(300);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("applies region=in filter with multiple values", async () => {
|
|
295
|
+
const model = await Model.create(
|
|
296
|
+
"test-pkg",
|
|
297
|
+
TEST_PKG_DIR,
|
|
298
|
+
"orders_optional.malloy",
|
|
299
|
+
getConnections(),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const { compactResult } = await model.getQueryResults(
|
|
303
|
+
"orders",
|
|
304
|
+
"summary",
|
|
305
|
+
undefined,
|
|
306
|
+
{ region: ["US", "EU"] },
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const r = asRows(compactResult);
|
|
310
|
+
expect(r.length).toBe(1);
|
|
311
|
+
expect(Number(r[0].order_count)).toBe(4);
|
|
312
|
+
expect(Number(r[0].total_amount)).toBe(525);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("applies status=equal filter", async () => {
|
|
316
|
+
const model = await Model.create(
|
|
317
|
+
"test-pkg",
|
|
318
|
+
TEST_PKG_DIR,
|
|
319
|
+
"orders_optional.malloy",
|
|
320
|
+
getConnections(),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const { compactResult } = await model.getQueryResults(
|
|
324
|
+
"orders",
|
|
325
|
+
"summary",
|
|
326
|
+
undefined,
|
|
327
|
+
{ status: "active" },
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const r = asRows(compactResult);
|
|
331
|
+
expect(r.length).toBe(1);
|
|
332
|
+
expect(Number(r[0].order_count)).toBe(4);
|
|
333
|
+
expect(Number(r[0].total_amount)).toBe(750);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("applies combined region + status filters", async () => {
|
|
337
|
+
const model = await Model.create(
|
|
338
|
+
"test-pkg",
|
|
339
|
+
TEST_PKG_DIR,
|
|
340
|
+
"orders_optional.malloy",
|
|
341
|
+
getConnections(),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const { compactResult } = await model.getQueryResults(
|
|
345
|
+
"orders",
|
|
346
|
+
"summary",
|
|
347
|
+
undefined,
|
|
348
|
+
{ region: ["EU"], status: "cancelled" },
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const r = asRows(compactResult);
|
|
352
|
+
expect(r.length).toBe(1);
|
|
353
|
+
expect(Number(r[0].order_count)).toBe(1);
|
|
354
|
+
expect(Number(r[0].total_amount)).toBe(75);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("works with by_region view and filters", async () => {
|
|
358
|
+
const model = await Model.create(
|
|
359
|
+
"test-pkg",
|
|
360
|
+
TEST_PKG_DIR,
|
|
361
|
+
"orders_optional.malloy",
|
|
362
|
+
getConnections(),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const { compactResult } = await model.getQueryResults(
|
|
366
|
+
"orders",
|
|
367
|
+
"by_region",
|
|
368
|
+
undefined,
|
|
369
|
+
{ status: "cancelled" },
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const r = asRows(compactResult);
|
|
373
|
+
expect(r.length).toBe(2);
|
|
374
|
+
const regions = r.map((row) => row.region);
|
|
375
|
+
expect(regions).toContain("EU");
|
|
376
|
+
expect(regions).toContain("APAC");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("works with ad-hoc query string and filters", async () => {
|
|
380
|
+
const model = await Model.create(
|
|
381
|
+
"test-pkg",
|
|
382
|
+
TEST_PKG_DIR,
|
|
383
|
+
"orders_optional.malloy",
|
|
384
|
+
getConnections(),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const { compactResult } = await model.getQueryResults(
|
|
388
|
+
undefined,
|
|
389
|
+
undefined,
|
|
390
|
+
"run: orders -> { aggregate: order_count is count() }",
|
|
391
|
+
{ region: ["APAC"] },
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const r = asRows(compactResult);
|
|
395
|
+
expect(r.length).toBe(1);
|
|
396
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// -----------------------------------------------------------------------
|
|
401
|
+
// Required filter enforcement
|
|
402
|
+
// -----------------------------------------------------------------------
|
|
403
|
+
describe("required filter enforcement", () => {
|
|
404
|
+
it("throws when required filter is missing", async () => {
|
|
405
|
+
const model = await Model.create(
|
|
406
|
+
"test-pkg",
|
|
407
|
+
TEST_PKG_DIR,
|
|
408
|
+
"orders.malloy",
|
|
409
|
+
getConnections(),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
await expect(
|
|
413
|
+
model.getQueryResults("orders", "summary", undefined, {
|
|
414
|
+
region: ["US"],
|
|
415
|
+
}),
|
|
416
|
+
).rejects.toThrow(BadRequestError);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("throws descriptive error for missing required filter", async () => {
|
|
420
|
+
const model = await Model.create(
|
|
421
|
+
"test-pkg",
|
|
422
|
+
TEST_PKG_DIR,
|
|
423
|
+
"orders.malloy",
|
|
424
|
+
getConnections(),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
await model.getQueryResults("orders", "summary", undefined, {});
|
|
429
|
+
throw new Error("Should have thrown");
|
|
430
|
+
} catch (error) {
|
|
431
|
+
expect(error).toBeInstanceOf(BadRequestError);
|
|
432
|
+
expect((error as Error).message).toContain("tenant");
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("succeeds when required filter is provided", async () => {
|
|
437
|
+
const model = await Model.create(
|
|
438
|
+
"test-pkg",
|
|
439
|
+
TEST_PKG_DIR,
|
|
440
|
+
"orders.malloy",
|
|
441
|
+
getConnections(),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const { compactResult } = await model.getQueryResults(
|
|
445
|
+
"orders",
|
|
446
|
+
"summary",
|
|
447
|
+
undefined,
|
|
448
|
+
{ tenant: "cust_a", region: ["US"] },
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const r = asRows(compactResult);
|
|
452
|
+
expect(r.length).toBe(1);
|
|
453
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
454
|
+
expect(Number(r[0].total_amount)).toBe(300);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("applies required + optional filters together", async () => {
|
|
458
|
+
const model = await Model.create(
|
|
459
|
+
"test-pkg",
|
|
460
|
+
TEST_PKG_DIR,
|
|
461
|
+
"orders.malloy",
|
|
462
|
+
getConnections(),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const { compactResult } = await model.getQueryResults(
|
|
466
|
+
"orders",
|
|
467
|
+
"summary",
|
|
468
|
+
undefined,
|
|
469
|
+
{ tenant: "cust_b", status: "cancelled" },
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const r = asRows(compactResult);
|
|
473
|
+
expect(r.length).toBe(1);
|
|
474
|
+
expect(Number(r[0].order_count)).toBe(1);
|
|
475
|
+
expect(Number(r[0].total_amount)).toBe(75);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// -----------------------------------------------------------------------
|
|
480
|
+
// bypassFilters
|
|
481
|
+
// -----------------------------------------------------------------------
|
|
482
|
+
describe("bypassFilters", () => {
|
|
483
|
+
it("skips required filter validation when bypassFilters=true", async () => {
|
|
484
|
+
const model = await Model.create(
|
|
485
|
+
"test-pkg",
|
|
486
|
+
TEST_PKG_DIR,
|
|
487
|
+
"orders.malloy",
|
|
488
|
+
getConnections(),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
const { compactResult } = await model.getQueryResults(
|
|
492
|
+
"orders",
|
|
493
|
+
"summary",
|
|
494
|
+
undefined,
|
|
495
|
+
{},
|
|
496
|
+
true,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const r = asRows(compactResult);
|
|
500
|
+
expect(r.length).toBe(1);
|
|
501
|
+
expect(Number(r[0].order_count)).toBe(6);
|
|
502
|
+
expect(Number(r[0].total_amount)).toBe(875);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("ignores provided filters when bypassFilters=true", async () => {
|
|
506
|
+
const model = await Model.create(
|
|
507
|
+
"test-pkg",
|
|
508
|
+
TEST_PKG_DIR,
|
|
509
|
+
"orders_optional.malloy",
|
|
510
|
+
getConnections(),
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const { compactResult } = await model.getQueryResults(
|
|
514
|
+
"orders",
|
|
515
|
+
"summary",
|
|
516
|
+
undefined,
|
|
517
|
+
{ region: ["US"] },
|
|
518
|
+
true,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const r = asRows(compactResult);
|
|
522
|
+
expect(Number(r[0].order_count)).toBe(6);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// -----------------------------------------------------------------------
|
|
527
|
+
// Notebook cell execution with filters
|
|
528
|
+
// -----------------------------------------------------------------------
|
|
529
|
+
describe("notebook cell execution", () => {
|
|
530
|
+
it("executes notebook code cell without filters", async () => {
|
|
531
|
+
const model = await Model.create(
|
|
532
|
+
"test-pkg",
|
|
533
|
+
TEST_PKG_DIR,
|
|
534
|
+
"test_notebook.malloynb",
|
|
535
|
+
getConnections(),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Cell 0 = markdown ("# Test Notebook")
|
|
539
|
+
// Cell 1 = code (model definition — no query, just source)
|
|
540
|
+
// Cell 2 = code (run: orders -> summary)
|
|
541
|
+
const codeCell = await model.executeNotebookCell(2);
|
|
542
|
+
expect(codeCell.type).toBe("code");
|
|
543
|
+
expect(codeCell.result).toBeDefined();
|
|
544
|
+
|
|
545
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
546
|
+
expect(notebookRows.length).toBe(1);
|
|
547
|
+
expect(Number(notebookRows[0].order_count)).toBe(6);
|
|
548
|
+
expect(Number(notebookRows[0].total_amount)).toBe(875);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("applies filterParams to notebook cell execution", async () => {
|
|
552
|
+
const model = await Model.create(
|
|
553
|
+
"test-pkg",
|
|
554
|
+
TEST_PKG_DIR,
|
|
555
|
+
"test_notebook.malloynb",
|
|
556
|
+
getConnections(),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const codeCell = await model.executeNotebookCell(2, {
|
|
560
|
+
region: ["US"],
|
|
561
|
+
});
|
|
562
|
+
expect(codeCell.result).toBeDefined();
|
|
563
|
+
|
|
564
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
565
|
+
expect(notebookRows.length).toBe(1);
|
|
566
|
+
expect(Number(notebookRows[0].order_count)).toBe(2);
|
|
567
|
+
expect(Number(notebookRows[0].total_amount)).toBe(300);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("applies status filter to notebook cell execution", async () => {
|
|
571
|
+
const model = await Model.create(
|
|
572
|
+
"test-pkg",
|
|
573
|
+
TEST_PKG_DIR,
|
|
574
|
+
"test_notebook.malloynb",
|
|
575
|
+
getConnections(),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
const codeCell = await model.executeNotebookCell(2, {
|
|
579
|
+
status: "cancelled",
|
|
580
|
+
});
|
|
581
|
+
expect(codeCell.result).toBeDefined();
|
|
582
|
+
|
|
583
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
584
|
+
expect(notebookRows.length).toBe(1);
|
|
585
|
+
expect(Number(notebookRows[0].order_count)).toBe(2);
|
|
586
|
+
expect(Number(notebookRows[0].total_amount)).toBe(125);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("bypassFilters skips filter injection on notebook cells", async () => {
|
|
590
|
+
const model = await Model.create(
|
|
591
|
+
"test-pkg",
|
|
592
|
+
TEST_PKG_DIR,
|
|
593
|
+
"test_notebook.malloynb",
|
|
594
|
+
getConnections(),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const codeCell = await model.executeNotebookCell(
|
|
598
|
+
2,
|
|
599
|
+
{ region: ["US"] },
|
|
600
|
+
true,
|
|
601
|
+
);
|
|
602
|
+
expect(codeCell.result).toBeDefined();
|
|
603
|
+
|
|
604
|
+
const notebookRows = parseNotebookResult(codeCell.result!);
|
|
605
|
+
expect(notebookRows.length).toBe(1);
|
|
606
|
+
expect(Number(notebookRows[0].order_count)).toBe(6);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("returns markdown cells unchanged", async () => {
|
|
610
|
+
const model = await Model.create(
|
|
611
|
+
"test-pkg",
|
|
612
|
+
TEST_PKG_DIR,
|
|
613
|
+
"test_notebook.malloynb",
|
|
614
|
+
getConnections(),
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
const markdownCell = await model.executeNotebookCell(0);
|
|
618
|
+
expect(markdownCell.type).toBe("markdown");
|
|
619
|
+
expect(markdownCell.text).toContain("Test Notebook");
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
});
|