@malloy-publisher/server 0.0.182 → 0.0.183-dev
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/build.ts +7 -3
- package/dist/app/api-doc.yaml +423 -60
- package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
- package/dist/app/assets/{MainPage-DINuSDg0.js → MainPage-BzB3yoqi.js} +1 -1
- package/dist/app/assets/{ModelPage-BMcaV1YQ.js → ModelPage-C9O_sAXT.js} +1 -1
- package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
- package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
- package/dist/app/assets/{RouteError-r14osUo0.js → RouteError-DAShbVCG.js} +1 -1
- package/dist/app/assets/{WorkbookPage-HI39NTWs.js → WorkbookPage-Cs_XYEaB.js} +1 -1
- package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
- package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
- package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
- package/dist/app/assets/index-15BOvhp0.js +456 -0
- package/dist/app/assets/{index-Dd6uCk_C.js → index-Bb2jqquW.js} +1 -1
- package/dist/app/assets/{index-JqHhhRqY.js → index-D68X76-7.js} +98 -98
- package/dist/app/assets/{index.umd-lwkX_kFe.js → index.umd-DGBekgSu.js} +1 -1
- package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
- package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
- package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
- package/dist/app/index.html +1 -1
- package/dist/{instrumentation.js → instrumentation.mjs} +10567 -10584
- package/dist/{server.js → server.mjs} +16642 -15332
- package/package.json +19 -17
- package/src/controller/connection.controller.ts +27 -20
- package/src/controller/manifest.controller.ts +29 -0
- package/src/controller/materialization.controller.ts +125 -0
- package/src/controller/model.controller.ts +0 -2
- package/src/controller/package.controller.ts +53 -2
- package/src/errors.ts +24 -0
- package/src/mcp/prompts/handlers.ts +1 -1
- package/src/server.ts +202 -15
- package/src/service/connection.ts +1 -4
- 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/manifest_service.spec.ts +201 -0
- package/src/service/manifest_service.ts +106 -0
- package/src/service/materialization_service.spec.ts +648 -0
- package/src/service/materialization_service.ts +929 -0
- package/src/service/materialized_table_gc.spec.ts +383 -0
- package/src/service/materialized_table_gc.ts +279 -0
- package/src/service/model.ts +54 -13
- package/src/service/package.ts +50 -0
- package/src/service/project_store.ts +21 -2
- package/src/service/quoting.ts +41 -0
- package/src/service/resolve_project.ts +13 -0
- package/src/storage/DatabaseInterface.ts +103 -1
- package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
- package/src/storage/StorageManager.ts +119 -1
- package/src/storage/duckdb/DuckDBConnection.ts +1 -1
- package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
- package/src/storage/duckdb/DuckDBRepository.ts +99 -9
- package/src/storage/duckdb/ManifestRepository.ts +119 -0
- package/src/storage/duckdb/MaterializationRepository.ts +249 -0
- package/src/storage/duckdb/manifest_store.spec.ts +133 -0
- package/src/storage/duckdb/schema.ts +59 -1
- package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
- package/tests/fixtures/persist-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-test/persist_test.malloy +11 -0
- package/tests/fixtures/persist-test/publisher.json +5 -0
- package/tests/fixtures/publisher.config.json +15 -0
- package/tests/harness/rest_e2e.ts +68 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
- package/tsconfig.json +1 -1
- package/dist/app/assets/HomePage-or6BbD5P.js +0 -1
- package/dist/app/assets/PackagePage-DXxlQcCj.js +0 -1
- package/dist/app/assets/ProjectPage-vfZc_Kvu.js +0 -1
- package/dist/app/assets/index-Bw1lh09G.js +0 -467
package/src/server.ts
CHANGED
|
@@ -6,13 +6,14 @@ import {
|
|
|
6
6
|
} from "./instrumentation";
|
|
7
7
|
|
|
8
8
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
-
import
|
|
9
|
+
import bodyParser from "body-parser";
|
|
10
10
|
import cors from "cors";
|
|
11
11
|
import express from "express";
|
|
12
12
|
import * as http from "http";
|
|
13
13
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
14
14
|
import { AddressInfo } from "net";
|
|
15
15
|
import * as path from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
16
17
|
import { CompileController } from "./controller/compile.controller";
|
|
17
18
|
import { ConnectionController } from "./controller/connection.controller";
|
|
18
19
|
import { DatabaseController } from "./controller/database.controller";
|
|
@@ -20,7 +21,11 @@ import { ModelController } from "./controller/model.controller";
|
|
|
20
21
|
import { PackageController } from "./controller/package.controller";
|
|
21
22
|
import { QueryController } from "./controller/query.controller";
|
|
22
23
|
import { WatchModeController } from "./controller/watch-mode.controller";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
BadRequestError,
|
|
26
|
+
internalErrorToHttpError,
|
|
27
|
+
NotImplementedError,
|
|
28
|
+
} from "./errors";
|
|
24
29
|
import {
|
|
25
30
|
drainingGuard,
|
|
26
31
|
registerHealthEndpoints,
|
|
@@ -28,7 +33,11 @@ import {
|
|
|
28
33
|
} from "./health";
|
|
29
34
|
import { logger, loggerMiddleware } from "./logger";
|
|
30
35
|
|
|
36
|
+
import { ManifestController } from "./controller/manifest.controller";
|
|
37
|
+
import { MaterializationController } from "./controller/materialization.controller";
|
|
31
38
|
import { initializeMcpServer } from "./mcp/server";
|
|
39
|
+
import { ManifestService } from "./service/manifest_service";
|
|
40
|
+
import { MaterializationService } from "./service/materialization_service";
|
|
32
41
|
import { ProjectStore } from "./service/project_store";
|
|
33
42
|
|
|
34
43
|
/** Normalize an Express query param into a string[] or undefined. */
|
|
@@ -112,30 +121,37 @@ const SHUTDOWN_DRAIN_DURATION_SECONDS = Number(
|
|
|
112
121
|
const SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS = Number(
|
|
113
122
|
process.env.SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS || 0,
|
|
114
123
|
);
|
|
115
|
-
// Find the app directory
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
ROOT = path.join(path.dirname(require.main.filename), "app");
|
|
120
|
-
} else {
|
|
121
|
-
// Fallback to current script directory
|
|
122
|
-
ROOT = path.join(path.dirname(process.argv[1] || __filename), "app");
|
|
123
|
-
}
|
|
124
|
+
// Find the app directory relative to this bundled server file.
|
|
125
|
+
// Works under both ESM (import.meta.url) and when invoked via NPX.
|
|
126
|
+
const __filename_esm = fileURLToPath(import.meta.url);
|
|
127
|
+
const ROOT = path.join(path.dirname(__filename_esm), "app");
|
|
124
128
|
const SERVER_ROOT = path.resolve(process.cwd(), process.env.SERVER_ROOT || ".");
|
|
125
129
|
const API_PREFIX = "/api/v0";
|
|
126
130
|
const isDevelopment = process.env["NODE_ENV"] === "development";
|
|
127
131
|
|
|
128
|
-
const app = express();
|
|
132
|
+
export const app = express();
|
|
129
133
|
app.use(loggerMiddleware);
|
|
130
134
|
app.use(httpMetricsMiddleware);
|
|
131
135
|
const projectStore = new ProjectStore(SERVER_ROOT);
|
|
136
|
+
const manifestService = new ManifestService(projectStore);
|
|
132
137
|
const watchModeController = new WatchModeController(projectStore);
|
|
133
138
|
const connectionController = new ConnectionController(projectStore);
|
|
134
139
|
const modelController = new ModelController(projectStore);
|
|
135
|
-
const packageController = new PackageController(projectStore);
|
|
140
|
+
const packageController = new PackageController(projectStore, manifestService);
|
|
136
141
|
const databaseController = new DatabaseController(projectStore);
|
|
137
142
|
const queryController = new QueryController(projectStore);
|
|
138
143
|
const compileController = new CompileController(projectStore);
|
|
144
|
+
const materializationService = new MaterializationService(
|
|
145
|
+
projectStore,
|
|
146
|
+
manifestService,
|
|
147
|
+
);
|
|
148
|
+
const materializationController = new MaterializationController(
|
|
149
|
+
materializationService,
|
|
150
|
+
);
|
|
151
|
+
const manifestController = new ManifestController(
|
|
152
|
+
projectStore,
|
|
153
|
+
manifestService,
|
|
154
|
+
);
|
|
139
155
|
|
|
140
156
|
export const mcpApp = express();
|
|
141
157
|
|
|
@@ -262,7 +278,9 @@ app.use(
|
|
|
262
278
|
credentials: true,
|
|
263
279
|
}),
|
|
264
280
|
);
|
|
265
|
-
|
|
281
|
+
|
|
282
|
+
// Set body-parser JSON limit to 1Mb (default: 100kb)
|
|
283
|
+
app.use(bodyParser.json({ limit: "1mb" }));
|
|
266
284
|
|
|
267
285
|
// Register health check endpoints on main app:
|
|
268
286
|
// - Required for production/Kubernetes monitoring (main server on PUBLISHER_PORT)
|
|
@@ -579,7 +597,7 @@ app.post(
|
|
|
579
597
|
req.params.projectName,
|
|
580
598
|
req.params.connectionName,
|
|
581
599
|
req.body.sqlStatement as string,
|
|
582
|
-
req.
|
|
600
|
+
req.body.options as string,
|
|
583
601
|
),
|
|
584
602
|
);
|
|
585
603
|
} catch (error) {
|
|
@@ -650,9 +668,11 @@ app.get(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
|
|
|
650
668
|
|
|
651
669
|
app.post(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
|
|
652
670
|
try {
|
|
671
|
+
const autoLoadManifest = req.query.autoLoadManifest === "true";
|
|
653
672
|
const _package = await packageController.addPackage(
|
|
654
673
|
req.params.projectName,
|
|
655
674
|
req.body,
|
|
675
|
+
{ autoLoadManifest },
|
|
656
676
|
);
|
|
657
677
|
res.status(200).json(_package?.getPackageMetadata());
|
|
658
678
|
} catch (error) {
|
|
@@ -953,6 +973,173 @@ app.post(
|
|
|
953
973
|
},
|
|
954
974
|
);
|
|
955
975
|
|
|
976
|
+
// ==================== MATERIALIZATION ROUTES ====================
|
|
977
|
+
|
|
978
|
+
app.post(
|
|
979
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/materializations`,
|
|
980
|
+
async (req, res) => {
|
|
981
|
+
try {
|
|
982
|
+
const build = await materializationController.createMaterialization(
|
|
983
|
+
req.params.projectName,
|
|
984
|
+
req.params.packageName,
|
|
985
|
+
req.body || {},
|
|
986
|
+
);
|
|
987
|
+
res.status(201).json(build);
|
|
988
|
+
} catch (error) {
|
|
989
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
990
|
+
res.status(status).json(json);
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
app.get(
|
|
996
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/materializations`,
|
|
997
|
+
async (req, res) => {
|
|
998
|
+
try {
|
|
999
|
+
const limit = req.query.limit
|
|
1000
|
+
? parseInt(req.query.limit as string, 10)
|
|
1001
|
+
: undefined;
|
|
1002
|
+
const offset = req.query.offset
|
|
1003
|
+
? parseInt(req.query.offset as string, 10)
|
|
1004
|
+
: undefined;
|
|
1005
|
+
const builds = await materializationController.listMaterializations(
|
|
1006
|
+
req.params.projectName,
|
|
1007
|
+
req.params.packageName,
|
|
1008
|
+
{ limit, offset },
|
|
1009
|
+
);
|
|
1010
|
+
res.status(200).json(builds);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1013
|
+
res.status(status).json(json);
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
app.get(
|
|
1019
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
|
|
1020
|
+
async (req, res) => {
|
|
1021
|
+
try {
|
|
1022
|
+
const build = await materializationController.getMaterialization(
|
|
1023
|
+
req.params.projectName,
|
|
1024
|
+
req.params.packageName,
|
|
1025
|
+
req.params.materializationId,
|
|
1026
|
+
);
|
|
1027
|
+
res.status(200).json(build);
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1030
|
+
res.status(status).json(json);
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
app.post(
|
|
1036
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/teardown`,
|
|
1037
|
+
async (req, res) => {
|
|
1038
|
+
try {
|
|
1039
|
+
const result = await materializationController.teardownPackage(
|
|
1040
|
+
req.params.projectName,
|
|
1041
|
+
req.params.packageName,
|
|
1042
|
+
req.body || {},
|
|
1043
|
+
);
|
|
1044
|
+
res.status(200).json(result);
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1047
|
+
res.status(status).json(json);
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
app.post(
|
|
1053
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
|
|
1054
|
+
async (req, res) => {
|
|
1055
|
+
try {
|
|
1056
|
+
const action = req.query.action;
|
|
1057
|
+
if (action === "start") {
|
|
1058
|
+
const build = await materializationController.startMaterialization(
|
|
1059
|
+
req.params.projectName,
|
|
1060
|
+
req.params.packageName,
|
|
1061
|
+
req.params.materializationId,
|
|
1062
|
+
);
|
|
1063
|
+
res.status(202).json(build);
|
|
1064
|
+
} else if (action === "stop") {
|
|
1065
|
+
const build = await materializationController.stopMaterialization(
|
|
1066
|
+
req.params.projectName,
|
|
1067
|
+
req.params.packageName,
|
|
1068
|
+
req.params.materializationId,
|
|
1069
|
+
);
|
|
1070
|
+
res.status(200).json(build);
|
|
1071
|
+
} else {
|
|
1072
|
+
throw new BadRequestError(
|
|
1073
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'start' or 'stop'.`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1078
|
+
res.status(status).json(json);
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
app.delete(
|
|
1084
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
|
|
1085
|
+
async (req, res) => {
|
|
1086
|
+
try {
|
|
1087
|
+
await materializationController.deleteMaterialization(
|
|
1088
|
+
req.params.projectName,
|
|
1089
|
+
req.params.packageName,
|
|
1090
|
+
req.params.materializationId,
|
|
1091
|
+
);
|
|
1092
|
+
res.status(204).send();
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1095
|
+
res.status(status).json(json);
|
|
1096
|
+
}
|
|
1097
|
+
},
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
// ==================== MANIFEST ROUTES ====================
|
|
1101
|
+
|
|
1102
|
+
app.get(
|
|
1103
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/manifest`,
|
|
1104
|
+
async (req, res) => {
|
|
1105
|
+
try {
|
|
1106
|
+
const manifest = await manifestController.getManifest(
|
|
1107
|
+
req.params.projectName,
|
|
1108
|
+
req.params.packageName,
|
|
1109
|
+
);
|
|
1110
|
+
res.status(200).json(manifest);
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
logger.error("Get manifest error", { error });
|
|
1113
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1114
|
+
res.status(status).json(json);
|
|
1115
|
+
}
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
app.post(
|
|
1120
|
+
`${API_PREFIX}/projects/:projectName/packages/:packageName/manifest`,
|
|
1121
|
+
async (req, res) => {
|
|
1122
|
+
try {
|
|
1123
|
+
const action = req.query.action;
|
|
1124
|
+
if (action === "reload") {
|
|
1125
|
+
const manifest = await manifestController.reloadManifest(
|
|
1126
|
+
req.params.projectName,
|
|
1127
|
+
req.params.packageName,
|
|
1128
|
+
);
|
|
1129
|
+
res.status(200).json(manifest);
|
|
1130
|
+
} else {
|
|
1131
|
+
throw new BadRequestError(
|
|
1132
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'reload'.`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
logger.error("Manifest action error", { error });
|
|
1137
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1138
|
+
res.status(status).json(json);
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
);
|
|
1142
|
+
|
|
956
1143
|
// Modify the catch-all route to only serve index.html in production
|
|
957
1144
|
if (!isDevelopment) {
|
|
958
1145
|
app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));
|
|
@@ -1093,10 +1093,7 @@ export async function createProjectConnections(
|
|
|
1093
1093
|
},
|
|
1094
1094
|
poolOptions: {
|
|
1095
1095
|
min: 1,
|
|
1096
|
-
max:
|
|
1097
|
-
testOnBorrow: false,
|
|
1098
|
-
testOnReturn: false,
|
|
1099
|
-
testWhileIdle: true,
|
|
1096
|
+
max: 20,
|
|
1100
1097
|
},
|
|
1101
1098
|
};
|
|
1102
1099
|
const snowflakeConnection = new SnowflakeConnection(
|
|
@@ -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
|
});
|