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}