@malloy-publisher/server 0.0.178 → 0.0.180-dev-v1

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.
Files changed (74) hide show
  1. package/build.ts +1 -1
  2. package/dist/app/api-doc.yaml +505 -52
  3. package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
  4. package/dist/app/assets/{MainPage-JYvDXOkC.js → MainPage-BzB3yoqi.js} +2 -2
  5. package/dist/app/assets/{ModelPage-TEQrhaqq.js → ModelPage-C9O_sAXT.js} +1 -1
  6. package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
  7. package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
  8. package/dist/app/assets/{RouteError-DnSZEzkT.js → RouteError-DAShbVCG.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DjQ8u5DD.js → WorkbookPage-Cs_XYEaB.js} +1 -1
  10. package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
  11. package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
  12. package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
  13. package/dist/app/assets/index-15BOvhp0.js +456 -0
  14. package/dist/app/assets/{index--80Q7qw1.js → index-Bb2jqquW.js} +1 -1
  15. package/dist/app/assets/{index-CZ4G_NMp.js → index-D68X76-7.js} +168 -166
  16. package/dist/app/assets/index.umd-DGBekgSu.js +1145 -0
  17. package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
  18. package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
  19. package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
  20. package/dist/app/index.html +1 -1
  21. package/dist/instrumentation.js +10567 -10584
  22. package/dist/server.js +16973 -15367
  23. package/package.json +14 -12
  24. package/src/controller/connection.controller.ts +27 -20
  25. package/src/controller/manifest.controller.ts +29 -0
  26. package/src/controller/materialization.controller.ts +125 -0
  27. package/src/controller/model.controller.ts +4 -3
  28. package/src/controller/package.controller.ts +53 -2
  29. package/src/controller/query.controller.ts +5 -0
  30. package/src/errors.ts +24 -0
  31. package/src/mcp/resources/model_resource.ts +12 -9
  32. package/src/mcp/resources/source_resource.ts +7 -6
  33. package/src/mcp/resources/view_resource.ts +0 -1
  34. package/src/mcp/tools/execute_query_tool.ts +9 -0
  35. package/src/server.ts +217 -5
  36. package/src/service/connection.ts +1 -4
  37. package/src/service/db_utils.spec.ts +4 -2
  38. package/src/service/db_utils.ts +6 -2
  39. package/src/service/filter.spec.ts +447 -0
  40. package/src/service/filter.ts +337 -0
  41. package/src/service/filter_integration.spec.ts +825 -0
  42. package/src/service/manifest_service.spec.ts +201 -0
  43. package/src/service/manifest_service.ts +106 -0
  44. package/src/service/materialization_service.spec.ts +648 -0
  45. package/src/service/materialization_service.ts +929 -0
  46. package/src/service/materialized_table_gc.spec.ts +383 -0
  47. package/src/service/materialized_table_gc.ts +279 -0
  48. package/src/service/model.ts +221 -47
  49. package/src/service/package.ts +50 -0
  50. package/src/service/project_store.ts +21 -2
  51. package/src/service/quoting.ts +41 -0
  52. package/src/service/resolve_project.ts +13 -0
  53. package/src/storage/DatabaseInterface.ts +103 -1
  54. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  55. package/src/storage/StorageManager.ts +119 -1
  56. package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
  57. package/src/storage/duckdb/DuckDBRepository.ts +99 -9
  58. package/src/storage/duckdb/ManifestRepository.ts +119 -0
  59. package/src/storage/duckdb/MaterializationRepository.ts +249 -0
  60. package/src/storage/duckdb/manifest_store.spec.ts +133 -0
  61. package/src/storage/duckdb/schema.ts +59 -1
  62. package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
  63. package/tests/fixtures/persist-test/data/orders.csv +5 -0
  64. package/tests/fixtures/persist-test/persist_test.malloy +11 -0
  65. package/tests/fixtures/persist-test/publisher.json +5 -0
  66. package/tests/fixtures/publisher.config.json +15 -0
  67. package/tests/harness/rest_e2e.ts +68 -0
  68. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
  69. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
  70. package/dist/app/assets/HomePage-CwUkFsA8.js +0 -1
  71. package/dist/app/assets/PackagePage-CgE-izLw.js +0 -1
  72. package/dist/app/assets/ProjectPage-PiMPpFX8.js +0 -1
  73. package/dist/app/assets/index-BJUsHnGO.js +0 -467
  74. package/dist/app/assets/index.umd-Cf-wqh-R.js +0 -1145
@@ -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
+ });