@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 CHANGED
@@ -229669,14 +229669,14 @@ function parseFilterAnnotation(annotation) {
229669
229669
  };
229670
229670
  }
229671
229671
  function parseFilters(annotations) {
229672
- const filters = [];
229672
+ const byName = new Map;
229673
229673
  for (const annotation of annotations) {
229674
229674
  const parsed = parseFilterAnnotation(annotation);
229675
229675
  if (parsed) {
229676
- filters.push(parsed);
229676
+ byName.set(parsed.name, parsed);
229677
229677
  }
229678
229678
  }
229679
- return filters;
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 allAnnotations = sourceObj.annotation?.blockNotes?.map((note) => note.text);
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 && allAnnotations.length > 0) {
230220
+ if (allAnnotations.length > 0) {
230213
230221
  try {
230214
230222
  const parsed = parseFilters(allAnnotations);
230215
230223
  if (parsed.length > 0) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.185",
4
+ "version": "0.0.186",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
@@ -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
  // -----------------------------------------------------------------------
@@ -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
- const filters: FilterDefinition[] = [];
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
- filters.push(parsed);
139
+ byName.set(parsed.name, parsed);
135
140
  }
136
141
  }
137
- return filters;
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
  });
@@ -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 (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);
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 && allAnnotations.length > 0) {
775
+ if (allAnnotations.length > 0) {
760
776
  try {
761
777
  const parsed = parseFilters(allAnnotations);
762
778
  if (parsed.length > 0) {