Skip to main content

code_moniker_core/lang/sql/
mod.rs

1mod body;
2mod canonicalize;
3mod kinds;
4mod plpgsql_grammar;
5mod sdk_pipeline;
6
7use tree_sitter::Tree;
8
9use crate::core::code_graph::CodeGraph;
10use crate::core::moniker::Moniker;
11use crate::core::shape::Shape;
12
13use crate::lang::{ExtractionContext, KindSpec, LangExtractor, ParsedDocument};
14
15#[derive(Clone, Debug, Default)]
16pub struct Presets {
17	pub external_schemas: Vec<String>,
18}
19
20pub fn parse(source: &str) -> Tree {
21	sdk_pipeline::discover::parse(source)
22}
23
24pub fn parse_plpgsql(source: &str) -> ParsedDocument {
25	ParsedDocument::new(body::parse_plpgsql(source))
26}
27
28pub fn extract(
29	uri: &str,
30	source: &str,
31	anchor: &Moniker,
32	deep: bool,
33	presets: &Presets,
34) -> CodeGraph {
35	<Lang as LangExtractor>::extract(uri, source, anchor, deep, presets)
36}
37
38pub struct Lang;
39
40const DEF_KINDS: &[&str] = &[
41	"function",
42	"procedure",
43	"view",
44	"table",
45	"column",
46	"constraint",
47	"trigger",
48	"type",
49	"schema",
50];
51
52const DEF_KIND_SPECS: &[KindSpec] = &[
53	KindSpec::new("schema", Shape::Namespace, 10, "schema"),
54	KindSpec::new("table", Shape::Type, 20, "table"),
55	KindSpec::new("view", Shape::Type, 21, "view"),
56	KindSpec::new("type", Shape::Type, 22, "type"),
57	KindSpec::new("column", Shape::Value, 30, "column"),
58	KindSpec::new("constraint", Shape::Value, 31, "constraint"),
59	KindSpec::new("trigger", Shape::Value, 32, "trigger"),
60	KindSpec::new("function", Shape::Callable, 40, "function"),
61	KindSpec::new("procedure", Shape::Callable, 41, "procedure"),
62];
63
64impl crate::lang::LangExtractor for Lang {
65	type Presets = Presets;
66	const LANG_TAG: &'static str = "sql";
67	const ALLOWED_KINDS: &'static [&'static str] = DEF_KINDS;
68	const KIND_SPECS: &'static [KindSpec] = DEF_KIND_SPECS;
69	const ALLOWED_VISIBILITIES: &'static [&'static str] = &[];
70
71	fn parse(_uri: &str, source: &str) -> ParsedDocument {
72		body::parse_document(parse(source), source)
73	}
74
75	fn file_root(uri: &str, anchor: &Moniker) -> Option<Moniker> {
76		Some(canonicalize::compute_module_moniker(anchor, uri))
77	}
78
79	fn extract_parsed(
80		context: ExtractionContext<'_, Self::Presets>,
81		document: &ParsedDocument,
82	) -> CodeGraph {
83		sdk_pipeline::extract(
84			context.uri,
85			context.source,
86			document,
87			context.anchor,
88			context.deep,
89			context.presets,
90		)
91	}
92}
93
94#[cfg(test)]
95mod tests {
96	use super::*;
97	use crate::core::moniker::MonikerBuilder;
98
99	fn anchor() -> Moniker {
100		MonikerBuilder::new().project(b"app").build()
101	}
102
103	fn run(uri: &str, src: &str) -> CodeGraph {
104		extract(uri, src, &anchor(), false, &Presets::default())
105	}
106
107	fn def_monikers(g: &CodeGraph) -> Vec<String> {
108		g.defs()
109			.map(|d| crate::core::uri::to_uri(&d.moniker, &Default::default()))
110			.collect()
111	}
112
113	fn ref_targets(g: &CodeGraph) -> Vec<String> {
114		g.refs()
115			.map(|r| crate::core::uri::to_uri(&r.target, &Default::default()))
116			.collect()
117	}
118
119	fn relation_edges(g: &CodeGraph, kind: &[u8]) -> Vec<(String, String)> {
120		g.refs()
121			.filter(|reference| reference.kind == kind)
122			.map(|reference| {
123				(
124					crate::core::uri::to_uri(
125						&g.def_at(reference.source).moniker,
126						&Default::default(),
127					),
128					crate::core::uri::to_uri(&reference.target, &Default::default()),
129				)
130			})
131			.collect()
132	}
133
134	#[test]
135	fn parsed_document_keeps_plpgsql_as_shared_syntax() {
136		let source = "CREATE FUNCTION account_balance(p_id bigint) RETURNS numeric \
137			LANGUAGE plpgsql AS $$\n\
138			DECLARE total numeric;\n\
139			BEGIN\n\
140			  SELECT sum(amount) INTO total FROM ledger_entry WHERE account_id = p_id;\n\
141			  IF total IS NULL THEN RETURN 0; END IF;\n\
142			  RETURN total;\n\
143			END;\n\
144			$$;";
145		let document = <Lang as crate::lang::LangExtractor>::parse("account.sql", source);
146		assert_eq!(document.primary().root_node().kind(), "source_file");
147		let injection = document
148			.injections()
149			.iter()
150			.find(|injection| injection.language() == "plpgsql")
151			.expect("PL/pgSQL injection");
152		assert_eq!(injection.tree().root_node().kind(), "source_file");
153		assert!(!injection.tree().root_node().has_error());
154
155		let sql_source = "CREATE FUNCTION recent_accounts() RETURNS SETOF account LANGUAGE sql AS \
156			 $$ SELECT * FROM account ORDER BY created_at DESC $$;";
157		let sql_document = <Lang as crate::lang::LangExtractor>::parse("account.sql", sql_source);
158		let sql_injection = sql_document
159			.injections()
160			.iter()
161			.find(|injection| injection.language() == "sql")
162			.expect("SQL-language injection");
163		assert_eq!(sql_injection.tree().root_node().kind(), "source_file");
164		assert!(!sql_injection.tree().root_node().has_error());
165	}
166
167	#[test]
168	fn dollar_quoted_parameter_default_is_distinct_from_plpgsql_body() {
169		let source = "CREATE FUNCTION app.with_default(value text DEFAULT $$fallback$$)\n\
170			RETURNS text\n\
171			LANGUAGE plpgsql\n\
172			AS $body$ BEGIN RETURN value; END; $body$;";
173		let document = <Lang as crate::lang::LangExtractor>::parse("default.sql", source);
174		let root = document.primary().root_node();
175		assert!(!root.has_error(), "{}", root.to_sexp());
176
177		let function = crate::lang::tree_util::find_descendant(root, "CreateFunctionStmt")
178			.expect("function declaration");
179		let parameter = crate::lang::tree_util::find_descendant(function, "func_arg_with_default")
180			.expect("parameter with default");
181		let default = crate::lang::tree_util::find_descendant(parameter, "dollar_quoted_string")
182			.expect("dollar-quoted default expression");
183		assert_eq!(
184			&source[default.start_byte()..default.end_byte()],
185			"$$fallback$$"
186		);
187
188		let injection = document
189			.injections()
190			.iter()
191			.find(|injection| injection.language() == "plpgsql")
192			.expect("PL/pgSQL body injection");
193		let host_range = injection.host_byte_range();
194		assert_eq!(
195			&source[host_range],
196			"$body$ BEGIN RETURN value; END; $body$"
197		);
198		let content_range = injection.content_byte_range();
199		assert_eq!(&source[content_range], " BEGIN RETURN value; END; ");
200		assert!(!injection.tree().root_node().has_error());
201	}
202
203	#[test]
204	fn multiple_routines_keep_their_own_dollar_quoted_bodies() {
205		let source = "CREATE FUNCTION app.first(value text DEFAULT $$fallback$$) RETURNS text \
206			LANGUAGE plpgsql AS $first$ BEGIN RETURN value; END; $first$;\n\
207			CREATE FUNCTION app.second(value text DEFAULT $default$other$default$) RETURNS text \
208			LANGUAGE plpgsql AS $second$ BEGIN RETURN value; END; $second$;";
209		let document = <Lang as crate::lang::LangExtractor>::parse("routines.sql", source);
210		assert!(!document.primary().root_node().has_error());
211		let bodies = document
212			.injections()
213			.iter()
214			.map(|injection| &source[injection.host_byte_range()])
215			.collect::<Vec<_>>();
216		assert_eq!(
217			bodies,
218			[
219				"$first$ BEGIN RETURN value; END; $first$",
220				"$second$ BEGIN RETURN value; END; $second$",
221			]
222		);
223	}
224
225	#[test]
226	fn qualified_function_emits_full_signature() {
227		let g = run(
228			"foo.sql",
229			"CREATE FUNCTION public.bar(a int, b text) RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;",
230		);
231		assert!(
232			def_monikers(&g).iter().any(|m| m
233				== "code+moniker://app/lang:sql/module:foo/schema:public/function:bar(a:int4,b:text)"),
234			"got defs: {:?}",
235			def_monikers(&g)
236		);
237		let func = g
238			.defs()
239			.find(|d| d.kind == b"function")
240			.expect("function def");
241		assert_eq!(func.signature, b"a:int4,b:text");
242	}
243
244	#[test]
245	fn overloads_with_different_types_both_land() {
246		let g = run(
247			"foo.sql",
248			"CREATE FUNCTION m(x int) RETURNS int LANGUAGE sql AS $$ SELECT x $$;\
249			 CREATE FUNCTION m(x text) RETURNS text LANGUAGE sql AS $$ SELECT x $$;",
250		);
251		assert_eq!(g.defs().filter(|d| d.kind == b"function").count(), 2);
252	}
253
254	#[test]
255	fn top_level_select_emits_qualified_call() {
256		let g = run("foo.sql", "SELECT public.bar(1, 2);");
257		assert!(
258			ref_targets(&g).iter().any(|t| t
259				== "code+moniker://app/lang:sql/module:foo/schema:public/function:bar(int4,int4)"),
260			"got refs: {:?}",
261			ref_targets(&g)
262		);
263	}
264
265	#[test]
266	fn empty_source_yields_only_module_root() {
267		let g = run("db/functions/plan/create_plan.sql", "");
268		let defs: Vec<_> = g.defs().collect();
269		assert_eq!(defs.len(), 1);
270		assert_eq!(
271			crate::core::uri::to_uri(&defs[0].moniker, &Default::default()),
272			"code+moniker://app/lang:sql/dir:db/dir:functions/dir:plan/module:create_plan"
273		);
274	}
275
276	#[test]
277	fn nested_calls_preserve_unknown_argument_slots() {
278		let g = run("foo.sql", "SELECT f(g(a, b));");
279		assert!(
280			ref_targets(&g)
281				.iter()
282				.any(|t| t == "code+moniker://app/lang:sql/module:foo/function:f(_)"),
283			"outer call f should preserve one unknown slot, got refs: {:?}",
284			ref_targets(&g)
285		);
286		assert!(
287			ref_targets(&g)
288				.iter()
289				.any(|t| t == "code+moniker://app/lang:sql/module:foo/function:g(_,_)"),
290			"inner call g should preserve two unknown slots, got refs: {:?}",
291			ref_targets(&g)
292		);
293	}
294
295	#[test]
296	fn call_argument_types_come_from_casts_literals_and_parameters() {
297		let g = run(
298			"foo.sql",
299			"CREATE FUNCTION public.wrapper(p_id uuid, p_enabled bool) RETURNS int LANGUAGE sql AS $$ SELECT public.choose(p_id, 42::bigint, p_enabled, 'x'::text, NULL) $$;",
300		);
301		assert!(
302			ref_targets(&g).iter().any(|target| target
303				== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(uuid,int8,bool,text,_)") ,
304			"typed target missing from {:?}",
305			ref_targets(&g)
306		);
307	}
308
309	#[test]
310	fn casts_inside_expressions_do_not_type_the_whole_argument() {
311		let g = run(
312			"foo.sql",
313			"CREATE FUNCTION public.wrapper(p_id int) RETURNS int LANGUAGE sql AS $$ SELECT public.choose(p_id = 1::int, (p_id = 1)::text) $$;",
314		);
315		assert!(
316			ref_targets(&g).iter().any(|target| target
317				== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(_,text)"),
318			"an inner cast must not type its enclosing expression: {:?}",
319			ref_targets(&g)
320		);
321	}
322
323	#[test]
324	fn named_call_arguments_preserve_names_and_types() {
325		let g = run(
326			"foo.sql",
327			"CREATE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql AS $$ SELECT public.choose(label => 'x'::text, value => p_id) $$;",
328		);
329		assert!(
330			ref_targets(&g).iter().any(|target| target
331				== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(label:text,value:uuid)"),
332			"named typed target missing from {:?}",
333			ref_targets(&g)
334		);
335	}
336
337	#[test]
338	fn unquoted_parameter_names_are_canonical_in_definitions_and_calls() {
339		let g = run(
340			"foo.sql",
341			"CREATE FUNCTION public.choose(Value int) RETURNS int LANGUAGE sql AS $$ SELECT Value $$; SELECT public.choose(value => 1::int);",
342		);
343		assert!(def_monikers(&g).iter().any(|definition| definition
344			== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(value:int4)"));
345		assert!(ref_targets(&g).iter().any(|target| target
346			== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(value:int4)"));
347	}
348
349	#[test]
350	fn static_function_search_path_qualifies_unqualified_calls() {
351		let g = run(
352			"foo.sql",
353			"CREATE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql SET search_path = jobs, pg_temp AS $$ SELECT refresh(p_id) $$;",
354		);
355		assert!(
356			ref_targets(&g).iter().any(|target| target
357				== "code+moniker://app/lang:sql/module:foo/schema:jobs/function:refresh(uuid)"),
358			"search-path-qualified target missing from {:?}",
359			ref_targets(&g)
360		);
361	}
362
363	#[test]
364	fn dynamic_or_catalog_search_path_does_not_claim_one_schema() {
365		let g = run(
366			"foo.sql",
367			"CREATE FUNCTION public.from_role(p_id uuid) RETURNS int LANGUAGE sql SET search_path = \"$user\", jobs, pg_temp AS $$ SELECT refresh(p_id) $$; CREATE FUNCTION public.from_catalog(p_id uuid) RETURNS int LANGUAGE sql SET search_path = pg_catalog, jobs, pg_temp AS $$ SELECT refresh(p_id) $$;",
368		);
369		let refresh_targets = ref_targets(&g)
370			.into_iter()
371			.filter(|target| target.contains("function:refresh"))
372			.collect::<Vec<_>>();
373		assert_eq!(refresh_targets.len(), 2, "got {refresh_targets:?}");
374		assert!(
375			refresh_targets
376				.iter()
377				.all(|target| !target.contains("schema:jobs")),
378			"dynamic/catalog search paths must stay unqualified: {refresh_targets:?}"
379		);
380	}
381
382	#[test]
383	fn duplicate_routine_keeps_the_first_search_path_with_the_first_body() {
384		let g = run(
385			"foo.sql",
386			"CREATE OR REPLACE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql SET search_path = first_schema, pg_temp AS $$ SELECT refresh(p_id) $$; CREATE OR REPLACE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql SET search_path = second_schema, pg_temp AS $$ SELECT refresh(p_id) $$;",
387		);
388		let refresh_targets = ref_targets(&g)
389			.into_iter()
390			.filter(|target| target.contains("function:refresh"))
391			.collect::<Vec<_>>();
392		assert_eq!(refresh_targets.len(), 1, "got {refresh_targets:?}");
393		assert!(refresh_targets[0].contains("schema:first_schema"));
394	}
395
396	#[test]
397	fn duplicate_routine_keeps_the_last_callable_arity_metadata() {
398		let g = run(
399			"foo.sql",
400			"CREATE OR REPLACE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql AS $$ SELECT 1 $$; CREATE OR REPLACE FUNCTION public.wrapper(p_id uuid DEFAULT NULL) RETURNS int LANGUAGE sql AS $$ SELECT 2 $$;",
401		);
402		let wrapper = g
403			.defs()
404			.find(|definition| definition.call_name == b"wrapper")
405			.expect("wrapper definition");
406		assert_eq!(wrapper.call_arity, Some(0));
407	}
408
409	#[test]
410	fn ddl_in_sql_body_does_not_emit_a_definition_with_an_invalid_parent() {
411		let g = run(
412			"foo.sql",
413			"CREATE FUNCTION public.wrapper() RETURNS void LANGUAGE sql AS $$ CREATE TABLE public.inner_table(id int); $$;",
414		);
415		assert_eq!(
416			g.defs()
417				.filter(|definition| definition.kind == b"function")
418				.count(),
419			1
420		);
421		assert_eq!(
422			g.defs()
423				.filter(|definition| definition.kind == b"table")
424				.count(),
425			0
426		);
427	}
428
429	#[test]
430	fn parser_recovery_keywords_do_not_become_calls() {
431		let g = run(
432			"scratch.sql",
433			"SELECT * FROM (SELECT id FROM things WHERE id =) broken; SELECT DISTINCT id FROM things;",
434		);
435		let names = g
436			.refs()
437			.filter(|reference| reference.kind == b"calls")
438			.map(|reference| String::from_utf8_lossy(&reference.call_name).into_owned())
439			.collect::<Vec<_>>();
440		assert!(
441			names
442				.iter()
443				.all(|name| !matches!(name.as_str(), "from" | "distinct" | "as" | "is" | "any")),
444			"parser recovery emitted SQL keywords as calls: {names:?}"
445		);
446	}
447
448	#[test]
449	fn comment_def_bytes_are_a_real_comment_in_outer_source() {
450		let src = r#"CREATE OR REPLACE FUNCTION foo.bar(
451  p_a uuid,
452  p_b text
453)
454RETURNS void
455LANGUAGE plpgsql
456SECURITY DEFINER
457SET search_path = foo, pg_temp
458AS $$
459DECLARE
460  v_x text;
461BEGIN
462  -- real comment, do not lose
463  v_x := 'hello';
464END;
465$$;
466"#;
467		let g = run("fixture.sql", src);
468		for d in g.defs().filter(|d| d.kind == b"comment") {
469			let (s, e) = d.position.expect("comment def must have a position");
470			let slice = &src.as_bytes()[s as usize..e as usize];
471			assert!(
472				slice.starts_with(b"--") || slice.starts_with(b"/*"),
473				"comment def bytes {s}..{e} are not a real comment: {:?}",
474				std::str::from_utf8(slice).unwrap_or("?")
475			);
476		}
477	}
478
479	#[test]
480	fn function_param_emits_uses_type_with_pg_catalog_target() {
481		let g = run(
482			"pkg.sql",
483			"CREATE FUNCTION f(x int, y text) RETURNS bigint LANGUAGE sql AS $$ SELECT 1 $$;",
484		);
485		let int_target = "code+moniker://app/sdk:sql/path:pg_catalog/path:int4";
486		let text_target = "code+moniker://app/sdk:sql/path:pg_catalog/path:text";
487		let bigint_target = "code+moniker://app/sdk:sql/path:pg_catalog/path:int8";
488		let targets = ref_targets(&g);
489		assert!(
490			targets.iter().any(|t| t == int_target),
491			"int param must emit uses_type → pg_catalog/path:int4, got: {targets:?}"
492		);
493		assert!(
494			targets.iter().any(|t| t == text_target),
495			"text param must emit uses_type → pg_catalog/path:text"
496		);
497		assert!(
498			targets.iter().any(|t| t == bigint_target),
499			"bigint return must emit uses_type → pg_catalog/path:int8"
500		);
501		let uses_type_count = g.refs().filter(|r| r.kind == b"uses_type").count();
502		assert!(
503			uses_type_count >= 3,
504			"expected at least 3 uses_type refs (2 params + 1 return), got {uses_type_count}"
505		);
506	}
507
508	#[test]
509	fn user_defined_types_are_definitions_and_qualified_type_targets() {
510		let g = run(
511			"types.sql",
512			"CREATE TYPE app.order_state AS ENUM ('new', 'done'); CREATE DOMAIN app.order_code AS text; CREATE FUNCTION app.accept(value app.order_state) RETURNS app.order_code LANGUAGE sql AS $$ SELECT value::text $$;",
513		);
514		let definitions = def_monikers(&g);
515		assert!(definitions.iter().any(|definition| definition
516			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_state"));
517		assert!(definitions.iter().any(|definition| definition
518			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_code"));
519		let targets = ref_targets(&g);
520		assert!(targets.iter().any(|target| target
521			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_state"));
522		assert!(targets.iter().any(|target| target
523			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_code"));
524	}
525
526	#[test]
527	fn quoted_user_type_targets_fold_only_unquoted_identifiers() {
528		let g = run(
529			"quoted_types.sql",
530			r#"
531CREATE TYPE Sales."OrderRow" AS (id uuid);
532CREATE FUNCTION Sales.load_orders()
533RETURNS SETOF Sales."OrderRow"
534LANGUAGE sql
535AS $$ SELECT NULL::Sales."OrderRow" $$;
536"#,
537		);
538		let targets = ref_targets(&g);
539		assert!(
540			targets.iter().any(|target| target
541				== "code+moniker://app/lang:sql/module:quoted_types/schema:sales/type:OrderRow"),
542			"quoted components preserve case while unquoted components fold: {targets:?}"
543		);
544		assert!(
545			targets
546				.iter()
547				.all(|target| !target.contains("schema:`SETOF Sales`")
548					&& !target.contains("type:%22OrderRow%22"))
549		);
550	}
551
552	#[test]
553	fn builtin_function_call_carries_external_confidence() {
554		let g = run("pkg.sql", "SELECT now();");
555		let r = g
556			.refs()
557			.find(|r| r.kind == b"calls")
558			.expect("calls ref for now()");
559		assert_eq!(
560			r.confidence,
561			b"external".to_vec(),
562			"builtin functions like now() must be marked external, got {:?}",
563			std::str::from_utf8(&r.confidence).unwrap_or("?")
564		);
565	}
566
567	#[test]
568	fn corpus_builtin_families_are_external() {
569		let g = run(
570			"pkg.sql",
571			"SELECT chr(65), json_build_object('id', 1), array_append(ARRAY[1], 2), row_number() OVER (), gen_random_uuid(), plainto_tsquery('code moniker'), quote_ident('table'), pg_tablespace_location(1), txid_current(), jsonb_array_length('[]'), array_upper(ARRAY[1], 1), ts_rank_cd(to_tsvector('code'), plainto_tsquery('code')), inet_client_addr(), split_part('a.b', '.', 1);",
572		);
573		let calls = g
574			.refs()
575			.filter(|reference| reference.kind == b"calls")
576			.collect::<Vec<_>>();
577		assert_eq!(calls.len(), 16, "got {calls:?}");
578		assert!(
579			calls
580				.iter()
581				.all(|reference| reference.confidence == b"external")
582		);
583	}
584
585	#[test]
586	fn callable_metadata_uses_sql_argument_nodes() {
587		let g = run(
588			"foo.sql",
589			"CREATE FUNCTION Public.Combine(a int, b numeric(10, 2)) RETURNS int LANGUAGE sql AS $$ SELECT 1 $$; SELECT PUBLIC.combine(1, 2); SELECT nested(2, 3);",
590		);
591		let definition = g
592			.defs()
593			.find(|def| def.kind == b"function" && def.call_name == b"combine")
594			.expect("combine definition");
595		assert_eq!(definition.call_arity, Some(2));
596		let combine = g
597			.refs()
598			.find(|reference| reference.call_name == b"combine")
599			.expect("combine call");
600		let nested = g
601			.refs()
602			.find(|reference| reference.call_name == b"nested")
603			.expect("nested call");
604		assert_eq!(combine.call_arity, Some(2));
605		assert_eq!(nested.call_arity, Some(2));
606	}
607
608	#[test]
609	fn callable_metadata_models_defaults_and_out_parameters() {
610		let g = run(
611			"foo.sql",
612			"CREATE FUNCTION optional_arg(value int DEFAULT 1) RETURNS int LANGUAGE sql AS $$ SELECT value $$; CREATE FUNCTION parse_type(value text, OUT type_id oid, OUT modifier int) RETURNS record LANGUAGE sql AS $$ SELECT 1, 2 $$;",
613		);
614		let optional = g
615			.defs()
616			.find(|def| def.call_name == b"optional_arg")
617			.expect("optional_arg definition");
618		let parse_type = g
619			.defs()
620			.find(|def| def.call_name == b"parse_type")
621			.expect("parse_type definition");
622		assert_eq!(optional.call_arity, Some(0));
623		assert_eq!(parse_type.call_arity, Some(1));
624	}
625
626	#[test]
627	fn variadic_parameters_are_explicit_in_callable_identity() {
628		let g = run(
629			"foo.sql",
630			"CREATE FUNCTION concat_all(VARIADIC items text[]) RETURNS text LANGUAGE sql AS $$ SELECT '' $$;",
631		);
632		assert!(
633			def_monikers(&g).iter().any(|definition| definition
634				== "code+moniker://app/lang:sql/module:foo/function:concat_all(items:text[]...)"),
635			"got defs: {:?}",
636			def_monikers(&g)
637		);
638	}
639
640	#[test]
641	fn uppercase_builtin_types_and_catalog_calls_are_external() {
642		let g = run(
643			"foo.sql",
644			"CREATE FUNCTION answer() RETURNS NUMERIC LANGUAGE sql AS $$ SELECT 1 $$; SELECT pg_catalog.current_setting('search_path');",
645		);
646		let type_ref = g
647			.refs()
648			.find(|reference| reference.kind == b"uses_type")
649			.expect("return type");
650		assert_eq!(type_ref.confidence, b"external");
651		let catalog_call = g
652			.refs()
653			.find(|reference| reference.kind == b"calls")
654			.expect("catalog call");
655		assert_eq!(catalog_call.confidence, b"external");
656		assert_eq!(
657			crate::core::uri::to_uri(&catalog_call.target, &Default::default()),
658			"code+moniker://app/sdk:sql/path:pg_catalog/path:current_setting"
659		);
660	}
661
662	#[test]
663	fn create_procedure_emits_procedure_callable() {
664		let src = "CREATE PROCEDURE refresh(value int) LANGUAGE sql AS $$ SELECT 1 $$;";
665		let g = run("foo.sql", src);
666		let procedure = g
667			.defs()
668			.find(|def| def.kind == b"procedure")
669			.expect("procedure definition");
670		assert_eq!(procedure.call_name, b"refresh");
671		assert_eq!(procedure.call_arity, Some(1));
672	}
673
674	#[test]
675	fn call_statements_target_procedures_and_quoted_names_are_canonical() {
676		let g = run(
677			"foo.sql",
678			"CREATE PROCEDURE public.\"RefreshCache\"() LANGUAGE sql AS $$ SELECT 1 $$; CALL public.\"RefreshCache\"(); SELECT PUBLIC.REFRESHCACHE();",
679		);
680		let procedure_call = g
681			.refs()
682			.find(|reference| reference.call_name == b"RefreshCache")
683			.expect("quoted procedure call");
684		assert_eq!(
685			procedure_call
686				.target
687				.as_view()
688				.segments()
689				.last()
690				.unwrap()
691				.kind,
692			b"procedure"
693		);
694		let unquoted_call = g
695			.refs()
696			.find(|reference| reference.call_name == b"refreshcache")
697			.expect("unquoted function call");
698		assert_eq!(
699			unquoted_call
700				.target
701				.as_view()
702				.segments()
703				.last()
704				.unwrap()
705				.kind,
706			b"function"
707		);
708	}
709
710	#[test]
711	fn relational_ddl_emits_schemas_columns_constraints_and_column_types() {
712		let g = run(
713			"relational.sql",
714			r#"
715CREATE SCHEMA IF NOT EXISTS Sales;
716CREATE TABLE Sales."Orders" (
717  "ID" uuid CONSTRAINT "Orders_PK" PRIMARY KEY,
718  customer_id uuid NOT NULL,
719  CONSTRAINT orders_customer_fk
720    FOREIGN KEY (customer_id) REFERENCES crm.customers(id)
721);
722"#,
723		);
724		let definitions = def_monikers(&g);
725		assert!(
726			definitions.iter().any(|definition| definition
727				== "code+moniker://app/lang:sql/module:relational/schema:sales")
728		);
729		assert!(definitions.iter().any(|definition| definition
730			== "code+moniker://app/lang:sql/module:relational/schema:sales/table:Orders/column:ID"));
731		assert!(definitions.iter().any(|definition| definition
732			== "code+moniker://app/lang:sql/module:relational/schema:sales/table:Orders/column:customer_id"));
733		assert!(definitions.iter().any(|definition| definition
734			== "code+moniker://app/lang:sql/module:relational/schema:sales/table:Orders/constraint:Orders_PK"));
735		assert!(definitions.iter().any(|definition| definition
736			== "code+moniker://app/lang:sql/module:relational/schema:sales/table:Orders/constraint:orders_customer_fk"));
737		assert_eq!(
738			g.defs()
739				.filter(|definition| definition.kind == b"constraint")
740				.count(),
741			3,
742			"named and anonymous column/table constraints must all be definitions"
743		);
744		let customer = g
745			.defs()
746			.find(|definition| {
747				definition.kind == b"column"
748					&& definition
749						.moniker
750						.as_view()
751						.segments()
752						.last()
753						.is_some_and(|segment| segment.name == b"customer_id")
754			})
755			.expect("customer_id column");
756		assert_eq!(customer.signature, b"uuid");
757		let uses_type = relation_edges(&g, b"uses_type");
758		assert!(uses_type.iter().any(|(source, target)| {
759			source.ends_with("/table:Orders/column:customer_id")
760				&& target == "code+moniker://app/sdk:sql/path:pg_catalog/path:uuid"
761		}));
762		assert!(
763			g.defs()
764				.filter(|definition| matches!(definition.kind.as_ref(), b"column" | b"constraint"))
765				.all(|definition| definition.position.is_some())
766		);
767	}
768
769	#[test]
770	fn foreign_keys_reference_the_target_table_and_available_columns() {
771		let g = run(
772			"foreign_keys.sql",
773			r#"
774CREATE TABLE sales.orders (
775  customer_id uuid,
776  CONSTRAINT orders_customer_fk
777    FOREIGN KEY (customer_id) REFERENCES crm.customers(id)
778);
779"#,
780		);
781		let edges = relation_edges(&g, b"references");
782		let source = "code+moniker://app/lang:sql/module:foreign_keys/schema:sales/table:orders/constraint:orders_customer_fk";
783		assert!(edges.iter().any(|(from, to)| {
784			from == source
785				&& to
786					== "code+moniker://app/lang:sql/module:foreign_keys/schema:crm/table:customers"
787		}));
788		assert!(edges.iter().any(|(from, to)| {
789			from == source
790				&& to
791					== "code+moniker://app/lang:sql/module:foreign_keys/schema:crm/table:customers/column:id"
792		}));
793		assert!(
794			g.refs()
795				.filter(|reference| reference.kind == b"references")
796				.all(|reference| reference.position.is_some())
797		);
798	}
799
800	#[test]
801	fn triggers_reference_relations_and_call_zero_argument_trigger_functions() {
802		let g = run(
803			"triggers.sql",
804			r#"
805CREATE TRIGGER "AuditOrders"
806AFTER INSERT OR UPDATE ON Sales."Orders"
807FOR EACH ROW EXECUTE FUNCTION audit.log_order('orders');
808
809CREATE CONSTRAINT TRIGGER orders_customer_check
810AFTER UPDATE ON Sales."Orders"
811FROM crm.customers
812DEFERRABLE INITIALLY DEFERRED
813FOR EACH ROW EXECUTE PROCEDURE Sales.check_customer();
814
815CREATE TRIGGER route_order_update
816INSTEAD OF UPDATE ON Sales.open_orders
817FOR EACH ROW EXECUTE FUNCTION Sales.route_order_update();
818"#,
819		);
820		let definitions = def_monikers(&g);
821		let audit_trigger = "code+moniker://app/lang:sql/module:triggers/schema:sales/table:Orders/trigger:AuditOrders";
822		let constraint_trigger = "code+moniker://app/lang:sql/module:triggers/schema:sales/table:Orders/trigger:orders_customer_check";
823		let view_trigger = "code+moniker://app/lang:sql/module:triggers/schema:sales/view:open_orders/trigger:route_order_update";
824		assert!(
825			definitions
826				.iter()
827				.any(|definition| definition == audit_trigger)
828		);
829		assert!(
830			definitions
831				.iter()
832				.any(|definition| definition == constraint_trigger)
833		);
834		assert!(
835			definitions
836				.iter()
837				.any(|definition| definition == view_trigger)
838		);
839
840		let references = relation_edges(&g, b"references");
841		assert!(references.iter().any(|(source, target)| {
842			source == audit_trigger
843				&& target == "code+moniker://app/lang:sql/module:triggers/schema:sales/table:Orders"
844		}));
845		assert!(references.iter().any(|(source, target)| {
846			source == constraint_trigger
847				&& target
848					== "code+moniker://app/lang:sql/module:triggers/schema:crm/table:customers"
849		}));
850		assert!(references.iter().any(|(source, target)| {
851			source == view_trigger
852				&& target
853					== "code+moniker://app/lang:sql/module:triggers/schema:sales/view:open_orders"
854		}));
855
856		let calls = relation_edges(&g, b"calls");
857		assert!(calls.iter().any(|(source, target)| {
858			source == audit_trigger
859				&& target
860					== "code+moniker://app/lang:sql/module:triggers/schema:audit/function:log_order()"
861		}));
862		assert!(calls.iter().any(|(source, target)| {
863			source == constraint_trigger
864				&& target
865					== "code+moniker://app/lang:sql/module:triggers/schema:sales/function:check_customer()"
866		}));
867		assert!(calls.iter().any(|(source, target)| {
868			source == view_trigger
869				&& target
870					== "code+moniker://app/lang:sql/module:triggers/schema:sales/function:route_order_update()"
871		}));
872		assert!(
873			g.refs()
874				.filter(|reference| {
875					reference.kind == b"calls"
876						&& (reference.call_name == b"log_order"
877							|| reference.call_name == b"check_customer"
878							|| reference.call_name == b"route_order_update")
879				})
880				.all(|reference| reference.call_arity == Some(0)),
881			"trigger arguments populate TG_ARGV and do not change the trigger function signature"
882		);
883	}
884
885	#[test]
886	fn views_and_routines_read_physical_relations_but_not_ctes_or_aliases() {
887		let g = run(
888			"queries.sql",
889			r#"
890CREATE VIEW sales.open_orders AS
891WITH recent AS (
892  SELECT * FROM sales.orders
893)
894SELECT r.id
895FROM recent r
896JOIN crm.customers c ON c.id = r.customer_id;
897
898CREATE FUNCTION sales.customer_orders()
899RETURNS SETOF sales.orders
900LANGUAGE sql
901SET search_path = sales, pg_temp
902AS $$ SELECT * FROM orders o JOIN crm.customers c ON c.id = o.customer_id $$;
903"#,
904		);
905		let reads = relation_edges(&g, b"reads");
906		assert!(
907			reads.iter().any(|(source, target)| {
908				source.ends_with("/schema:sales/view:open_orders")
909					&& target.ends_with("/schema:sales/table:orders")
910			}),
911			"missing view -> orders read: {reads:?}"
912		);
913		assert!(
914			reads.iter().any(|(source, target)| {
915				source.ends_with("/schema:sales/view:open_orders")
916					&& target.ends_with("/schema:crm/table:customers")
917			}),
918			"missing view -> customers read: {reads:?}"
919		);
920		assert!(reads.iter().any(|(source, target)| {
921			source.contains("/schema:sales/function:customer_orders(")
922				&& target.ends_with("/schema:sales/table:orders")
923		}));
924		assert!(
925			reads
926				.iter()
927				.all(|(_, target)| !target.ends_with("/table:recent")
928					&& !target.ends_with("/table:r")
929					&& !target.ends_with("/table:c")),
930			"CTEs and aliases are not physical relations: {reads:?}"
931		);
932	}
933
934	#[test]
935	fn dml_and_create_table_as_emit_writes_and_select_reads() {
936		let g = run(
937			"mutations.sql",
938			r#"
939INSERT INTO audit.events SELECT * FROM sales.orders;
940UPDATE sales.orders o SET customer_id = c.id FROM crm.customers c;
941DELETE FROM sales.orders o USING crm.customers c;
942CREATE TABLE sales.orders_copy AS SELECT * FROM sales.orders;
943"#,
944		);
945		let writes = relation_edges(&g, b"writes");
946		assert!(writes.iter().any(|(source, target)| {
947			source.ends_with("/module:mutations") && target.ends_with("/schema:audit/table:events")
948		}));
949		assert!(writes.iter().any(|(source, target)| {
950			source.ends_with("/module:mutations") && target.ends_with("/schema:sales/table:orders")
951		}));
952		assert!(writes.iter().any(|(source, target)| {
953			source.ends_with("/module:mutations")
954				&& target.ends_with("/schema:sales/table:orders_copy")
955		}));
956		let reads = relation_edges(&g, b"reads");
957		assert!(
958			reads
959				.iter()
960				.any(|(_, target)| { target.ends_with("/schema:sales/table:orders") })
961		);
962		assert!(
963			reads
964				.iter()
965				.any(|(_, target)| { target.ends_with("/schema:crm/table:customers") })
966		);
967	}
968
969	#[test]
970	fn plpgsql_static_statements_are_relational_but_dynamic_sql_is_not_certain() {
971		let g = run(
972			"procedures.sql",
973			r#"
974CREATE PROCEDURE sales.refresh(target_table text)
975LANGUAGE plpgsql AS $$
976BEGIN
977  INSERT INTO audit.events SELECT * FROM sales.orders;
978  EXECUTE 'INSERT INTO audit.' || quote_ident(target_table) || ' SELECT * FROM sales.orders';
979END;
980$$;
981"#,
982		);
983		let writes = relation_edges(&g, b"writes");
984		assert_eq!(
985			writes
986				.iter()
987				.filter(|(_, target)| target.ends_with("/schema:audit/table:events"))
988				.count(),
989			1,
990			"only the static statement may claim the concrete write: {writes:?}"
991		);
992		let reads = relation_edges(&g, b"reads");
993		assert_eq!(
994			reads
995				.iter()
996				.filter(|(_, target)| target.ends_with("/schema:sales/table:orders"))
997				.count(),
998			1,
999			"dynamic SQL text must not fabricate a second certain read: {reads:?}"
1000		);
1001	}
1002}