@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
@@ -42,12 +42,20 @@ import {
42
42
  } from "../errors";
43
43
  import { logger } from "../logger";
44
44
  import { URL_READER } from "../utils";
45
+ import {
46
+ buildFilterClause,
47
+ injectFilterRefinement,
48
+ parseFilters,
49
+ FilterValidationError,
50
+ type FilterDefinition,
51
+ type FilterParams,
52
+ } from "./filter";
45
53
 
46
54
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
47
55
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
48
56
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
49
- // @ts-expect-error TODO: Fix missing Source type in API
50
57
  type ApiSource = components["schemas"]["Source"];
58
+ type ApiFilter = components["schemas"]["Filter"];
51
59
  type ApiView = components["schemas"]["View"];
52
60
  type ApiQuery = components["schemas"]["Query"];
53
61
  export type ApiConnection = components["schemas"]["Connection"];
@@ -64,6 +72,8 @@ interface RunnableNotebookCell {
64
72
  type: "code" | "markdown";
65
73
  text: string;
66
74
  runnable?: QueryMaterializer;
75
+ /** Retained so we can rebuild the query with filter refinements at execution time. */
76
+ modelMaterializer?: ModelMaterializer;
67
77
  newSources?: Malloy.SourceInfo[];
68
78
  queryInfo?: Malloy.QueryInfo;
69
79
  }
@@ -81,6 +91,8 @@ export class Model {
81
91
  private sourceInfos: Malloy.SourceInfo[] | undefined;
82
92
  private runnableNotebookCells: RunnableNotebookCell[] | undefined;
83
93
  private compilationError: MalloyError | Error | undefined;
94
+ /** Parsed #(filter) definitions keyed by source name. */
95
+ private filterMap: Map<string, FilterDefinition[]>;
84
96
  private meter = metrics.getMeter("publisher");
85
97
  private queryExecutionHistogram = this.meter.createHistogram(
86
98
  "malloy_model_query_duration",
@@ -103,6 +115,7 @@ export class Model {
103
115
  sourceInfos: Malloy.SourceInfo[] | undefined,
104
116
  runnableNotebookCells: RunnableNotebookCell[] | undefined,
105
117
  compilationError: MalloyError | Error | undefined,
118
+ filterMap?: Map<string, FilterDefinition[]>,
106
119
  ) {
107
120
  this.packageName = packageName;
108
121
  this.modelPath = modelPath;
@@ -115,11 +128,31 @@ export class Model {
115
128
  this.sourceInfos = sourceInfos;
116
129
  this.runnableNotebookCells = runnableNotebookCells;
117
130
  this.compilationError = compilationError;
131
+ this.filterMap = filterMap ?? new Map();
118
132
  this.modelInfo = this.modelDef
119
133
  ? modelDefToModelInfo(this.modelDef)
120
134
  : undefined;
121
135
  }
122
136
 
137
+ /**
138
+ * Get the parsed filter definitions for a given source name.
139
+ * Returns an empty array if no filters are declared.
140
+ */
141
+ public getFilters(sourceName: string): FilterDefinition[] {
142
+ return this.filterMap.get(sourceName) ?? [];
143
+ }
144
+
145
+ /**
146
+ * Best-effort extraction of a source name from an ad-hoc Malloy query string.
147
+ * Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
148
+ */
149
+ private extractSourceName(query?: string): string | undefined {
150
+ if (!query) return undefined;
151
+ const runMatch = query.match(/run\s*:\s*(\w+)\s*->/);
152
+ const arrowMatch = query.match(/^\s*(\w+)\s*->/m);
153
+ return runMatch?.[1] ?? arrowMatch?.[1];
154
+ }
155
+
123
156
  public static async create(
124
157
  packageName: string,
125
158
  packagePath: string,
@@ -143,10 +176,13 @@ export class Model {
143
176
  let modelDef = undefined;
144
177
  let sources = undefined;
145
178
  let queries = undefined;
179
+ let filterMap: Map<string, FilterDefinition[]> | undefined;
146
180
  const sourceInfos: Malloy.SourceInfo[] = [];
147
181
  if (modelMaterializer) {
148
182
  modelDef = (await modelMaterializer.getModel())._modelDef;
149
- sources = Model.getSources(modelPath, modelDef);
183
+ const sourceResult = Model.getSources(modelPath, modelDef);
184
+ sources = sourceResult.sources;
185
+ filterMap = sourceResult.filterMap;
150
186
  queries = Model.getQueries(modelPath, modelDef);
151
187
 
152
188
  // Collect sourceInfos from imported models first
@@ -207,6 +243,7 @@ export class Model {
207
243
  sourceInfos.length > 0 ? sourceInfos : undefined,
208
244
  runnableNotebookCells,
209
245
  undefined,
246
+ filterMap,
210
247
  );
211
248
  } catch (error) {
212
249
  let computedError = error;
@@ -292,6 +329,8 @@ export class Model {
292
329
  sourceName?: string,
293
330
  queryName?: string,
294
331
  query?: string,
332
+ filterParams?: FilterParams,
333
+ bypassFilters?: boolean,
295
334
  ): Promise<{
296
335
  result: Malloy.Result;
297
336
  compactResult: QueryData;
@@ -318,12 +357,11 @@ export class Model {
318
357
 
319
358
  // Wrap loadQuery calls in try-catch to handle query parsing errors
320
359
  try {
360
+ let queryString: string;
321
361
  if (!sourceName && !queryName && query) {
322
- runnable = this.modelMaterializer.loadQuery("\n" + query);
362
+ queryString = "\n" + query;
323
363
  } else if (queryName && !query) {
324
- runnable = this.modelMaterializer.loadQuery(
325
- `\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`,
326
- );
364
+ queryString = `\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`;
327
365
  } else {
328
366
  const endTime = performance.now();
329
367
  const executionTime = endTime - startTime;
@@ -338,11 +376,35 @@ export class Model {
338
376
  "Invalid query request. (Query AND !sourceName) OR (queryName AND sourceName) must be defined.",
339
377
  );
340
378
  }
379
+
380
+ // Inject source filter predicates unless bypassed
381
+ if (!bypassFilters) {
382
+ const effectiveSource = sourceName ?? this.extractSourceName(query);
383
+ if (effectiveSource) {
384
+ const filters = this.getFilters(effectiveSource);
385
+ if (filters.length > 0) {
386
+ const filterClause = buildFilterClause(
387
+ filters,
388
+ filterParams ?? {},
389
+ );
390
+ queryString = injectFilterRefinement(
391
+ queryString,
392
+ filterClause,
393
+ );
394
+ }
395
+ }
396
+ }
397
+
398
+ runnable = this.modelMaterializer.loadQuery(queryString);
341
399
  } catch (error) {
342
400
  // Re-throw BadRequestError as-is
343
401
  if (error instanceof BadRequestError) {
344
402
  throw error;
345
403
  }
404
+ // Source filter validation errors are client errors (400)
405
+ if (error instanceof FilterValidationError) {
406
+ throw new BadRequestError(error.message);
407
+ }
346
408
  // Re-throw MalloyError as-is (maps to 400)
347
409
  if (error instanceof MalloyError) {
348
410
  throw error;
@@ -491,7 +553,11 @@ export class Model {
491
553
  } as ApiRawNotebook;
492
554
  }
493
555
 
494
- public async executeNotebookCell(cellIndex: number): Promise<{
556
+ public async executeNotebookCell(
557
+ cellIndex: number,
558
+ filterParams?: FilterParams,
559
+ bypassFilters?: boolean,
560
+ ): Promise<{
495
561
  type: "code" | "markdown";
496
562
  text: string;
497
563
  queryName?: string;
@@ -527,18 +593,44 @@ export class Model {
527
593
 
528
594
  if (cell.runnable) {
529
595
  try {
596
+ let runnableToExecute = cell.runnable;
597
+
598
+ // If filters need to be applied, rebuild the query with a refinement
599
+ if (!bypassFilters && cell.modelMaterializer) {
600
+ const effectiveSource = this.extractSourceName(cell.text);
601
+ if (effectiveSource) {
602
+ const filters = this.getFilters(effectiveSource);
603
+ if (filters.length > 0) {
604
+ const filterClause = buildFilterClause(
605
+ filters,
606
+ filterParams ?? {},
607
+ );
608
+ if (filterClause) {
609
+ const refinedQuery = injectFilterRefinement(
610
+ cell.text,
611
+ filterClause,
612
+ );
613
+ runnableToExecute =
614
+ cell.modelMaterializer.loadQuery(refinedQuery);
615
+ }
616
+ }
617
+ }
618
+ }
619
+
530
620
  const rowLimit =
531
- (await cell.runnable.getPreparedResult()).resultExplore.limit ||
532
- ROW_LIMIT;
533
- const result = await cell.runnable.run({ rowLimit });
534
- const query = (await cell.runnable.getPreparedQuery())._query;
621
+ (await runnableToExecute.getPreparedResult()).resultExplore
622
+ .limit || ROW_LIMIT;
623
+ const result = await runnableToExecute.run({ rowLimit });
624
+ const query = (await runnableToExecute.getPreparedQuery())._query;
535
625
  queryName = (query as NamedQueryDef).as || query.name;
536
626
  queryResult =
537
627
  result?._queryResult &&
538
628
  this.modelInfo &&
539
629
  JSON.stringify(API.util.wrapResult(result));
540
630
  } catch (error) {
541
- // Re-throw execution errors so the client knows about them
631
+ if (error instanceof FilterValidationError) {
632
+ throw new BadRequestError(error.message);
633
+ }
542
634
  if (error instanceof MalloyError) {
543
635
  throw error;
544
636
  }
@@ -644,38 +736,82 @@ export class Model {
644
736
  private static getSources(
645
737
  modelPath: string,
646
738
  modelDef: ModelDef,
647
- ): ApiSource[] {
648
- return Object.values(modelDef.contents)
739
+ ): {
740
+ sources: ApiSource[];
741
+ filterMap: Map<string, FilterDefinition[]>;
742
+ } {
743
+ const filterMap = new Map<string, FilterDefinition[]>();
744
+
745
+ const sources = Object.values(modelDef.contents)
649
746
  .filter((obj) => isSourceDef(obj))
650
- .map(
651
- (sourceObj) =>
652
- ({
653
- name: sourceObj.as || sourceObj.name,
654
- annotations: (sourceObj as StructDef).annotation?.blockNotes
655
- ?.filter((note) => note.at.url.includes(modelPath))
656
- .map((note) => note.text),
657
- views: (sourceObj as StructDef).fields
658
- .filter((turtleObj) => turtleObj.type === "turtle")
659
- .filter((turtleObj) =>
660
- // TODO(kjnesbit): Fix non-reduce views. Filter out
661
- // non-reduce views, i.e., indexes. Need to discuss with Will.
662
- (turtleObj as TurtleDef).pipeline
663
- .map((stage) => stage.type)
664
- .every((type) => type == "reduce"),
665
- )
666
- .map(
667
- (turtleObj) =>
668
- ({
669
- name: turtleObj.as || turtleObj.name,
670
- annotations: turtleObj?.annotation?.blockNotes
671
- ?.filter((note) =>
672
- note.at.url.includes(modelPath),
673
- )
674
- .map((note) => note.text),
675
- }) as ApiView,
676
- ),
677
- }) as ApiSource,
678
- );
747
+ .map((sourceObj) => {
748
+ const sourceName = sourceObj.as || sourceObj.name;
749
+ const annotations = (sourceObj as StructDef).annotation?.blockNotes
750
+ ?.filter((note) => note.at.url.includes(modelPath))
751
+ .map((note) => note.text);
752
+
753
+ // Parse #(filter) from ALL annotations (including imports)
754
+ // so filters defined on an imported source are honored by notebooks
755
+ const allAnnotations = (
756
+ sourceObj as StructDef
757
+ ).annotation?.blockNotes?.map((note) => note.text);
758
+ let filters: ApiFilter[] | undefined;
759
+ if (allAnnotations && allAnnotations.length > 0) {
760
+ try {
761
+ const parsed = parseFilters(allAnnotations);
762
+ if (parsed.length > 0) {
763
+ filterMap.set(sourceName, parsed);
764
+ const structFields = (sourceObj as StructDef).fields;
765
+ filters = parsed.map((f) => {
766
+ const field = structFields.find(
767
+ (fd) => (fd.as || fd.name) === f.dimension,
768
+ );
769
+ return {
770
+ name: f.name,
771
+ dimension: f.dimension,
772
+ type: f.type,
773
+ implicit: f.implicit,
774
+ required: f.required,
775
+ dimensionType: field?.type as string | undefined,
776
+ };
777
+ });
778
+ }
779
+ } catch (err) {
780
+ logger.warn(
781
+ `Failed to parse filter annotations on source "${sourceName}"`,
782
+ { error: err },
783
+ );
784
+ }
785
+ }
786
+
787
+ const views = (sourceObj as StructDef).fields
788
+ .filter((turtleObj) => turtleObj.type === "turtle")
789
+ .filter((turtleObj) =>
790
+ // TODO(kjnesbit): Fix non-reduce views. Filter out
791
+ // non-reduce views, i.e., indexes. Need to discuss with Will.
792
+ (turtleObj as TurtleDef).pipeline
793
+ .map((stage) => stage.type)
794
+ .every((type) => type == "reduce"),
795
+ )
796
+ .map(
797
+ (turtleObj) =>
798
+ ({
799
+ name: turtleObj.as || turtleObj.name,
800
+ annotations: turtleObj?.annotation?.blockNotes
801
+ ?.filter((note) => note.at.url.includes(modelPath))
802
+ .map((note) => note.text),
803
+ }) as ApiView,
804
+ );
805
+
806
+ return {
807
+ name: sourceName,
808
+ annotations,
809
+ views,
810
+ filters,
811
+ } as ApiSource;
812
+ });
813
+
814
+ return { sources, filterMap };
679
815
  }
680
816
 
681
817
  static async getModelMaterializer(
@@ -857,6 +993,7 @@ export class Model {
857
993
  type: "code",
858
994
  text: stmt.text,
859
995
  runnable: runnable,
996
+ modelMaterializer: localMM,
860
997
  newSources,
861
998
  queryInfo,
862
999
  } as RunnableNotebookCell;