Skip to content

Commit 4eb6d35

Browse files
Add Malloy Source Compile-Check API Endpoint (#619)
* feat(server): add compile-check API endpoint Add POST /api/v0/projects/:projectName/compile endpoint that accepts Malloy source code, compiles it against the project's connections, and returns success/error status with any compilation problems. - Add compileSource() method to Project class that creates a virtual file, intercepts the URL reader, and compiles via Malloy Runtime - Add CompileController to handle the HTTP request and map compilation results to a status response - Register the new POST route in server.ts New files: packages/server/src/controller/compile.controller.ts Modified files: packages/server/src/service/project.ts packages/server/src/server.ts Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * refactor(server): scope compile endpoint to package/model path Update the compile endpoint route from: POST /api/v0/projects/:projectName/compile to: POST /api/v0/projects/:projectName/packages/:packageName/models/:modelName/compile Place the virtual compile file in the model's directory instead of the project root so that relative imports resolve correctly against sibling model files. Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * docs(api): add compile endpoint to OpenAPI spec Add POST /projects/{projectName}/packages/{packageName}/models/{path}/compile path and CompileRequest, CompileResult, CompileProblem schemas to api-doc.yaml. Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * fix(server): inherit model import context in compile endpoint Extract the model file's preamble (pragmas, imports, comments) and prepend it to the submitted source before compilation. This allows raw queries referencing imported sources (e.g. run: my_source -> {...}) to compile successfully, matching the context available in the query endpoint. - Extract preamble parsing into exported extractPreamble() and extractPreambleFromSource() functions for testability - Add project_compile.spec.ts with 14 tests covering edge cases Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * fix: prettier formatting Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * feat: add includeSql option to compile endpoint Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * feat: add includeSql option to compile endpoint Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> * fix:linting Signed-off-by: Adam Ribaudo <aribaudo@form-function.co> --------- Signed-off-by: Adam Ribaudo <aribaudo@form-function.co>
1 parent 2c3cf4f commit 4eb6d35

File tree

6 files changed

+484
-0
lines changed

6 files changed

+484
-0
lines changed

.vscode/tasks.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"version": "2.0.0",
3+
"tasks": [
4+
{
5+
"label": "build:server-deploy",
6+
"type": "shell",
7+
"command": "bun run build:server-deploy",
8+
"group": {
9+
"kind": "build",
10+
"isDefault": true
11+
},
12+
"problemMatcher": []
13+
}
14+
]
15+
}

api-doc.yaml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,6 +1318,57 @@ paths:
13181318
"503":
13191319
$ref: "#/components/responses/ServiceUnavailable"
13201320

1321+
/projects/{projectName}/packages/{packageName}/models/{path}/compile:
1322+
post:
1323+
tags:
1324+
- models
1325+
operationId: compile-model-source
1326+
summary: Compile Malloy source code
1327+
description: |
1328+
Compiles Malloy source code in the context of a specific model's directory,
1329+
allowing relative imports to resolve correctly against sibling model files.
1330+
Returns compilation status and any problems (errors or warnings) found.
1331+
parameters:
1332+
- name: projectName
1333+
in: path
1334+
description: Name of the project
1335+
required: true
1336+
schema:
1337+
$ref: "#/components/schemas/IdentifierPattern"
1338+
- name: packageName
1339+
in: path
1340+
description: Name of the package
1341+
required: true
1342+
schema:
1343+
type: string
1344+
- name: path
1345+
in: path
1346+
description: Path to the model within the package (used to resolve relative imports)
1347+
required: true
1348+
schema:
1349+
$ref: "#/components/schemas/PathPattern"
1350+
requestBody:
1351+
required: true
1352+
content:
1353+
application/json:
1354+
schema:
1355+
$ref: "#/components/schemas/CompileRequest"
1356+
responses:
1357+
"200":
1358+
description: Compilation result with status and any problems
1359+
content:
1360+
application/json:
1361+
schema:
1362+
$ref: "#/components/schemas/CompileResult"
1363+
"400":
1364+
$ref: "#/components/responses/BadRequest"
1365+
"404":
1366+
$ref: "#/components/responses/NotFound"
1367+
"500":
1368+
$ref: "#/components/responses/InternalServerError"
1369+
"503":
1370+
$ref: "#/components/responses/ServiceUnavailable"
1371+
13211372
/projects/{projectName}/packages/{packageName}/notebooks:
13221373
get:
13231374
tags:
@@ -2390,3 +2441,59 @@ components:
23902441
errorMessage:
23912442
type: string
23922443
description: Error message if the connection test failed, null if successful
2444+
2445+
CompileRequest:
2446+
type: object
2447+
description: Request body for compiling Malloy source code
2448+
properties:
2449+
source:
2450+
type: string
2451+
description: Malloy source code to compile
2452+
includeSql:
2453+
type: boolean
2454+
default: false
2455+
description: If true, returns the generated SQL alongside compilation results (only available when compilation succeeds and the source contains a runnable query).
2456+
required:
2457+
- source
2458+
2459+
CompileResult:
2460+
type: object
2461+
description: Result of a Malloy source compilation check
2462+
properties:
2463+
status:
2464+
type: string
2465+
description: Overall compilation status — "error" if any problems have error severity
2466+
enum: ["success", "error"]
2467+
problems:
2468+
type: array
2469+
description: List of compilation problems (errors and warnings)
2470+
items:
2471+
$ref: "#/components/schemas/CompileProblem"
2472+
sql:
2473+
type: string
2474+
description: Generated SQL for the compiled query. Only present when includeSql is true and compilation succeeds with a runnable query.
2475+
2476+
CompileProblem:
2477+
type: object
2478+
description: A compilation problem reported by the Malloy compiler
2479+
properties:
2480+
message:
2481+
type: string
2482+
description: Human-readable problem description
2483+
severity:
2484+
type: string
2485+
description: Severity level of the problem
2486+
enum: ["error", "warn", "debug"]
2487+
code:
2488+
type: string
2489+
description: Machine-readable error code
2490+
at:
2491+
type: object
2492+
description: Source location of the problem
2493+
properties:
2494+
url:
2495+
type: string
2496+
description: URL of the source file
2497+
range:
2498+
type: object
2499+
description: Character range within the source file
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { LogMessage } from "@malloydata/malloy";
2+
import { ProjectStore } from "../service/project_store";
3+
4+
export class CompileController {
5+
private projectStore: ProjectStore;
6+
7+
constructor(projectStore: ProjectStore) {
8+
this.projectStore = projectStore;
9+
}
10+
11+
public async compile(
12+
projectName: string,
13+
packageName: string,
14+
modelName: string,
15+
source: string,
16+
includeSql: boolean = false,
17+
): Promise<{ status: string; problems: LogMessage[]; sql?: string }> {
18+
const project = await this.projectStore.getProject(projectName, false);
19+
const { problems, sql } = await project.compileSource(
20+
packageName,
21+
modelName,
22+
source,
23+
includeSql,
24+
);
25+
26+
// Determine overall status based on presence of errors
27+
const hasErrors = problems.some((p) => p.severity === "error");
28+
29+
return {
30+
status: hasErrors ? "error" : "success",
31+
problems: problems,
32+
...(sql !== undefined && { sql }),
33+
};
34+
}
35+
}

packages/server/src/server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as http from "http";
1313
import { createProxyMiddleware } from "http-proxy-middleware";
1414
import { AddressInfo } from "net";
1515
import * as path from "path";
16+
import { CompileController } from "./controller/compile.controller";
1617
import { ConnectionController } from "./controller/connection.controller";
1718
import { DatabaseController } from "./controller/database.controller";
1819
import { ModelController } from "./controller/model.controller";
@@ -126,6 +127,7 @@ const modelController = new ModelController(projectStore);
126127
const packageController = new PackageController(projectStore);
127128
const databaseController = new DatabaseController(projectStore);
128129
const queryController = new QueryController(projectStore);
130+
const compileController = new CompileController(projectStore);
129131

130132
export const mcpApp = express();
131133

@@ -917,6 +919,26 @@ app.get(
917919
},
918920
);
919921

922+
app.post(
923+
`${API_PREFIX}/projects/:projectName/packages/:packageName/models/:modelName/compile`,
924+
async (req, res) => {
925+
try {
926+
const result = await compileController.compile(
927+
req.params.projectName,
928+
req.params.packageName,
929+
req.params.modelName,
930+
req.body.source,
931+
req.body.includeSql === true,
932+
);
933+
res.status(200).json(result);
934+
} catch (error) {
935+
logger.error("Compilation error", { error });
936+
const { json, status } = internalErrorToHttpError(error as Error);
937+
res.status(status).json(json);
938+
}
939+
},
940+
);
941+
920942
// Modify the catch-all route to only serve index.html in production
921943
if (!isDevelopment) {
922944
app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));

packages/server/src/service/project.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { LogMessage } from "@malloydata/malloy";
2+
import { FixedConnectionMap, MalloyError, Runtime } from "@malloydata/malloy";
13
import { BaseConnection } from "@malloydata/malloy/connection";
24
import { Mutex } from "async-mutex";
35
import * as fs from "fs";
@@ -10,6 +12,7 @@ import {
1012
ProjectNotFoundError,
1113
} from "../errors";
1214
import { logger } from "../logger";
15+
import { URL_READER } from "../utils";
1316
import { createProjectConnections, InternalConnection } from "./connection";
1417
import { ApiConnection } from "./model";
1518
import { Package } from "./package";
@@ -159,6 +162,71 @@ export class Project {
159162
return this.metadata;
160163
}
161164

165+
public async compileSource(
166+
packageName: string,
167+
modelName: string,
168+
source: string,
169+
includeSql: boolean = false,
170+
): Promise<{ problems: LogMessage[]; sql?: string }> {
171+
// Place the virtual file in the model's directory so relative imports resolve correctly.
172+
const modelDir = path.dirname(
173+
path.join(this.projectPath, packageName, modelName),
174+
);
175+
const virtualUri = `file://${path.join(modelDir, "__compile_check.malloy")}`;
176+
const virtualUrl = new URL(virtualUri);
177+
178+
// Read the model file and extract its preamble (pragmas + imports) so that
179+
// the user's query inherits the model's import context.
180+
const modelPath = path.join(this.projectPath, packageName, modelName);
181+
const preamble = await extractPreamble(modelPath);
182+
const fullSource = preamble ? `${preamble}\n${source}` : source;
183+
184+
// Create a URL Reader that serves the source string for the virtual file,
185+
// but falls back to the disk for everything else (imports).
186+
const interceptingReader = {
187+
readURL: async (url: URL) => {
188+
if (url.toString() === virtualUri) {
189+
return fullSource;
190+
}
191+
return URL_READER.readURL(url);
192+
},
193+
};
194+
195+
// Initialize Runtime with the project's active connections
196+
const runtime = new Runtime({
197+
urlReader: interceptingReader,
198+
connections: new FixedConnectionMap(this.malloyConnections, "duckdb"),
199+
});
200+
201+
// Attempt to compile
202+
try {
203+
const modelMaterializer = runtime.loadModel(virtualUrl);
204+
const model = await modelMaterializer.getModel();
205+
206+
// If includeSql is requested and compilation succeeded, attempt to extract SQL
207+
let sql: string | undefined;
208+
if (includeSql) {
209+
try {
210+
const queryMaterializer = modelMaterializer.loadFinalQuery();
211+
sql = await queryMaterializer.getSQL();
212+
} catch {
213+
// Source may not contain a runnable query (e.g. only source definitions),
214+
// in which case we simply omit the sql field.
215+
}
216+
}
217+
218+
// If successful, return any non-fatal warnings
219+
return { problems: model.problems, sql };
220+
} catch (error) {
221+
// If parsing/compilation fails, return the errors
222+
if (error instanceof MalloyError) {
223+
return { problems: error.problems };
224+
}
225+
// If it's a system error (e.g. file not found), throw it up
226+
throw error;
227+
}
228+
}
229+
162230
public listApiConnections(): ApiConnection[] {
163231
return this.apiConnections;
164232
}
@@ -548,3 +616,43 @@ export class Project {
548616
});
549617
}
550618
}
619+
620+
/**
621+
* Extracts the preamble from a Malloy model file — the leading block of
622+
* `##!` pragmas, `import` statements, blank lines, and comments that appear
623+
* before any `source:`, `query:`, or `run:` definition. This allows a
624+
* submitted query to inherit the model's import context.
625+
*/
626+
export async function extractPreamble(modelPath: string): Promise<string> {
627+
try {
628+
const content = await fs.promises.readFile(modelPath, "utf8");
629+
return extractPreambleFromSource(content);
630+
} catch {
631+
// If the model file can't be read, return empty preamble
632+
// and let the compilation surface any import errors naturally.
633+
return "";
634+
}
635+
}
636+
637+
/**
638+
* Extracts the preamble from Malloy source text. Exported for testing.
639+
*/
640+
export function extractPreambleFromSource(content: string): string {
641+
const lines = content.split("\n");
642+
const preambleLines: string[] = [];
643+
644+
for (const line of lines) {
645+
const trimmed = line.trim();
646+
// Stop at the first source/query/run definition
647+
if (
648+
trimmed.startsWith("source:") ||
649+
trimmed.startsWith("query:") ||
650+
trimmed.startsWith("run:")
651+
) {
652+
break;
653+
}
654+
preambleLines.push(line);
655+
}
656+
657+
return preambleLines.join("\n").trimEnd();
658+
}

0 commit comments

Comments
 (0)