Skip to content

Commit 05ed735

Browse files
authored
Merge pull request #320 from constructive-io/feat/classify-sql-language-bodies
feat(transform): classify references inside LANGUAGE sql function bodies
2 parents df66770 + b164789 commit 05ed735

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

packages/transform/__tests__/facts.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,31 @@ describe('classifyStatements', () => {
8181
expect(facts[0].referencedSchemas).toEqual(expect.arrayContaining(['billing', 'store']));
8282
});
8383

84+
it('extracts references from LANGUAGE sql string bodies', () => {
85+
const facts = classifyStatements(`
86+
CREATE FUNCTION catalog.product_slug() RETURNS text AS $$
87+
SELECT catalog.slugify(name) FROM catalog.products LIMIT 1;
88+
$$ LANGUAGE sql STABLE;
89+
`);
90+
91+
expect(facts).toHaveLength(1);
92+
expect(facts[0].creates).toEqual([{ schema: 'catalog', name: 'product_slug' }]);
93+
// references inside the opaque LANGUAGE sql body are discovered
94+
expect(facts[0].references).toContainEqual({ schema: 'catalog', name: 'products' });
95+
expect(facts[0].references).toContainEqual({ schema: 'catalog', name: 'slugify' });
96+
expect(facts[0].bodyReferences).toContainEqual({ schema: 'catalog', name: 'products' });
97+
// the function does not depend on itself
98+
expect(facts[0].references).not.toContainEqual({ schema: 'catalog', name: 'product_slug' });
99+
});
100+
101+
it('does not treat a C-language function body string as SQL', () => {
102+
const facts = classifyStatements(
103+
`CREATE FUNCTION ext.thing() RETURNS void AS 'MODULE_PATHNAME', 'thing_fn' LANGUAGE c;`
104+
);
105+
expect(facts[0].creates).toEqual([{ schema: 'ext', name: 'thing' }]);
106+
expect(facts[0].references).toEqual([]);
107+
});
108+
84109
it('separates body-only references as late-binding bodyReferences', () => {
85110
const facts = classifyStatements(`
86111
CREATE FUNCTION app_public.quota_gate(org app_types.org_ref) RETURNS boolean

packages/transform/src/facts.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { walk as walkSql } from '@pgsql/traverse';
2-
import { transformSync, walk as walkPlpgsql } from 'plpgsql-parser';
2+
import { parseSql, transformSync, walk as walkPlpgsql } from 'plpgsql-parser';
33

44
/**
55
* A (possibly schema-qualified) object name extracted from a statement.
@@ -321,6 +321,42 @@ function classifyOne(nodeTag: string, node: any): StatementFacts {
321321
return facts;
322322
}
323323

324+
/** Read a `CreateFunctionStmt` DefElem option's scalar/list value. */
325+
function functionOption(node: any, defname: string): any {
326+
for (const opt of node.options ?? []) {
327+
if (opt?.DefElem?.defname === defname) return opt.DefElem.arg;
328+
}
329+
return undefined;
330+
}
331+
332+
/**
333+
* Collect references from a `LANGUAGE sql` function body supplied as a string
334+
* literal (`AS $$ ... $$`). That body is an opaque String node the AST walker
335+
* never parses, so — mirroring the schema transformer's body rewrite — parse
336+
* it standalone and walk each statement with the facts visitor. The standard
337+
* `BEGIN ATOMIC` / `RETURN` `sql_body` form is already part of the AST and is
338+
* covered by the outer walk, so only the string form needs this.
339+
*/
340+
function collectSqlBodyReferences(node: any, facts: StatementFacts): void {
341+
const language = functionOption(node, 'language')?.String?.sval;
342+
if (typeof language !== 'string' || language.toLowerCase() !== 'sql') return;
343+
344+
const asArg = functionOption(node, 'as');
345+
const items: any[] = asArg?.List?.items ?? [];
346+
const body = items[0]?.String?.sval;
347+
if (typeof body !== 'string') return;
348+
349+
try {
350+
const stmts: any[] = parseSql(body)?.stmts ?? [];
351+
const visitor = createFactsVisitor(facts, facts.bodyReferences);
352+
for (const stmt of stmts) {
353+
if (stmt?.stmt) walkSql(stmt.stmt, visitor);
354+
}
355+
} catch {
356+
// A non-parseable body (C symbol name, etc.) contributes no references.
357+
}
358+
}
359+
324360
/**
325361
* Classify each top-level statement in a SQL script into {@link StatementFacts}.
326362
*
@@ -342,6 +378,9 @@ export function classifyStatements(sql: string): StatementFacts[] {
342378
if (stmtNode) {
343379
walkSql(stmtNode, createFactsVisitor(facts));
344380
}
381+
if (nodeTag === 'CreateFunctionStmt') {
382+
collectSqlBodyReferences(node, facts);
383+
}
345384
allFacts.push(facts);
346385
}
347386

0 commit comments

Comments
 (0)