@malloy-publisher/server 0.0.185 → 0.0.186
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/server.js +13 -5
- package/package.json +1 -1
- package/src/service/filter.spec.ts +55 -0
- package/src/service/filter.ts +8 -3
- package/src/service/filter_integration.spec.ts +203 -0
- package/src/service/model.ts +22 -6
package/dist/server.js
CHANGED
|
@@ -229669,14 +229669,14 @@ function parseFilterAnnotation(annotation) {
|
|
|
229669
229669
|
};
|
|
229670
229670
|
}
|
|
229671
229671
|
function parseFilters(annotations) {
|
|
229672
|
-
const
|
|
229672
|
+
const byName = new Map;
|
|
229673
229673
|
for (const annotation of annotations) {
|
|
229674
229674
|
const parsed = parseFilterAnnotation(annotation);
|
|
229675
229675
|
if (parsed) {
|
|
229676
|
-
|
|
229676
|
+
byName.set(parsed.name, parsed);
|
|
229677
229677
|
}
|
|
229678
229678
|
}
|
|
229679
|
-
return
|
|
229679
|
+
return [...byName.values()];
|
|
229680
229680
|
}
|
|
229681
229681
|
function escapeMalloyString(value) {
|
|
229682
229682
|
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
@@ -230207,9 +230207,17 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
230207
230207
|
const sources = Object.values(modelDef.contents).filter((obj) => import_malloy2.isSourceDef(obj)).map((sourceObj) => {
|
|
230208
230208
|
const sourceName = sourceObj.as || sourceObj.name;
|
|
230209
230209
|
const annotations = sourceObj.annotation?.blockNotes?.filter((note) => note.at.url.includes(modelPath)).map((note) => note.text);
|
|
230210
|
-
const
|
|
230210
|
+
const collectedAnnotations = [];
|
|
230211
|
+
let curAnnotation = sourceObj.annotation;
|
|
230212
|
+
while (curAnnotation) {
|
|
230213
|
+
if (curAnnotation.blockNotes) {
|
|
230214
|
+
collectedAnnotations.push(curAnnotation.blockNotes.map((note) => note.text));
|
|
230215
|
+
}
|
|
230216
|
+
curAnnotation = curAnnotation.inherits;
|
|
230217
|
+
}
|
|
230218
|
+
const allAnnotations = collectedAnnotations.reverse().flat();
|
|
230211
230219
|
let filters;
|
|
230212
|
-
if (allAnnotations
|
|
230220
|
+
if (allAnnotations.length > 0) {
|
|
230213
230221
|
try {
|
|
230214
230222
|
const parsed = parseFilters(allAnnotations);
|
|
230215
230223
|
if (parsed.length > 0) {
|
package/package.json
CHANGED
|
@@ -170,6 +170,61 @@ describe("service/filter", () => {
|
|
|
170
170
|
const filters = parseFilters(["#(doc) some docs", "# hidden"]);
|
|
171
171
|
expect(filters).toHaveLength(0);
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
it("deduplicates by name, later annotations win (extend pattern)", () => {
|
|
175
|
+
const annotations = [
|
|
176
|
+
// Base source annotations (come first in blockNotes via inherits chain)
|
|
177
|
+
"#(filter) name=Manufacturer dimension=Manufacturer type=in",
|
|
178
|
+
"#(filter) name=Subject dimension=Subject type=like",
|
|
179
|
+
'#(filter) name="Major Recall" dimension="Major Recall" type=equal',
|
|
180
|
+
// Extending source annotations (come later, should win)
|
|
181
|
+
"#(filter) name=Manufacturer dimension=Manufacturer type=equal required",
|
|
182
|
+
"#(filter) name=Subject dimension=Subject type=like",
|
|
183
|
+
];
|
|
184
|
+
const filters = parseFilters(annotations);
|
|
185
|
+
// 3 unique names: Manufacturer, Subject, Major Recall
|
|
186
|
+
expect(filters).toHaveLength(3);
|
|
187
|
+
|
|
188
|
+
// Manufacturer: child overrides base (in → equal, gains required)
|
|
189
|
+
const mfr = filters.find((f) => f.name === "Manufacturer");
|
|
190
|
+
expect(mfr).toBeDefined();
|
|
191
|
+
expect(mfr!.type).toBe("equal");
|
|
192
|
+
expect(mfr!.required).toBe(true);
|
|
193
|
+
|
|
194
|
+
// Subject: child re-declares identically, no visible change
|
|
195
|
+
const subj = filters.find((f) => f.name === "Subject");
|
|
196
|
+
expect(subj).toBeDefined();
|
|
197
|
+
expect(subj!.type).toBe("like");
|
|
198
|
+
expect(subj!.required).toBeFalsy();
|
|
199
|
+
|
|
200
|
+
// Major Recall: only on base, preserved in child
|
|
201
|
+
const major = filters.find((f) => f.name === "Major Recall");
|
|
202
|
+
expect(major).toBeDefined();
|
|
203
|
+
expect(major!.type).toBe("equal");
|
|
204
|
+
expect(major!.dimension).toBe("Major Recall");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("child can remove required flag by overriding", () => {
|
|
208
|
+
const annotations = [
|
|
209
|
+
"#(filter) name=status dimension=status type=equal required",
|
|
210
|
+
"#(filter) name=status dimension=status type=equal",
|
|
211
|
+
];
|
|
212
|
+
const filters = parseFilters(annotations);
|
|
213
|
+
expect(filters).toHaveLength(1);
|
|
214
|
+
expect(filters[0].name).toBe("status");
|
|
215
|
+
expect(filters[0].required).toBeFalsy();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("child can change filter type by overriding", () => {
|
|
219
|
+
const annotations = [
|
|
220
|
+
"#(filter) name=category dimension=category type=in",
|
|
221
|
+
"#(filter) name=category dimension=category type=equal required",
|
|
222
|
+
];
|
|
223
|
+
const filters = parseFilters(annotations);
|
|
224
|
+
expect(filters).toHaveLength(1);
|
|
225
|
+
expect(filters[0].type).toBe("equal");
|
|
226
|
+
expect(filters[0].required).toBe(true);
|
|
227
|
+
});
|
|
173
228
|
});
|
|
174
229
|
|
|
175
230
|
// -----------------------------------------------------------------------
|
package/src/service/filter.ts
CHANGED
|
@@ -127,14 +127,19 @@ export function parseFilterAnnotation(
|
|
|
127
127
|
* (as found on a Malloy source's `blockNotes`).
|
|
128
128
|
*/
|
|
129
129
|
export function parseFilters(annotations: string[]): FilterDefinition[] {
|
|
130
|
-
|
|
130
|
+
// Use a Map keyed by filter name so that later annotations (from an
|
|
131
|
+
// extending source) override earlier ones (from the base source).
|
|
132
|
+
// This is important when `source: child is parent extend {}` inherits
|
|
133
|
+
// blockNotes from the parent — the child's annotations come last and
|
|
134
|
+
// should win.
|
|
135
|
+
const byName = new Map<string, FilterDefinition>();
|
|
131
136
|
for (const annotation of annotations) {
|
|
132
137
|
const parsed = parseFilterAnnotation(annotation);
|
|
133
138
|
if (parsed) {
|
|
134
|
-
|
|
139
|
+
byName.set(parsed.name, parsed);
|
|
135
140
|
}
|
|
136
141
|
}
|
|
137
|
-
return
|
|
142
|
+
return [...byName.values()];
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
// ---------------------------------------------------------------------------
|
|
@@ -94,6 +94,45 @@ import "orders_optional.malloy"
|
|
|
94
94
|
run: orders -> summary
|
|
95
95
|
`;
|
|
96
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
|
+
|
|
97
136
|
beforeAll(async () => {
|
|
98
137
|
await fs.mkdir(TEST_DB_DIR, { recursive: true });
|
|
99
138
|
await fs.mkdir(TEST_PKG_DIR, { recursive: true });
|
|
@@ -619,4 +658,168 @@ describe("filter integration", () => {
|
|
|
619
658
|
expect(markdownCell.text).toContain("Test Notebook");
|
|
620
659
|
});
|
|
621
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
|
+
});
|
|
622
825
|
});
|
package/src/service/model.ts
CHANGED
|
@@ -750,13 +750,29 @@ export class Model {
|
|
|
750
750
|
?.filter((note) => note.at.url.includes(modelPath))
|
|
751
751
|
.map((note) => note.text);
|
|
752
752
|
|
|
753
|
-
// Parse #(filter) from ALL annotations
|
|
754
|
-
// so filters
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
753
|
+
// Parse #(filter) from ALL annotations, traversing the inherits
|
|
754
|
+
// chain so that filters on a base source (e.g. `recalls`) are
|
|
755
|
+
// picked up by an extending source (`manufacturer_recalls is
|
|
756
|
+
// recalls extend {}`). The Malloy compiler stores the base
|
|
757
|
+
// source's annotations in `annotation.inherits`.
|
|
758
|
+
//
|
|
759
|
+
// The chain goes child → parent, so we collect child-first.
|
|
760
|
+
// parseFilters uses "last wins" dedup, so we reverse to put
|
|
761
|
+
// parent annotations first and child annotations last (winning).
|
|
762
|
+
const collectedAnnotations: string[][] = [];
|
|
763
|
+
let curAnnotation: Annotation | undefined = (sourceObj as StructDef)
|
|
764
|
+
.annotation;
|
|
765
|
+
while (curAnnotation) {
|
|
766
|
+
if (curAnnotation.blockNotes) {
|
|
767
|
+
collectedAnnotations.push(
|
|
768
|
+
curAnnotation.blockNotes.map((note) => note.text),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
curAnnotation = curAnnotation.inherits;
|
|
772
|
+
}
|
|
773
|
+
const allAnnotations = collectedAnnotations.reverse().flat();
|
|
758
774
|
let filters: ApiFilter[] | undefined;
|
|
759
|
-
if (allAnnotations
|
|
775
|
+
if (allAnnotations.length > 0) {
|
|
760
776
|
try {
|
|
761
777
|
const parsed = parseFilters(allAnnotations);
|
|
762
778
|
if (parsed.length > 0) {
|