Skip to main content

code_moniker_core/lang/sql/
mod.rs

1mod body;
2mod canonicalize;
3mod kinds;
4mod sdk_pipeline;
5
6use crate::core::code_graph::CodeGraph;
7use crate::core::moniker::Moniker;
8use crate::core::shape::Shape;
9
10use crate::lang::KindSpec;
11
12#[derive(Clone, Debug, Default)]
13pub struct Presets {
14	pub external_schemas: Vec<String>,
15}
16
17pub fn extract(
18	uri: &str,
19	source: &str,
20	anchor: &Moniker,
21	deep: bool,
22	presets: &Presets,
23) -> CodeGraph {
24	sdk_pipeline::extract(uri, source, anchor, deep, presets)
25}
26
27pub struct Lang;
28
29const DEF_KINDS: &[&str] = &["function", "procedure", "view", "table", "type", "schema"];
30
31const DEF_KIND_SPECS: &[KindSpec] = &[
32	KindSpec::new("schema", Shape::Namespace, 10, "schema"),
33	KindSpec::new("table", Shape::Type, 20, "table"),
34	KindSpec::new("view", Shape::Type, 21, "view"),
35	KindSpec::new("type", Shape::Type, 22, "type"),
36	KindSpec::new("function", Shape::Callable, 40, "function"),
37	KindSpec::new("procedure", Shape::Callable, 41, "procedure"),
38];
39
40impl crate::lang::LangExtractor for Lang {
41	type Presets = Presets;
42	const LANG_TAG: &'static str = "sql";
43	const ALLOWED_KINDS: &'static [&'static str] = DEF_KINDS;
44	const KIND_SPECS: &'static [KindSpec] = DEF_KIND_SPECS;
45	const ALLOWED_VISIBILITIES: &'static [&'static str] = &[];
46
47	fn extract(
48		uri: &str,
49		source: &str,
50		anchor: &Moniker,
51		deep: bool,
52		presets: &Self::Presets,
53	) -> CodeGraph {
54		extract(uri, source, anchor, deep, presets)
55	}
56}
57
58#[cfg(test)]
59mod tests {
60	use super::*;
61	use crate::core::moniker::MonikerBuilder;
62
63	fn anchor() -> Moniker {
64		MonikerBuilder::new().project(b"app").build()
65	}
66
67	fn run(uri: &str, src: &str) -> CodeGraph {
68		extract(uri, src, &anchor(), false, &Presets::default())
69	}
70
71	fn def_monikers(g: &CodeGraph) -> Vec<String> {
72		g.defs()
73			.map(|d| crate::core::uri::to_uri(&d.moniker, &Default::default()))
74			.collect()
75	}
76
77	fn ref_targets(g: &CodeGraph) -> Vec<String> {
78		g.refs()
79			.map(|r| crate::core::uri::to_uri(&r.target, &Default::default()))
80			.collect()
81	}
82
83	#[test]
84	fn qualified_function_emits_full_signature() {
85		let g = run(
86			"foo.sql",
87			"CREATE FUNCTION public.bar(a int, b text) RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;",
88		);
89		assert!(
90			def_monikers(&g).iter().any(|m| m
91				== "code+moniker://app/lang:sql/module:foo/schema:public/function:bar(a:int4,b:text)"),
92			"got defs: {:?}",
93			def_monikers(&g)
94		);
95		let func = g
96			.defs()
97			.find(|d| d.kind == b"function")
98			.expect("function def");
99		assert_eq!(func.signature, b"a:int4,b:text");
100	}
101
102	#[test]
103	fn overloads_with_different_types_both_land() {
104		let g = run(
105			"foo.sql",
106			"CREATE FUNCTION m(x int) RETURNS int LANGUAGE sql AS $$ SELECT x $$;\
107			 CREATE FUNCTION m(x text) RETURNS text LANGUAGE sql AS $$ SELECT x $$;",
108		);
109		assert_eq!(g.defs().filter(|d| d.kind == b"function").count(), 2);
110	}
111
112	#[test]
113	fn top_level_select_emits_qualified_call() {
114		let g = run("foo.sql", "SELECT public.bar(1, 2);");
115		assert!(
116			ref_targets(&g).iter().any(|t| t
117				== "code+moniker://app/lang:sql/module:foo/schema:public/function:bar(int4,int4)"),
118			"got refs: {:?}",
119			ref_targets(&g)
120		);
121	}
122
123	#[test]
124	fn empty_source_yields_only_module_root() {
125		let g = run("db/functions/plan/create_plan.sql", "");
126		let defs: Vec<_> = g.defs().collect();
127		assert_eq!(defs.len(), 1);
128		assert_eq!(
129			crate::core::uri::to_uri(&defs[0].moniker, &Default::default()),
130			"code+moniker://app/lang:sql/dir:db/dir:functions/dir:plan/module:create_plan"
131		);
132	}
133
134	#[test]
135	fn nested_calls_preserve_unknown_argument_slots() {
136		let g = run("foo.sql", "SELECT f(g(a, b));");
137		assert!(
138			ref_targets(&g)
139				.iter()
140				.any(|t| t == "code+moniker://app/lang:sql/module:foo/function:f(_)"),
141			"outer call f should preserve one unknown slot, got refs: {:?}",
142			ref_targets(&g)
143		);
144		assert!(
145			ref_targets(&g)
146				.iter()
147				.any(|t| t == "code+moniker://app/lang:sql/module:foo/function:g(_,_)"),
148			"inner call g should preserve two unknown slots, got refs: {:?}",
149			ref_targets(&g)
150		);
151	}
152
153	#[test]
154	fn call_argument_types_come_from_casts_literals_and_parameters() {
155		let g = run(
156			"foo.sql",
157			"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) $$;",
158		);
159		assert!(
160			ref_targets(&g).iter().any(|target| target
161				== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(uuid,int8,bool,text,_)") ,
162			"typed target missing from {:?}",
163			ref_targets(&g)
164		);
165	}
166
167	#[test]
168	fn casts_inside_expressions_do_not_type_the_whole_argument() {
169		let g = run(
170			"foo.sql",
171			"CREATE FUNCTION public.wrapper(p_id int) RETURNS int LANGUAGE sql AS $$ SELECT public.choose(p_id = 1::int, (p_id = 1)::text) $$;",
172		);
173		assert!(
174			ref_targets(&g).iter().any(|target| target
175				== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(_,text)"),
176			"an inner cast must not type its enclosing expression: {:?}",
177			ref_targets(&g)
178		);
179	}
180
181	#[test]
182	fn named_call_arguments_preserve_names_and_types() {
183		let g = run(
184			"foo.sql",
185			"CREATE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql AS $$ SELECT public.choose(label => 'x'::text, value => p_id) $$;",
186		);
187		assert!(
188			ref_targets(&g).iter().any(|target| target
189				== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(label:text,value:uuid)"),
190			"named typed target missing from {:?}",
191			ref_targets(&g)
192		);
193	}
194
195	#[test]
196	fn unquoted_parameter_names_are_canonical_in_definitions_and_calls() {
197		let g = run(
198			"foo.sql",
199			"CREATE FUNCTION public.choose(Value int) RETURNS int LANGUAGE sql AS $$ SELECT Value $$; SELECT public.choose(value => 1::int);",
200		);
201		assert!(def_monikers(&g).iter().any(|definition| definition
202			== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(value:int4)"));
203		assert!(ref_targets(&g).iter().any(|target| target
204			== "code+moniker://app/lang:sql/module:foo/schema:public/function:choose(value:int4)"));
205	}
206
207	#[test]
208	fn static_function_search_path_qualifies_unqualified_calls() {
209		let g = run(
210			"foo.sql",
211			"CREATE FUNCTION public.wrapper(p_id uuid) RETURNS int LANGUAGE sql SET search_path = jobs, pg_temp AS $$ SELECT refresh(p_id) $$;",
212		);
213		assert!(
214			ref_targets(&g).iter().any(|target| target
215				== "code+moniker://app/lang:sql/module:foo/schema:jobs/function:refresh(uuid)"),
216			"search-path-qualified target missing from {:?}",
217			ref_targets(&g)
218		);
219	}
220
221	#[test]
222	fn dynamic_or_catalog_search_path_does_not_claim_one_schema() {
223		let g = run(
224			"foo.sql",
225			"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) $$;",
226		);
227		let refresh_targets = ref_targets(&g)
228			.into_iter()
229			.filter(|target| target.contains("function:refresh"))
230			.collect::<Vec<_>>();
231		assert_eq!(refresh_targets.len(), 2, "got {refresh_targets:?}");
232		assert!(
233			refresh_targets
234				.iter()
235				.all(|target| !target.contains("schema:jobs")),
236			"dynamic/catalog search paths must stay unqualified: {refresh_targets:?}"
237		);
238	}
239
240	#[test]
241	fn duplicate_routine_keeps_the_first_search_path_with_the_first_body() {
242		let g = run(
243			"foo.sql",
244			"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) $$;",
245		);
246		let refresh_targets = ref_targets(&g)
247			.into_iter()
248			.filter(|target| target.contains("function:refresh"))
249			.collect::<Vec<_>>();
250		assert_eq!(refresh_targets.len(), 1, "got {refresh_targets:?}");
251		assert!(refresh_targets[0].contains("schema:first_schema"));
252	}
253
254	#[test]
255	fn duplicate_routine_keeps_the_last_callable_arity_metadata() {
256		let g = run(
257			"foo.sql",
258			"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 $$;",
259		);
260		let wrapper = g
261			.defs()
262			.find(|definition| definition.call_name == b"wrapper")
263			.expect("wrapper definition");
264		assert_eq!(wrapper.call_arity, Some(0));
265	}
266
267	#[test]
268	fn ddl_in_sql_body_does_not_emit_a_definition_with_an_invalid_parent() {
269		let g = run(
270			"foo.sql",
271			"CREATE FUNCTION public.wrapper() RETURNS void LANGUAGE sql AS $$ CREATE TABLE public.inner_table(id int); $$;",
272		);
273		assert_eq!(
274			g.defs()
275				.filter(|definition| definition.kind == b"function")
276				.count(),
277			1
278		);
279		assert_eq!(
280			g.defs()
281				.filter(|definition| definition.kind == b"table")
282				.count(),
283			0
284		);
285	}
286
287	#[test]
288	fn parser_recovery_keywords_do_not_become_calls() {
289		let g = run(
290			"scratch.sql",
291			"SELECT * FROM (SELECT id FROM things WHERE id =) broken; SELECT DISTINCT id FROM things;",
292		);
293		let names = g
294			.refs()
295			.filter(|reference| reference.kind == b"calls")
296			.map(|reference| String::from_utf8_lossy(&reference.call_name).into_owned())
297			.collect::<Vec<_>>();
298		assert!(
299			names
300				.iter()
301				.all(|name| !matches!(name.as_str(), "from" | "distinct" | "as" | "is" | "any")),
302			"parser recovery emitted SQL keywords as calls: {names:?}"
303		);
304	}
305
306	#[test]
307	fn comment_def_bytes_are_a_real_comment_in_outer_source() {
308		let src = r#"CREATE OR REPLACE FUNCTION foo.bar(
309  p_a uuid,
310  p_b text
311)
312RETURNS void
313LANGUAGE plpgsql
314SECURITY DEFINER
315SET search_path = foo, pg_temp
316AS $$
317DECLARE
318  v_x text;
319BEGIN
320  -- real comment, do not lose
321  v_x := 'hello';
322END;
323$$;
324"#;
325		let g = run("fixture.sql", src);
326		for d in g.defs().filter(|d| d.kind == b"comment") {
327			let (s, e) = d.position.expect("comment def must have a position");
328			let slice = &src.as_bytes()[s as usize..e as usize];
329			assert!(
330				slice.starts_with(b"--") || slice.starts_with(b"/*"),
331				"comment def bytes {s}..{e} are not a real comment: {:?}",
332				std::str::from_utf8(slice).unwrap_or("?")
333			);
334		}
335	}
336
337	#[test]
338	fn function_param_emits_uses_type_with_pg_catalog_target() {
339		let g = run(
340			"pkg.sql",
341			"CREATE FUNCTION f(x int, y text) RETURNS bigint LANGUAGE sql AS $$ SELECT 1 $$;",
342		);
343		let int_target = "code+moniker://app/sdk:sql/path:pg_catalog/path:int4";
344		let text_target = "code+moniker://app/sdk:sql/path:pg_catalog/path:text";
345		let bigint_target = "code+moniker://app/sdk:sql/path:pg_catalog/path:int8";
346		let targets = ref_targets(&g);
347		assert!(
348			targets.iter().any(|t| t == int_target),
349			"int param must emit uses_type → pg_catalog/path:int4, got: {targets:?}"
350		);
351		assert!(
352			targets.iter().any(|t| t == text_target),
353			"text param must emit uses_type → pg_catalog/path:text"
354		);
355		assert!(
356			targets.iter().any(|t| t == bigint_target),
357			"bigint return must emit uses_type → pg_catalog/path:int8"
358		);
359		let uses_type_count = g.refs().filter(|r| r.kind == b"uses_type").count();
360		assert!(
361			uses_type_count >= 3,
362			"expected at least 3 uses_type refs (2 params + 1 return), got {uses_type_count}"
363		);
364	}
365
366	#[test]
367	fn user_defined_types_are_definitions_and_qualified_type_targets() {
368		let g = run(
369			"types.sql",
370			"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 $$;",
371		);
372		let definitions = def_monikers(&g);
373		assert!(definitions.iter().any(|definition| definition
374			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_state"));
375		assert!(definitions.iter().any(|definition| definition
376			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_code"));
377		let targets = ref_targets(&g);
378		assert!(targets.iter().any(|target| target
379			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_state"));
380		assert!(targets.iter().any(|target| target
381			== "code+moniker://app/lang:sql/module:types/schema:app/type:order_code"));
382	}
383
384	#[test]
385	fn builtin_function_call_carries_external_confidence() {
386		let g = run("pkg.sql", "SELECT now();");
387		let r = g
388			.refs()
389			.find(|r| r.kind == b"calls")
390			.expect("calls ref for now()");
391		assert_eq!(
392			r.confidence,
393			b"external".to_vec(),
394			"builtin functions like now() must be marked external, got {:?}",
395			std::str::from_utf8(&r.confidence).unwrap_or("?")
396		);
397	}
398
399	#[test]
400	fn corpus_builtin_families_are_external() {
401		let g = run(
402			"pkg.sql",
403			"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);",
404		);
405		let calls = g
406			.refs()
407			.filter(|reference| reference.kind == b"calls")
408			.collect::<Vec<_>>();
409		assert_eq!(calls.len(), 16, "got {calls:?}");
410		assert!(
411			calls
412				.iter()
413				.all(|reference| reference.confidence == b"external")
414		);
415	}
416
417	#[test]
418	fn callable_metadata_uses_sql_argument_nodes() {
419		let g = run(
420			"foo.sql",
421			"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);",
422		);
423		let definition = g
424			.defs()
425			.find(|def| def.kind == b"function" && def.call_name == b"combine")
426			.expect("combine definition");
427		assert_eq!(definition.call_arity, Some(2));
428		let combine = g
429			.refs()
430			.find(|reference| reference.call_name == b"combine")
431			.expect("combine call");
432		let nested = g
433			.refs()
434			.find(|reference| reference.call_name == b"nested")
435			.expect("nested call");
436		assert_eq!(combine.call_arity, Some(2));
437		assert_eq!(nested.call_arity, Some(2));
438	}
439
440	#[test]
441	fn callable_metadata_models_defaults_and_out_parameters() {
442		let g = run(
443			"foo.sql",
444			"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 $$;",
445		);
446		let optional = g
447			.defs()
448			.find(|def| def.call_name == b"optional_arg")
449			.expect("optional_arg definition");
450		let parse_type = g
451			.defs()
452			.find(|def| def.call_name == b"parse_type")
453			.expect("parse_type definition");
454		assert_eq!(optional.call_arity, Some(0));
455		assert_eq!(parse_type.call_arity, Some(1));
456	}
457
458	#[test]
459	fn variadic_parameters_are_explicit_in_callable_identity() {
460		let g = run(
461			"foo.sql",
462			"CREATE FUNCTION concat_all(VARIADIC items text[]) RETURNS text LANGUAGE sql AS $$ SELECT '' $$;",
463		);
464		assert!(
465			def_monikers(&g).iter().any(|definition| definition
466				== "code+moniker://app/lang:sql/module:foo/function:concat_all(items:text[]...)"),
467			"got defs: {:?}",
468			def_monikers(&g)
469		);
470	}
471
472	#[test]
473	fn uppercase_builtin_types_and_catalog_calls_are_external() {
474		let g = run(
475			"foo.sql",
476			"CREATE FUNCTION answer() RETURNS NUMERIC LANGUAGE sql AS $$ SELECT 1 $$; SELECT pg_catalog.current_setting('search_path');",
477		);
478		let type_ref = g
479			.refs()
480			.find(|reference| reference.kind == b"uses_type")
481			.expect("return type");
482		assert_eq!(type_ref.confidence, b"external");
483		let catalog_call = g
484			.refs()
485			.find(|reference| reference.kind == b"calls")
486			.expect("catalog call");
487		assert_eq!(catalog_call.confidence, b"external");
488		assert_eq!(
489			crate::core::uri::to_uri(&catalog_call.target, &Default::default()),
490			"code+moniker://app/sdk:sql/path:pg_catalog/path:current_setting"
491		);
492	}
493
494	#[test]
495	fn create_procedure_emits_procedure_callable() {
496		let src = "CREATE PROCEDURE refresh(value int) LANGUAGE sql AS $$ SELECT 1 $$;";
497		let g = run("foo.sql", src);
498		let procedure = g
499			.defs()
500			.find(|def| def.kind == b"procedure")
501			.expect("procedure definition");
502		assert_eq!(procedure.call_name, b"refresh");
503		assert_eq!(procedure.call_arity, Some(1));
504	}
505
506	#[test]
507	fn call_statements_target_procedures_and_quoted_names_are_canonical() {
508		let g = run(
509			"foo.sql",
510			"CREATE PROCEDURE public.\"RefreshCache\"() LANGUAGE sql AS $$ SELECT 1 $$; CALL public.\"RefreshCache\"(); SELECT PUBLIC.REFRESHCACHE();",
511		);
512		let procedure_call = g
513			.refs()
514			.find(|reference| reference.call_name == b"RefreshCache")
515			.expect("quoted procedure call");
516		assert_eq!(
517			procedure_call
518				.target
519				.as_view()
520				.segments()
521				.last()
522				.unwrap()
523				.kind,
524			b"procedure"
525		);
526		let unquoted_call = g
527			.refs()
528			.find(|reference| reference.call_name == b"refreshcache")
529			.expect("unquoted function call");
530		assert_eq!(
531			unquoted_call
532				.target
533				.as_view()
534				.segments()
535				.last()
536				.unwrap()
537				.kind,
538			b"function"
539		);
540	}
541}