@malloy-publisher/server 0.0.202 → 0.0.204
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 +25 -3
- package/dist/app/assets/{EnvironmentPage-CNQYDaxR.js → EnvironmentPage-CX06cjOF.js} +1 -1
- package/dist/app/assets/HomePage-CNFt_eUU.js +1 -0
- package/dist/app/assets/{MainPage-B0kNpkxT.js → MainPage-nUJ9YatG.js} +1 -1
- package/dist/app/assets/{PackagePage-yAh0TrOV.js → MaterializationsPage-B5goxVXW.js} +1 -1
- package/dist/app/assets/{ModelPage-DcVElc9L.js → ModelPage-Ba7Xh4lL.js} +1 -1
- package/dist/app/assets/PackagePage-BaEVdEAG.js +1 -0
- package/dist/app/assets/{RouteError-DknUbx_s.js → RouteError-BShQjZio.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CCqc8otA.js → WorkbookPage-CBn6ZjJW.js} +1 -1
- package/dist/app/assets/{core-B3A61KGJ.es-iOUZ6RJL.js → core-DECXYL4E.es-OaRfXwuQ.js} +1 -1
- package/dist/app/assets/{index-W0bOLKGl.js → index-BLfPC1gy.js} +2 -2
- package/dist/app/assets/index-DqiJ0bWp.js +455 -0
- package/dist/app/assets/index-Dy3YhAZQ.js +1812 -0
- package/dist/app/assets/index.umd-DAN9K8yC.js +2469 -0
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +392 -67
- package/dist/server.mjs +418 -153
- package/package.json +11 -11
- package/src/ducklake_version.spec.ts +43 -0
- package/src/ducklake_version.ts +26 -0
- package/src/errors.ts +18 -1
- package/src/package_load/package_load_pool.ts +0 -5
- package/src/package_load/package_load_worker.ts +41 -99
- package/src/package_load/protocol.ts +1 -7
- package/src/service/annotations.spec.ts +118 -0
- package/src/service/annotations.ts +91 -0
- package/src/service/authorize.spec.ts +132 -0
- package/src/service/authorize.ts +241 -0
- package/src/service/authorize_integration.spec.ts +838 -0
- package/src/service/connection.ts +1 -1
- package/src/service/environment.ts +4 -4
- package/src/service/environment_store.ts +14 -2
- package/src/service/filter.spec.ts +14 -3
- package/src/service/filter.ts +5 -1
- package/src/service/filter_bypass.spec.ts +418 -0
- package/src/service/given.ts +37 -12
- package/src/service/givens_integration.spec.ts +34 -7
- package/src/service/materialization_service.ts +25 -20
- package/src/service/materialized_table_gc.spec.ts +6 -5
- package/src/service/materialized_table_gc.ts +2 -50
- package/src/service/model.spec.ts +203 -8
- package/src/service/model.ts +305 -155
- package/src/service/package_worker_path.spec.ts +113 -0
- package/src/service/quoting.ts +0 -20
- package/src/service/restricted_mode.spec.ts +299 -0
- package/src/service/source_extraction.ts +226 -0
- package/src/storage/StorageManager.ts +73 -0
- package/dist/app/assets/HomePage-DBFTIoD8.js +0 -1
- package/dist/app/assets/index-F_o127LC.js +0 -454
- package/dist/app/assets/index-QeX_e740.js +0 -1803
- package/dist/app/assets/index.umd-CEDRw4TK.js +0 -1145
|
@@ -123,8 +123,36 @@ describe("givens introspection", () => {
|
|
|
123
123
|
|
|
124
124
|
expect(region).toBeDefined();
|
|
125
125
|
expect(region?.type).toBe("string");
|
|
126
|
+
expect(region?.default).toBe("'US'");
|
|
126
127
|
expect(cutoff).toBeDefined();
|
|
127
128
|
expect(cutoff?.type).toBe("date");
|
|
129
|
+
expect(cutoff?.default).toBe("@2024-02-01");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("omits default for a given declared without one", async () => {
|
|
133
|
+
await fs.writeFile(
|
|
134
|
+
path.join(TEST_PKG_DIR, "mixed_defaults.malloy"),
|
|
135
|
+
`##! experimental.givens
|
|
136
|
+
|
|
137
|
+
given: with_default :: string is 'WN'
|
|
138
|
+
given: no_default :: string
|
|
139
|
+
|
|
140
|
+
source: orders is duckdb.table('orders') extend {
|
|
141
|
+
primary_key: order_id
|
|
142
|
+
}
|
|
143
|
+
`,
|
|
144
|
+
);
|
|
145
|
+
const model = await Model.create(
|
|
146
|
+
"test-pkg",
|
|
147
|
+
TEST_PKG_DIR,
|
|
148
|
+
"mixed_defaults.malloy",
|
|
149
|
+
getConnections(),
|
|
150
|
+
);
|
|
151
|
+
const byName = new Map(
|
|
152
|
+
((await model.getModel()).givens ?? []).map((g) => [g.name, g]),
|
|
153
|
+
);
|
|
154
|
+
expect(byName.get("with_default")?.default).toBe("'WN'");
|
|
155
|
+
expect(byName.get("no_default")?.default).toBeUndefined();
|
|
128
156
|
});
|
|
129
157
|
|
|
130
158
|
it("attaches the model-level givens list to every source", async () => {
|
|
@@ -179,14 +207,13 @@ describe("givens introspection", () => {
|
|
|
179
207
|
const region = compiledModel.givens?.[0];
|
|
180
208
|
expect(region?.name).toBe("region_filter");
|
|
181
209
|
|
|
182
|
-
// The
|
|
183
|
-
// Only
|
|
210
|
+
// The given declares two app-route annotations (`#(doc)`, `#(label)`).
|
|
211
|
+
// Only app routes land on the wire; Malloy-reserved routes — the
|
|
212
|
+
// model-level `##!` pragma, plain `#` tags, `#"` doc strings — must
|
|
213
|
+
// not leak onto the given's surface.
|
|
184
214
|
const annotations = region?.annotations ?? [];
|
|
185
215
|
expect(annotations.length).toBeGreaterThanOrEqual(2);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
// Negative assertion: no pragma leakage.
|
|
190
|
-
expect(annotations.some((a) => a.startsWith("##!"))).toBe(false);
|
|
216
|
+
expect(annotations.some((a) => a.startsWith("##"))).toBe(false);
|
|
217
|
+
expect(annotations.some((a) => a.startsWith('#"'))).toBe(false);
|
|
191
218
|
});
|
|
192
219
|
});
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
liveTableKey,
|
|
28
28
|
} from "./materialized_table_gc";
|
|
29
29
|
import { Model } from "./model";
|
|
30
|
-
import {
|
|
30
|
+
import { splitTablePath } from "./quoting";
|
|
31
31
|
import { resolveEnvironmentId } from "./resolve_environment";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -91,12 +91,17 @@ export function manifestTableKey(
|
|
|
91
91
|
* running a zero-row SELECT. Returns `true` if the table resolves,
|
|
92
92
|
* `false` if the query fails (assumed "table not found").
|
|
93
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* `tableName` is interpolated verbatim into the probe SQL — the caller
|
|
96
|
+
* supplies it already quoted for the dialect (the `#@ persist name=…`
|
|
97
|
+
* contract), matching how Malloy substitutes the name on the read side.
|
|
98
|
+
*/
|
|
94
99
|
export async function tablePhysicallyExists(
|
|
95
100
|
connection: MalloyConnection,
|
|
96
|
-
|
|
101
|
+
tableName: string,
|
|
97
102
|
): Promise<boolean> {
|
|
98
103
|
try {
|
|
99
|
-
await connection.runSQL(`SELECT 1 FROM ${
|
|
104
|
+
await connection.runSQL(`SELECT 1 FROM ${tableName} WHERE 1=0`);
|
|
100
105
|
return true;
|
|
101
106
|
} catch {
|
|
102
107
|
return false;
|
|
@@ -731,7 +736,7 @@ export class MaterializationService {
|
|
|
731
736
|
|
|
732
737
|
// getBuildPlan() throws if the tag is missing, so check first to
|
|
733
738
|
// keep plain models in the same package buildable.
|
|
734
|
-
const modelTag = malloyModel.
|
|
739
|
+
const modelTag = malloyModel.annotations.parseAsTag("!").tag;
|
|
735
740
|
if (!modelTag.has("experimental", "persistence")) {
|
|
736
741
|
logger.debug(
|
|
737
742
|
"Model has no ##! experimental.persistence tag, skipping",
|
|
@@ -775,7 +780,7 @@ export class MaterializationService {
|
|
|
775
780
|
const tableOwners = new Map<string, string>();
|
|
776
781
|
for (const [sourceID, source] of Object.entries(allSources)) {
|
|
777
782
|
const tableName =
|
|
778
|
-
source.
|
|
783
|
+
source.annotations.parseAsTag("@").tag.text("name") || source.name;
|
|
779
784
|
const key = `${source.connectionName}::${tableName}`;
|
|
780
785
|
const existing = tableOwners.get(key);
|
|
781
786
|
if (existing) {
|
|
@@ -844,12 +849,16 @@ export class MaterializationService {
|
|
|
844
849
|
|
|
845
850
|
const connectionName = persistSource.connectionName;
|
|
846
851
|
const tableName =
|
|
847
|
-
persistSource.
|
|
852
|
+
persistSource.annotations.parseAsTag("@").tag.text("name") ||
|
|
848
853
|
persistSource.name;
|
|
849
|
-
const {
|
|
850
|
-
const stagingTableName = `${
|
|
851
|
-
|
|
852
|
-
|
|
854
|
+
const { bareName } = splitTablePath(tableName);
|
|
855
|
+
const stagingTableName = `${tableName}${stagingSuffix(buildId)}`;
|
|
856
|
+
|
|
857
|
+
// Table names go into DDL verbatim. Malloy assumes a table name handed
|
|
858
|
+
// to it (here, via the build manifest) is already quoted for the
|
|
859
|
+
// dialect and substitutes it into generated SQL as-is; our DDL has to
|
|
860
|
+
// match that exact identifier or the CREATE and the read diverge. The
|
|
861
|
+
// model author owns quoting the `#@ persist name=...` value.
|
|
853
862
|
|
|
854
863
|
// Guard: refuse to overwrite a pre-existing table that was not
|
|
855
864
|
// created by a previous materialization build. Without this check a
|
|
@@ -858,7 +867,7 @@ export class MaterializationService {
|
|
|
858
867
|
// DROP TABLE below would silently destroy it.
|
|
859
868
|
const tableKey = manifestTableKey(connectionName, tableName);
|
|
860
869
|
if (!knownMaterializedTables.has(tableKey)) {
|
|
861
|
-
if (await tablePhysicallyExists(connection,
|
|
870
|
+
if (await tablePhysicallyExists(connection, tableName)) {
|
|
862
871
|
throw new BadRequestError(
|
|
863
872
|
`Refusing to materialize source '${persistSource.name}': ` +
|
|
864
873
|
`target table '${tableName}' already exists on connection ` +
|
|
@@ -877,26 +886,22 @@ export class MaterializationService {
|
|
|
877
886
|
|
|
878
887
|
const startTime = performance.now();
|
|
879
888
|
|
|
880
|
-
await connection.runSQL(
|
|
881
|
-
`DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
|
|
882
|
-
);
|
|
889
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
883
890
|
|
|
884
891
|
// If any step after CREATE throws we must best-effort drop the
|
|
885
892
|
// staging table, else it orphans under a name that GC will never
|
|
886
893
|
// find (no manifest row is written for a failed build).
|
|
887
894
|
try {
|
|
888
895
|
await connection.runSQL(
|
|
889
|
-
`CREATE TABLE ${
|
|
896
|
+
`CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
|
|
890
897
|
);
|
|
891
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${
|
|
898
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${tableName}`);
|
|
892
899
|
await connection.runSQL(
|
|
893
|
-
`ALTER TABLE ${
|
|
900
|
+
`ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
|
|
894
901
|
);
|
|
895
902
|
} catch (err) {
|
|
896
903
|
try {
|
|
897
|
-
await connection.runSQL(
|
|
898
|
-
`DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
|
|
899
|
-
);
|
|
904
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
900
905
|
} catch (cleanupErr) {
|
|
901
906
|
logger.warn(
|
|
902
907
|
"Build: failed to clean up staging table after a failed rebuild; physical leak",
|
|
@@ -174,7 +174,7 @@ describe("dropManifestEntries", () => {
|
|
|
174
174
|
);
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
it("
|
|
177
|
+
it("drops regardless of connection dialect — DDL uses table names verbatim", async () => {
|
|
178
178
|
const ctx2 = makeCtx("martian-sql");
|
|
179
179
|
const entry = makeEntry();
|
|
180
180
|
|
|
@@ -184,10 +184,11 @@ describe("dropManifestEntries", () => {
|
|
|
184
184
|
environmentId: "proj-1",
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
expect(result.
|
|
190
|
-
expect(
|
|
187
|
+
// GC no longer needs a per-dialect quoter: table names are used
|
|
188
|
+
// verbatim, so an unrecognized dialect is no longer a special case.
|
|
189
|
+
expect(result.dropped).toHaveLength(1);
|
|
190
|
+
expect(result.errors).toHaveLength(0);
|
|
191
|
+
expect(ctx2.conn.runSQL.callCount).toBe(2);
|
|
191
192
|
});
|
|
192
193
|
|
|
193
194
|
it("dryRun lists what would drop without issuing SQL or deleting rows", async () => {
|
|
@@ -1,37 +1,8 @@
|
|
|
1
1
|
import type { Connection } from "@malloydata/malloy";
|
|
2
|
-
import {
|
|
3
|
-
DatabricksDialect,
|
|
4
|
-
DuckDBDialect,
|
|
5
|
-
MySQLDialect,
|
|
6
|
-
PostgresDialect,
|
|
7
|
-
SnowflakeDialect,
|
|
8
|
-
StandardSQLDialect,
|
|
9
|
-
TrinoDialect,
|
|
10
|
-
} from "@malloydata/malloy";
|
|
11
2
|
import { logger } from "../logger";
|
|
12
3
|
import { ManifestEntry } from "../storage/DatabaseInterface";
|
|
13
4
|
import { ManifestService } from "./manifest_service";
|
|
14
5
|
import { stagingSuffix } from "./materialization_service";
|
|
15
|
-
import { type Quoter, quoteTablePath } from "./quoting";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Registry of built-in dialects keyed by `Connection.dialectName`. Malloy's
|
|
19
|
-
* internal `getDialect` helper isn't part of the package's public exports,
|
|
20
|
-
* so we assemble our own registry from the exported dialect classes.
|
|
21
|
-
*
|
|
22
|
-
* Note: `presto` (extends `TrinoDialect`) is not re-exported publicly and
|
|
23
|
-
* is niche enough to omit; if/when it ships as a publisher connection type,
|
|
24
|
-
* add it here.
|
|
25
|
-
*/
|
|
26
|
-
const DIALECTS: Readonly<Record<string, Quoter>> = Object.freeze({
|
|
27
|
-
duckdb: new DuckDBDialect(),
|
|
28
|
-
standardsql: new StandardSQLDialect(),
|
|
29
|
-
trino: new TrinoDialect(),
|
|
30
|
-
postgres: new PostgresDialect(),
|
|
31
|
-
snowflake: new SnowflakeDialect(),
|
|
32
|
-
mysql: new MySQLDialect(),
|
|
33
|
-
databricks: new DatabricksDialect(),
|
|
34
|
-
});
|
|
35
6
|
|
|
36
7
|
/** Build a stable key for a `(connectionName, tableName)` tuple. */
|
|
37
8
|
export function liveTableKey(
|
|
@@ -154,19 +125,6 @@ async function processOneEntry(
|
|
|
154
125
|
};
|
|
155
126
|
}
|
|
156
127
|
|
|
157
|
-
// ── Unknown dialect ───────────────────────────────────────────
|
|
158
|
-
const dialect = DIALECTS[connection.dialectName];
|
|
159
|
-
if (!dialect) {
|
|
160
|
-
return {
|
|
161
|
-
error: {
|
|
162
|
-
buildId: entry.buildId,
|
|
163
|
-
tableName: entry.tableName,
|
|
164
|
-
connectionName: entry.connectionName,
|
|
165
|
-
error: `No dialect registered for '${connection.dialectName}'`,
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
128
|
// ── Dry run ───────────────────────────────────────────────────
|
|
171
129
|
if (ctx.dryRun) {
|
|
172
130
|
return {
|
|
@@ -181,8 +139,6 @@ async function processOneEntry(
|
|
|
181
139
|
}
|
|
182
140
|
|
|
183
141
|
// ── Live run: delete manifest row first ───────────────────────
|
|
184
|
-
const quoted = (p: string) => quoteTablePath(p, dialect);
|
|
185
|
-
|
|
186
142
|
try {
|
|
187
143
|
await ctx.manifestService.deleteEntry(ctx.environmentId, entry.id);
|
|
188
144
|
} catch (err) {
|
|
@@ -214,9 +170,7 @@ async function processOneEntry(
|
|
|
214
170
|
);
|
|
215
171
|
} else {
|
|
216
172
|
try {
|
|
217
|
-
await connection.runSQL(
|
|
218
|
-
`DROP TABLE IF EXISTS ${quoted(entry.tableName)}`,
|
|
219
|
-
);
|
|
173
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${entry.tableName}`);
|
|
220
174
|
} catch (err) {
|
|
221
175
|
logger.warn(
|
|
222
176
|
"GC: deleted manifest row but failed to drop materialized table (orphaned)",
|
|
@@ -231,9 +185,7 @@ async function processOneEntry(
|
|
|
231
185
|
|
|
232
186
|
// ── Best-effort drop staging companion ────────────────────────
|
|
233
187
|
try {
|
|
234
|
-
await connection.runSQL(
|
|
235
|
-
`DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
|
|
236
|
-
);
|
|
188
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
237
189
|
} catch (err) {
|
|
238
190
|
logger.warn("GC: failed to drop staging table (best-effort)", {
|
|
239
191
|
stagingTableName,
|
|
@@ -191,6 +191,84 @@ describe("service/model", () => {
|
|
|
191
191
|
|
|
192
192
|
sinon.restore();
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
it("embeds model-level givens in each newSources SourceInfo", async () => {
|
|
196
|
+
const sourceInfo = {
|
|
197
|
+
name: "carriers",
|
|
198
|
+
schema: { fields: [] },
|
|
199
|
+
};
|
|
200
|
+
const givens = [
|
|
201
|
+
{
|
|
202
|
+
name: "region",
|
|
203
|
+
type: "string",
|
|
204
|
+
annotations: ["#(doc) Region"],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
const model = new Model(
|
|
208
|
+
packageName,
|
|
209
|
+
"test.malloynb",
|
|
210
|
+
{},
|
|
211
|
+
"notebook",
|
|
212
|
+
undefined, // modelMaterializer
|
|
213
|
+
undefined, // modelDef
|
|
214
|
+
undefined, // sources
|
|
215
|
+
undefined, // queries
|
|
216
|
+
undefined, // sourceInfos
|
|
217
|
+
[
|
|
218
|
+
{
|
|
219
|
+
type: "code",
|
|
220
|
+
text: "import 'carriers.malloy'",
|
|
221
|
+
newSources: [sourceInfo],
|
|
222
|
+
},
|
|
223
|
+
], // runnableNotebookCells
|
|
224
|
+
undefined, // compilationError
|
|
225
|
+
undefined, // filterMap
|
|
226
|
+
givens, // givens
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const notebook = await model.getNotebook();
|
|
230
|
+
expect(notebook.notebookCells).toHaveLength(1);
|
|
231
|
+
const parsed = JSON.parse(
|
|
232
|
+
notebook.notebookCells![0].newSources![0],
|
|
233
|
+
);
|
|
234
|
+
expect(parsed.name).toBe("carriers");
|
|
235
|
+
// SourceInfo fields are preserved untouched.
|
|
236
|
+
expect(parsed.schema).toEqual({ fields: [] });
|
|
237
|
+
// Givens ride along verbatim — no second getModel round-trip needed.
|
|
238
|
+
expect(parsed.givens).toEqual(givens);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("omits givens from newSources when the model declares none", async () => {
|
|
242
|
+
const sourceInfo = { name: "carriers", schema: { fields: [] } };
|
|
243
|
+
const model = new Model(
|
|
244
|
+
packageName,
|
|
245
|
+
"test.malloynb",
|
|
246
|
+
{},
|
|
247
|
+
"notebook",
|
|
248
|
+
undefined,
|
|
249
|
+
undefined,
|
|
250
|
+
undefined,
|
|
251
|
+
undefined,
|
|
252
|
+
undefined,
|
|
253
|
+
[
|
|
254
|
+
{
|
|
255
|
+
type: "code",
|
|
256
|
+
text: "import 'carriers.malloy'",
|
|
257
|
+
newSources: [sourceInfo],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
undefined,
|
|
261
|
+
undefined,
|
|
262
|
+
undefined, // no givens
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const notebook = await model.getNotebook();
|
|
266
|
+
const parsed = JSON.parse(
|
|
267
|
+
notebook.notebookCells![0].newSources![0],
|
|
268
|
+
);
|
|
269
|
+
expect(parsed.name).toBe("carriers");
|
|
270
|
+
expect(parsed).not.toHaveProperty("givens");
|
|
271
|
+
});
|
|
194
272
|
});
|
|
195
273
|
|
|
196
274
|
describe("getQueryResults", () => {
|
|
@@ -239,6 +317,78 @@ describe("service/model", () => {
|
|
|
239
317
|
sinon.restore();
|
|
240
318
|
});
|
|
241
319
|
|
|
320
|
+
// Both caller-driven compile paths — the free-form `query` text and the
|
|
321
|
+
// `run: source->view` string built from `sourceName`/`queryName` — must
|
|
322
|
+
// go through restricted mode. The trusted `loadQuery` is reserved for
|
|
323
|
+
// author-curated content (notebook cells) and must never be reached from
|
|
324
|
+
// `getQueryResults`. These tests pin the dispatch so a regression that
|
|
325
|
+
// re-routes either path back to `loadQuery` is caught.
|
|
326
|
+
describe("compile dispatch", () => {
|
|
327
|
+
function buildDispatchModel(): {
|
|
328
|
+
model: Model;
|
|
329
|
+
loadQuery: sinon.SinonStub;
|
|
330
|
+
loadRestrictedQuery: sinon.SinonStub;
|
|
331
|
+
} {
|
|
332
|
+
// getPreparedResult rejects so execution stops right after the
|
|
333
|
+
// loader call; we only assert which loader was invoked.
|
|
334
|
+
const runnableStub = {
|
|
335
|
+
getPreparedResult: sinon
|
|
336
|
+
.stub()
|
|
337
|
+
.rejects(new MalloyError("stub-stop", [])),
|
|
338
|
+
run: sinon.stub().rejects(new MalloyError("stub-stop", [])),
|
|
339
|
+
};
|
|
340
|
+
const loadQuery = sinon.stub().returns(runnableStub);
|
|
341
|
+
const loadRestrictedQuery = sinon.stub().returns(runnableStub);
|
|
342
|
+
const modelMaterializer = { loadQuery, loadRestrictedQuery };
|
|
343
|
+
const model = new Model(
|
|
344
|
+
packageName,
|
|
345
|
+
mockModelPath,
|
|
346
|
+
{},
|
|
347
|
+
"model",
|
|
348
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
349
|
+
modelMaterializer as any,
|
|
350
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
351
|
+
{ contents: {}, exports: [], queryList: [] } as any,
|
|
352
|
+
undefined,
|
|
353
|
+
undefined,
|
|
354
|
+
undefined,
|
|
355
|
+
undefined,
|
|
356
|
+
undefined,
|
|
357
|
+
);
|
|
358
|
+
return { model, loadQuery, loadRestrictedQuery };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
afterEach(() => sinon.restore());
|
|
362
|
+
|
|
363
|
+
it("compiles ad-hoc query text in restricted mode, never trusted loadQuery", async () => {
|
|
364
|
+
const { model, loadQuery, loadRestrictedQuery } =
|
|
365
|
+
buildDispatchModel();
|
|
366
|
+
|
|
367
|
+
await expect(
|
|
368
|
+
model.getQueryResults(
|
|
369
|
+
undefined,
|
|
370
|
+
undefined,
|
|
371
|
+
"run: orders -> { aggregate: c is count() }",
|
|
372
|
+
),
|
|
373
|
+
).rejects.toThrow(MalloyError);
|
|
374
|
+
|
|
375
|
+
expect(loadRestrictedQuery.calledOnce).toBe(true);
|
|
376
|
+
expect(loadQuery.called).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("compiles the named source/view path in restricted mode, never trusted loadQuery", async () => {
|
|
380
|
+
const { model, loadQuery, loadRestrictedQuery } =
|
|
381
|
+
buildDispatchModel();
|
|
382
|
+
|
|
383
|
+
await expect(
|
|
384
|
+
model.getQueryResults("orders", "summary"),
|
|
385
|
+
).rejects.toThrow(MalloyError);
|
|
386
|
+
|
|
387
|
+
expect(loadRestrictedQuery.calledOnce).toBe(true);
|
|
388
|
+
expect(loadQuery.called).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
242
392
|
it("forwards givens to runnable.getPreparedResult and .run", async () => {
|
|
243
393
|
const givensArg = { region: "EU" };
|
|
244
394
|
const preparedResultStub = sinon
|
|
@@ -247,11 +397,13 @@ describe("service/model", () => {
|
|
|
247
397
|
const runStub = sinon
|
|
248
398
|
.stub()
|
|
249
399
|
.rejects(new MalloyError("stub-stop", []));
|
|
400
|
+
const runnableStub = {
|
|
401
|
+
getPreparedResult: preparedResultStub,
|
|
402
|
+
run: runStub,
|
|
403
|
+
};
|
|
250
404
|
const modelMaterializer = {
|
|
251
|
-
loadQuery: sinon.stub().returns(
|
|
252
|
-
|
|
253
|
-
run: runStub,
|
|
254
|
-
}),
|
|
405
|
+
loadQuery: sinon.stub().returns(runnableStub),
|
|
406
|
+
loadRestrictedQuery: sinon.stub().returns(runnableStub),
|
|
255
407
|
};
|
|
256
408
|
|
|
257
409
|
const model = new Model(
|
|
@@ -347,11 +499,13 @@ describe("service/model", () => {
|
|
|
347
499
|
typeof API.util.wrapResult
|
|
348
500
|
>,
|
|
349
501
|
);
|
|
502
|
+
const runnableStub = {
|
|
503
|
+
getPreparedResult: preparedResultStub,
|
|
504
|
+
run: runStub,
|
|
505
|
+
};
|
|
350
506
|
const modelMaterializer = {
|
|
351
|
-
loadQuery: sinon.stub().returns(
|
|
352
|
-
|
|
353
|
-
run: runStub,
|
|
354
|
-
}),
|
|
507
|
+
loadQuery: sinon.stub().returns(runnableStub),
|
|
508
|
+
loadRestrictedQuery: sinon.stub().returns(runnableStub),
|
|
355
509
|
};
|
|
356
510
|
const model = new Model(
|
|
357
511
|
packageName,
|
|
@@ -529,6 +683,47 @@ describe("service/model", () => {
|
|
|
529
683
|
|
|
530
684
|
sinon.restore();
|
|
531
685
|
});
|
|
686
|
+
|
|
687
|
+
it("embeds model-level givens in executed cell newSources", async () => {
|
|
688
|
+
const sourceInfo = { name: "carriers", schema: { fields: [] } };
|
|
689
|
+
const givens = [
|
|
690
|
+
{
|
|
691
|
+
name: "region",
|
|
692
|
+
type: "string",
|
|
693
|
+
annotations: ["#(doc) Region"],
|
|
694
|
+
},
|
|
695
|
+
];
|
|
696
|
+
// A source-only code cell (no runnable) still emits newSources.
|
|
697
|
+
const runnableCells = [
|
|
698
|
+
{
|
|
699
|
+
type: "code" as const,
|
|
700
|
+
text: "import 'carriers.malloy'",
|
|
701
|
+
newSources: [sourceInfo],
|
|
702
|
+
},
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
const model = new Model(
|
|
706
|
+
packageName,
|
|
707
|
+
"test.malloynb",
|
|
708
|
+
{},
|
|
709
|
+
"notebook",
|
|
710
|
+
undefined, // modelMaterializer
|
|
711
|
+
undefined, // modelDef
|
|
712
|
+
undefined, // sources
|
|
713
|
+
undefined, // queries
|
|
714
|
+
undefined, // sourceInfos
|
|
715
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
716
|
+
runnableCells as any, // runnableNotebookCells
|
|
717
|
+
undefined, // compilationError
|
|
718
|
+
undefined, // filterMap
|
|
719
|
+
givens, // givens
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const result = await model.executeNotebookCell(0);
|
|
723
|
+
const parsed = JSON.parse(result.newSources![0]);
|
|
724
|
+
expect(parsed.name).toBe("carriers");
|
|
725
|
+
expect(parsed.givens).toEqual(givens);
|
|
726
|
+
});
|
|
532
727
|
});
|
|
533
728
|
});
|
|
534
729
|
|