@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
package/src/service/model.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
362
|
+
queryString = "\n" + query;
|
|
323
363
|
} else if (queryName && !query) {
|
|
324
|
-
|
|
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(
|
|
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
|
|
532
|
-
|
|
533
|
-
const result = await
|
|
534
|
-
const query = (await
|
|
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
|
-
|
|
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
|
-
):
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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;
|