@malloy-publisher/server 0.0.180 → 0.0.182

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 (27) hide show
  1. package/dist/app/api-doc.yaml +91 -1
  2. package/dist/app/assets/{HomePage-DRmAsRAP.js → HomePage-or6BbD5P.js} +1 -1
  3. package/dist/app/assets/{MainPage-BLhfzy47.js → MainPage-DINuSDg0.js} +2 -2
  4. package/dist/app/assets/{ModelPage-bgdjxhyc.js → ModelPage-BMcaV1YQ.js} +1 -1
  5. package/dist/app/assets/{PackagePage-rPw0OAJY.js → PackagePage-DXxlQcCj.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-D0DYloUr.js → ProjectPage-vfZc_Kvu.js} +1 -1
  7. package/dist/app/assets/{RouteError-CsFH2AdT.js → RouteError-r14osUo0.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-CQ37Bfli.js → WorkbookPage-HI39NTWs.js} +1 -1
  9. package/dist/app/assets/{index-C2IkGoJ8.js → index-Bw1lh09G.js} +78 -78
  10. package/dist/app/assets/{index-Cev5PtEG.js → index-Dd6uCk_C.js} +1 -1
  11. package/dist/app/assets/{index-DcnbmCmI.js → index-JqHhhRqY.js} +168 -166
  12. package/dist/app/assets/index.umd-lwkX_kFe.js +1145 -0
  13. package/dist/app/index.html +1 -1
  14. package/dist/server.js +323 -31
  15. package/package.json +10 -10
  16. package/src/controller/model.controller.ts +4 -1
  17. package/src/controller/query.controller.ts +5 -0
  18. package/src/mcp/resources/model_resource.ts +12 -9
  19. package/src/mcp/resources/source_resource.ts +7 -6
  20. package/src/mcp/resources/view_resource.ts +0 -1
  21. package/src/mcp/tools/execute_query_tool.ts +9 -0
  22. package/src/server.ts +21 -0
  23. package/src/service/filter.spec.ts +392 -0
  24. package/src/service/filter.ts +332 -0
  25. package/src/service/filter_integration.spec.ts +622 -0
  26. package/src/service/model.ts +180 -43
  27. package/dist/app/assets/index.umd-BwIMLH79.js +0 -1145
@@ -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
+ });