1use sqry_core::graph::unified::build::shape::{CfBucket, ShapeMapping};
10use sqry_core::graph::unified::node::NodeKind;
11use sqry_core::graph::unified::storage::shape::SignatureShape;
12use sqry_core::graph::{
13 GraphBuilder, GraphBuilderError, GraphResult, Language, Span,
14 unified::{GraphBuildHelper, StagingGraph},
15};
16use std::path::Path;
17use std::sync::OnceLock;
18use streaming_iterator::StreamingIterator;
19use tree_sitter::{Node, Query, QueryCursor, Tree};
20
21#[derive(Debug, Clone)]
22struct SqlCallable {
23 node_id: sqry_core::graph::unified::NodeId,
24 start_byte: usize,
25 end_byte: usize,
26}
27
28#[derive(Debug, Clone)]
29struct SqlDatabaseObject {
30 node_id: sqry_core::graph::unified::NodeId,
31}
32
33#[derive(Debug, Clone)]
34enum SqlTableOpKind {
35 Read,
36 Write(sqry_core::graph::unified::TableWriteOp),
37}
38
39#[derive(Debug, Clone)]
40struct SqlTableOp {
41 op_span_bytes: (usize, usize),
42 kind: SqlTableOpKind,
43 table_name: String,
44 schema: Option<String>,
45 table_node_id: sqry_core::graph::unified::NodeId,
46 span: Span,
47}
48
49const FILE_MODULE_NAME: &str = "<file_module>";
54
55#[derive(Debug, Default, Clone, Copy)]
64pub struct SqlGraphBuilder;
65
66impl SqlGraphBuilder {
67 #[must_use]
69 pub fn new() -> Self {
70 Self
71 }
72}
73
74impl GraphBuilder for SqlGraphBuilder {
75 fn build_graph(
76 &self,
77 tree: &Tree,
78 content: &[u8],
79 file: &Path,
80 staging: &mut StagingGraph,
81 ) -> GraphResult<()> {
82 let mut helper = GraphBuildHelper::new(staging, file, Language::Sql);
84
85 let language = tree_sitter_sequel::LANGUAGE.into();
87 let queries = SqlQueries::new(&language)?;
88
89 let mut callables = extract_procedures(tree, content, &queries.procedures, &mut helper);
91
92 callables.extend(extract_triggers(
94 tree,
95 content,
96 &queries.triggers,
97 &mut helper,
98 ));
99
100 let table_reads = extract_table_reads(tree, content, &queries.table_reads, &mut helper);
102
103 let table_writes = extract_table_writes(tree, content, &queries.table_writes, &mut helper);
105
106 let function_calls = extract_function_calls(tree, content, &queries.function_calls);
108
109 let table_definitions =
111 extract_table_definitions(tree, content, &queries.table_definitions, &mut helper);
112
113 let view_definitions =
115 extract_view_definitions(tree, content, &queries.view_definitions, &mut helper);
116
117 for op in table_reads.into_iter().chain(table_writes) {
119 let Some(caller) = find_enclosing_callable(&callables, op.op_span_bytes) else {
120 continue;
121 };
122
123 match op.kind {
124 SqlTableOpKind::Read => helper.add_table_read_edge_with_span(
125 caller.node_id,
126 op.table_node_id,
127 &op.table_name,
128 op.schema.as_deref(),
129 vec![op.span],
130 ),
131 SqlTableOpKind::Write(operation) => helper.add_table_write_edge_with_span(
132 caller.node_id,
133 op.table_node_id,
134 &op.table_name,
135 op.schema.as_deref(),
136 operation,
137 vec![op.span],
138 ),
139 }
140 }
141
142 for call in function_calls {
144 if let Some(caller) = find_enclosing_callable(&callables, call.span_bytes) {
146 let callee_id =
149 helper.add_call_site_node(&call.callee_name, call.span, NodeKind::Function);
150 helper.add_call_edge_full_with_span(
151 caller.node_id,
152 callee_id,
153 255,
154 false,
155 vec![call.span],
156 );
157 }
158 }
161
162 extract_trigger_execute_function_calls(
165 tree,
166 content,
167 &queries.trigger_execute_function,
168 &callables,
169 &mut helper,
170 );
171
172 emit_exports(
174 &mut helper,
175 &callables,
176 &table_definitions,
177 &view_definitions,
178 );
179
180 Ok(())
181 }
182
183 fn language(&self) -> Language {
184 Language::Sql
185 }
186
187 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
188 Some(sql_shape_mapping())
189 }
190}
191
192pub struct SqlShapeMapping {
202 cf_by_kind_id: Vec<Option<CfBucket>>,
203}
204
205impl SqlShapeMapping {
206 fn build() -> Self {
207 let lang: tree_sitter::Language = tree_sitter_sequel::LANGUAGE.into();
208 let count = lang.node_kind_count();
209 let mut cf_by_kind_id = vec![None; count];
210 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
211 let Ok(kind_id) = u16::try_from(id) else {
212 break;
213 };
214 if !lang.node_kind_is_named(kind_id) {
215 continue;
216 }
217 if let Some(name) = lang.node_kind_for_id(kind_id) {
218 *slot = cf_bucket_for_sql_kind(name);
219 }
220 }
221 Self { cf_by_kind_id }
222 }
223}
224
225impl ShapeMapping for SqlShapeMapping {
226 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
227 self.cf_by_kind_id
228 .get(ts_node_kind_id as usize)
229 .copied()
230 .flatten()
231 }
232
233 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
234 let mut shape = SignatureShape::default();
235 let mut cursor = fn_node.walk();
238 for child in fn_node.named_children(&mut cursor) {
239 if child.kind() == "function_arguments" {
240 let mut arg_cursor = child.walk();
241 for arg in child.named_children(&mut arg_cursor) {
242 if arg.kind() == "function_argument" {
243 shape.arity_positional = shape.arity_positional.saturating_add(1);
244 let mut def_cursor = arg.walk();
247 for piece in arg.children(&mut def_cursor) {
248 if piece.kind() == "keyword_default" {
249 shape.has_defaults = true;
250 }
251 }
252 }
253 }
254 }
255 }
256 shape
257 }
258}
259
260fn cf_bucket_for_sql_kind(name: &str) -> Option<CfBucket> {
263 let bucket = match name {
264 "case" => CfBucket::Match,
266 "when_clause" => CfBucket::Branch,
267 "assignment" => CfBucket::Assign,
269 "invocation" => CfBucket::Call,
271 _ => return None,
272 };
273 Some(bucket)
274}
275
276#[must_use]
278pub fn sql_shape_mapping() -> &'static SqlShapeMapping {
279 static MAPPING: OnceLock<SqlShapeMapping> = OnceLock::new();
280 MAPPING.get_or_init(SqlShapeMapping::build)
281}
282
283struct SqlQueries {
285 procedures: Query,
286 triggers: Query,
287 trigger_execute_function: Query,
288 table_reads: Query,
289 table_writes: Query,
290 function_calls: Query,
291 table_definitions: Query,
292 view_definitions: Query,
293}
294
295impl SqlQueries {
296 #[allow(clippy::too_many_lines)]
298 fn new(language: &tree_sitter::Language) -> GraphResult<Self> {
299 let procedures = Query::new(
301 language,
302 r"
303 (create_function
304 (object_reference
305 name: (identifier) @func.name)) @func
306 ",
307 )
308 .map_err(|e| GraphBuilderError::ParseError {
309 span: Span::default(),
310 reason: format!("Failed to compile procedure query: {e}"),
311 })?;
312
313 let triggers = Query::new(
316 language,
317 r"
318 (create_trigger
319 (object_reference
320 name: (identifier) @trigger.name)
321 (keyword_on)
322 (object_reference
323 name: (identifier) @trigger.table)) @trigger
324 ",
325 )
326 .map_err(|e| GraphBuilderError::ParseError {
327 span: Span::default(),
328 reason: format!("Failed to compile trigger query: {e}"),
329 })?;
330
331 let trigger_execute_function = Query::new(
334 language,
335 r"
336 (create_trigger
337 (object_reference
338 name: (identifier) @trigger.name)
339 (keyword_execute)
340 (keyword_function)
341 (object_reference
342 name: (identifier) @func.name)) @trigger_exec
343 ",
344 )
345 .map_err(|e| GraphBuilderError::ParseError {
346 span: Span::default(),
347 reason: format!("Failed to compile trigger_execute_function query: {e}"),
348 })?;
349
350 let table_reads = Query::new(
353 language,
354 r"
355 (statement
356 (select) @select
357 (from
358 (keyword_from)
359 (relation
360 (object_reference
361 name: (identifier) @table.name))))
362 ",
363 )
364 .map_err(|e| GraphBuilderError::ParseError {
365 span: Span::default(),
366 reason: format!("Failed to compile table_reads query: {e}"),
367 })?;
368
369 let table_writes = Query::new(
374 language,
375 r"
376 [
377 (insert
378 (object_reference
379 name: (identifier) @table.name)) @write
380
381 (update
382 (relation
383 (object_reference
384 name: (identifier) @table.name))) @write
385
386 (statement
387 (delete) @write
388 (from
389 (keyword_from)
390 (object_reference
391 name: (identifier) @table.name)))
392 ]
393 ",
394 )
395 .map_err(|e| GraphBuilderError::ParseError {
396 span: Span::default(),
397 reason: format!("Failed to compile table_writes query: {e}"),
398 })?;
399
400 let function_calls = Query::new(
406 language,
407 r#"
408 [
409 (invocation
410 (object_reference
411 name: (identifier) @call.name)) @call
412
413 (ERROR
414 ":="
415 (_) @call.name
416 "(") @call
417
418 (ERROR) @call.error
419 ]
420 "#,
421 )
422 .map_err(|e| GraphBuilderError::ParseError {
423 span: Span::default(),
424 reason: format!("Failed to compile function_calls query: {e}"),
425 })?;
426
427 let table_definitions = Query::new(
429 language,
430 r"
431 (create_table
432 (object_reference
433 name: (identifier) @table.name)) @table
434 ",
435 )
436 .map_err(|e| GraphBuilderError::ParseError {
437 span: Span::default(),
438 reason: format!("Failed to compile table_definitions query: {e}"),
439 })?;
440
441 let view_definitions = Query::new(
443 language,
444 r"
445 [
446 (create_view
447 (object_reference
448 name: (identifier) @view.name)) @view
449 (create_materialized_view
450 (object_reference
451 name: (identifier) @view.name)) @view
452 ]
453 ",
454 )
455 .map_err(|e| GraphBuilderError::ParseError {
456 span: Span::default(),
457 reason: format!("Failed to compile view_definitions query: {e}"),
458 })?;
459
460 Ok(Self {
461 procedures,
462 triggers,
463 trigger_execute_function,
464 table_reads,
465 table_writes,
466 function_calls,
467 table_definitions,
468 view_definitions,
469 })
470 }
471}
472
473fn extract_procedures(
475 tree: &Tree,
476 content: &[u8],
477 query: &Query,
478 helper: &mut GraphBuildHelper,
479) -> Vec<SqlCallable> {
480 let mut callables = Vec::new();
481 let mut cursor = QueryCursor::new();
482 let capture_names = query.capture_names();
483 let mut matches = cursor.matches(query, tree.root_node(), content);
484
485 while let Some(m) = matches.next() {
486 let mut func_name = None;
487 let mut func_node = None;
488
489 for capture in m.captures {
490 let name = capture_names[capture.index as usize];
491 if name == "func.name"
492 && let Ok(text) = capture.node.utf8_text(content)
493 {
494 func_name = Some(text.to_string());
495 }
496 if name == "func" {
497 func_node = Some(capture.node);
498 }
499 }
500
501 if let (Some(name), Some(node)) = (func_name, func_node) {
502 let span = Span::from_node(&node);
503 let node_id = helper.add_function(&name, Some(span), false, false);
504 helper.mark_definition(node_id);
506 callables.push(SqlCallable {
507 node_id,
508 start_byte: node.start_byte(),
509 end_byte: node.end_byte(),
510 });
511 }
512 }
513
514 callables
515}
516
517fn extract_triggers(
519 tree: &Tree,
520 content: &[u8],
521 query: &Query,
522 helper: &mut GraphBuildHelper,
523) -> Vec<SqlCallable> {
524 let mut callables = Vec::new();
525 let mut cursor = QueryCursor::new();
526 let capture_names = query.capture_names();
527 let mut matches = cursor.matches(query, tree.root_node(), content);
528
529 while let Some(m) = matches.next() {
530 let mut trigger_name = None;
531 let mut table_name = None;
532 let mut trigger_node = None;
533
534 for capture in m.captures {
535 let name = capture_names[capture.index as usize];
536 match name {
537 "trigger.name" => {
538 if let Ok(text) = capture.node.utf8_text(content) {
539 trigger_name = Some(text.to_string());
540 }
541 }
542 "trigger.table" => {
543 if let Ok(text) = capture.node.utf8_text(content) {
544 table_name = Some(text.to_string());
545 }
546 }
547 "trigger" => {
548 trigger_node = Some(capture.node);
549 }
550 _ => {}
551 }
552 }
553
554 if let (Some(trigger), Some(table), Some(node)) = (trigger_name, table_name, trigger_node) {
555 let (schema, table_only) = split_schema_table(&table);
556 let span = Span::from_node(&node);
557
558 let trigger_id = helper.add_function(&trigger, Some(span), false, false);
559 helper.mark_definition(trigger_id);
561 callables.push(SqlCallable {
562 node_id: trigger_id,
563 start_byte: node.start_byte(),
564 end_byte: node.end_byte(),
565 });
566
567 let table_id = helper.add_variable(table_only, Some(span));
568 helper.add_triggered_by_edge_with_span(
569 trigger_id,
570 table_id,
571 &trigger,
572 schema,
573 vec![span],
574 );
575 }
576 }
577
578 callables
579}
580
581fn extract_trigger_execute_function_calls(
586 tree: &Tree,
587 content: &[u8],
588 query: &Query,
589 callables: &[SqlCallable],
590 helper: &mut GraphBuildHelper,
591) {
592 let mut cursor = QueryCursor::new();
593 let capture_names = query.capture_names();
594 let mut matches = cursor.matches(query, tree.root_node(), content);
595
596 while let Some(m) = matches.next() {
597 let mut trigger_name = None;
598 let mut func_name = None;
599 let mut trigger_node = None;
600
601 for capture in m.captures {
602 let name = capture_names[capture.index as usize];
603 match name {
604 "trigger.name" => {
605 if let Ok(text) = capture.node.utf8_text(content) {
606 trigger_name = Some(text.to_string());
607 }
608 }
609 "func.name" => {
610 if let Ok(text) = capture.node.utf8_text(content) {
611 func_name = Some(text.to_string());
612 }
613 }
614 "trigger_exec" => {
615 trigger_node = Some(capture.node);
616 }
617 _ => {}
618 }
619 }
620
621 if let (Some(_trigger), Some(func), Some(node)) = (trigger_name, func_name, trigger_node) {
622 let span = Span::from_node(&node);
623
624 if let Some(trigger_callable) = callables.iter().find(|c| {
626 c.start_byte <= node.start_byte() && node.end_byte() <= c.end_byte
628 }) {
629 let callee_id = helper.add_call_site_node(&func, span, NodeKind::Function);
632 helper.add_call_edge_full_with_span(
633 trigger_callable.node_id,
634 callee_id,
635 255,
636 false,
637 vec![span],
638 );
639 }
640 }
641 }
642}
643
644fn extract_table_reads(
646 tree: &Tree,
647 content: &[u8],
648 query: &Query,
649 helper: &mut GraphBuildHelper,
650) -> Vec<SqlTableOp> {
651 let mut ops = Vec::new();
652 let mut cursor = QueryCursor::new();
653 let capture_names = query.capture_names();
654 let mut matches = cursor.matches(query, tree.root_node(), content);
655
656 while let Some(m) = matches.next() {
657 let mut table_name = None;
658 let mut op_node = None;
659
660 for capture in m.captures {
661 let name = capture_names[capture.index as usize];
662 match name {
663 "table.name" => {
664 if let Ok(text) = capture.node.utf8_text(content) {
665 table_name = Some(text.to_string());
666 }
667 }
668 "select" => op_node = Some(capture.node),
669 _ => {}
670 }
671 }
672
673 if let (Some(table_name), Some(node)) = (table_name, op_node) {
674 let (schema, table_only) = split_schema_table(&table_name);
675 let span = Span::from_node(&node);
676 let table_node_id = helper.add_variable(table_only, Some(span));
677 ops.push(SqlTableOp {
678 op_span_bytes: (node.start_byte(), node.end_byte()),
679 kind: SqlTableOpKind::Read,
680 table_name: table_only.to_string(),
681 schema: schema.map(str::to_string),
682 table_node_id,
683 span,
684 });
685 }
686 }
687
688 ops
689}
690
691fn extract_table_writes(
693 tree: &Tree,
694 content: &[u8],
695 query: &Query,
696 helper: &mut GraphBuildHelper,
697) -> Vec<SqlTableOp> {
698 let mut ops = Vec::new();
699 let mut cursor = QueryCursor::new();
700 let capture_names = query.capture_names();
701 let mut matches = cursor.matches(query, tree.root_node(), content);
702
703 while let Some(m) = matches.next() {
704 let mut table_name = None;
705 let mut write_node = None;
706
707 for capture in m.captures {
708 let name = capture_names[capture.index as usize];
709 match name {
710 "table.name" => {
711 if let Ok(text) = capture.node.utf8_text(content) {
712 table_name = Some(text.to_string());
713 }
714 }
715 "write" => write_node = Some(capture.node),
716 _ => {}
717 }
718 }
719
720 let Some(table_name) = table_name else {
721 continue;
722 };
723 let Some(node) = write_node else {
724 continue;
725 };
726
727 let operation = match node.kind() {
728 "insert" => sqry_core::graph::unified::TableWriteOp::Insert,
729 "delete" => sqry_core::graph::unified::TableWriteOp::Delete,
730 _ => sqry_core::graph::unified::TableWriteOp::Update,
731 };
732
733 let (schema, table_only) = split_schema_table(&table_name);
734 let span = Span::from_node(&node);
735 let table_node_id = helper.add_variable(table_only, Some(span));
736 ops.push(SqlTableOp {
737 op_span_bytes: (node.start_byte(), node.end_byte()),
738 kind: SqlTableOpKind::Write(operation),
739 table_name: table_only.to_string(),
740 schema: schema.map(str::to_string),
741 table_node_id,
742 span,
743 });
744 }
745
746 ops
747}
748
749#[derive(Debug)]
751struct SqlFunctionCall {
752 callee_name: String,
753 span_bytes: (usize, usize),
754 span: Span,
755}
756
757fn extract_function_calls(tree: &Tree, content: &[u8], query: &Query) -> Vec<SqlFunctionCall> {
759 let mut calls = Vec::new();
760 let mut cursor = QueryCursor::new();
761 let capture_names = query.capture_names();
762 let mut matches = cursor.matches(query, tree.root_node(), content);
763
764 while let Some(m) = matches.next() {
765 let mut call_name = None;
766 let mut call_node = None;
767
768 for capture in m.captures {
769 let name = capture_names[capture.index as usize];
770 match name {
771 "call.name" => {
772 if let Ok(text) = capture.node.utf8_text(content) {
773 call_name = Some(normalize_callee_name(text));
774 }
775 }
776 "call" | "call.error" => call_node = Some(capture.node),
777 _ => {}
778 }
779 }
780
781 let Some(node) = call_node else {
782 continue;
783 };
784
785 let span_bytes = (node.start_byte(), node.end_byte());
786 let span = Span::from_node(&node);
787
788 if node.kind() == "ERROR" {
789 if let Ok(text) = node.utf8_text(content) {
790 for name in extract_error_call_names(text) {
791 calls.push(SqlFunctionCall {
792 callee_name: name,
793 span_bytes,
794 span,
795 });
796 }
797 }
798 continue;
799 }
800
801 if let Some(name) = call_name
802 && !name.is_empty()
803 {
804 calls.push(SqlFunctionCall {
805 callee_name: name,
806 span_bytes,
807 span,
808 });
809 }
810 }
811
812 calls
813}
814
815fn normalize_callee_name(name: &str) -> String {
816 name.trim()
817 .rsplit('.')
818 .next()
819 .unwrap_or_default()
820 .trim()
821 .to_string()
822}
823
824fn extract_error_call_names(text: &str) -> Vec<String> {
825 let bytes = text.as_bytes();
826 let mut offset = 0;
827 let mut call_names = Vec::new();
828
829 while offset < bytes.len() {
830 if !is_sql_identifier_start(bytes[offset]) {
831 offset += 1;
832 continue;
833 }
834
835 let start = offset;
836 offset += 1;
837 while offset < bytes.len() && is_sql_identifier_continue(bytes[offset]) {
838 offset += 1;
839 }
840
841 let token = &text[start..offset];
842 let mut lookahead = offset;
843 while lookahead < bytes.len() && bytes[lookahead].is_ascii_whitespace() {
844 lookahead += 1;
845 }
846
847 if lookahead < bytes.len() && bytes[lookahead] == b'(' {
848 let normalized = normalize_callee_name(token);
849 if !normalized.is_empty() && !call_names.iter().any(|name| name == &normalized) {
850 call_names.push(normalized);
851 }
852 }
853 }
854
855 call_names
856}
857
858const fn is_sql_identifier_start(byte: u8) -> bool {
859 byte.is_ascii_alphabetic() || byte == b'_'
860}
861
862const fn is_sql_identifier_continue(byte: u8) -> bool {
863 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')
864}
865
866fn extract_table_definitions(
868 tree: &Tree,
869 content: &[u8],
870 query: &Query,
871 helper: &mut GraphBuildHelper,
872) -> Vec<SqlDatabaseObject> {
873 let mut objects = Vec::new();
874 let mut cursor = QueryCursor::new();
875 let capture_names = query.capture_names();
876 let mut matches = cursor.matches(query, tree.root_node(), content);
877
878 while let Some(m) = matches.next() {
879 let mut table_name = None;
880 let mut table_node = None;
881
882 for capture in m.captures {
883 let name = capture_names[capture.index as usize];
884 match name {
885 "table.name" => {
886 if let Ok(text) = capture.node.utf8_text(content) {
887 table_name = Some(text.to_string());
888 }
889 }
890 "table" => table_node = Some(capture.node),
891 _ => {}
892 }
893 }
894
895 if let (Some(name), Some(node)) = (table_name, table_node) {
896 let (_, table_only) = split_schema_table(&name);
898 let span = Span::from_node(&node);
899 let node_id = helper.add_variable(table_only, Some(span));
900 helper.mark_definition(node_id);
902 objects.push(SqlDatabaseObject { node_id });
903 }
904 }
905
906 objects
907}
908
909fn extract_view_definitions(
911 tree: &Tree,
912 content: &[u8],
913 query: &Query,
914 helper: &mut GraphBuildHelper,
915) -> Vec<SqlDatabaseObject> {
916 let mut objects = Vec::new();
917 let mut cursor = QueryCursor::new();
918 let capture_names = query.capture_names();
919 let mut matches = cursor.matches(query, tree.root_node(), content);
920
921 while let Some(m) = matches.next() {
922 let mut view_name = None;
923 let mut view_node = None;
924
925 for capture in m.captures {
926 let name = capture_names[capture.index as usize];
927 match name {
928 "view.name" => {
929 if let Ok(text) = capture.node.utf8_text(content) {
930 view_name = Some(text.to_string());
931 }
932 }
933 "view" => view_node = Some(capture.node),
934 _ => {}
935 }
936 }
937
938 if let (Some(name), Some(node)) = (view_name, view_node) {
939 let (_, view_only) = split_schema_table(&name);
941 let span = Span::from_node(&node);
942 let node_id = helper.add_variable(view_only, Some(span));
943 helper.mark_definition(node_id);
945 objects.push(SqlDatabaseObject { node_id });
946 }
947 }
948
949 objects
950}
951
952fn find_enclosing_callable(
953 callables: &[SqlCallable],
954 op_span_bytes: (usize, usize),
955) -> Option<&SqlCallable> {
956 let (start_byte, end_byte) = op_span_bytes;
957 callables
958 .iter()
959 .filter(|c| c.start_byte <= start_byte && end_byte <= c.end_byte)
960 .min_by_key(|c| c.end_byte.saturating_sub(c.start_byte))
961}
962
963fn split_schema_table(name: &str) -> (Option<&str>, &str) {
964 let mut parts = name.splitn(2, '.');
965 let first = parts.next().unwrap_or(name).trim();
966 let second = parts.next().map(str::trim);
967 match second {
968 Some(table) if !table.is_empty() => (Some(first), table),
969 _ => (None, first),
970 }
971}
972
973fn emit_exports(
979 helper: &mut GraphBuildHelper,
980 callables: &[SqlCallable],
981 tables: &[SqlDatabaseObject],
982 views: &[SqlDatabaseObject],
983) {
984 if callables.is_empty() && tables.is_empty() && views.is_empty() {
986 return;
987 }
988
989 let module_id = helper.add_module(FILE_MODULE_NAME, None);
991
992 for callable in callables {
994 helper.add_export_edge(module_id, callable.node_id);
995 }
996
997 for table in tables {
999 helper.add_export_edge(module_id, table.node_id);
1000 }
1001
1002 for view in views {
1004 helper.add_export_edge(module_id, view.node_id);
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011 use sqry_core::graph::unified::StagingOp;
1012 use sqry_core::graph::unified::TableWriteOp;
1013 use sqry_core::graph::unified::edge::EdgeKind;
1014 use std::path::PathBuf;
1015
1016 fn parse_sql(sql: &str) -> Tree {
1017 let mut parser = tree_sitter::Parser::new();
1018 parser
1019 .set_language(&tree_sitter_sequel::LANGUAGE.into())
1020 .expect("Failed to set SQL language");
1021 parser
1022 .parse(sql.as_bytes(), None)
1023 .expect("Failed to parse SQL")
1024 }
1025
1026 #[allow(dead_code)]
1028 fn get_table_read_edges(staging: &StagingGraph) -> Vec<String> {
1029 staging
1030 .operations()
1031 .iter()
1032 .filter_map(|op| {
1033 if let StagingOp::AddEdge {
1034 kind: EdgeKind::TableRead { table_name, .. },
1035 ..
1036 } = op
1037 {
1038 Some(format!("TableRead({table_name:?})"))
1041 } else {
1042 None
1043 }
1044 })
1045 .collect()
1046 }
1047
1048 #[allow(dead_code)]
1050 fn get_table_write_edges(staging: &StagingGraph) -> Vec<(String, TableWriteOp)> {
1051 staging
1052 .operations()
1053 .iter()
1054 .filter_map(|op| {
1055 if let StagingOp::AddEdge {
1056 kind:
1057 EdgeKind::TableWrite {
1058 table_name,
1059 operation,
1060 ..
1061 },
1062 ..
1063 } = op
1064 {
1065 Some((format!("TableWrite({table_name:?})"), *operation))
1066 } else {
1067 None
1068 }
1069 })
1070 .collect()
1071 }
1072
1073 fn count_table_read_edges(staging: &StagingGraph) -> usize {
1075 staging
1076 .operations()
1077 .iter()
1078 .filter(|op| {
1079 matches!(
1080 op,
1081 StagingOp::AddEdge {
1082 kind: EdgeKind::TableRead { .. },
1083 ..
1084 }
1085 )
1086 })
1087 .count()
1088 }
1089
1090 fn count_table_write_edges(staging: &StagingGraph) -> usize {
1091 staging
1092 .operations()
1093 .iter()
1094 .filter(|op| {
1095 matches!(
1096 op,
1097 StagingOp::AddEdge {
1098 kind: EdgeKind::TableWrite { .. },
1099 ..
1100 }
1101 )
1102 })
1103 .count()
1104 }
1105
1106 fn count_table_write_edges_by_op(staging: &StagingGraph, expected_op: TableWriteOp) -> usize {
1107 staging
1108 .operations()
1109 .iter()
1110 .filter(|op| {
1111 matches!(
1112 op,
1113 StagingOp::AddEdge { kind: EdgeKind::TableWrite { operation, .. }, .. }
1114 if *operation == expected_op
1115 )
1116 })
1117 .count()
1118 }
1119
1120 fn count_call_edges(staging: &StagingGraph) -> usize {
1121 staging
1122 .operations()
1123 .iter()
1124 .filter(|op| {
1125 matches!(
1126 op,
1127 StagingOp::AddEdge {
1128 kind: EdgeKind::Calls { .. },
1129 ..
1130 }
1131 )
1132 })
1133 .count()
1134 }
1135
1136 fn count_export_edges(staging: &StagingGraph) -> usize {
1138 staging
1139 .operations()
1140 .iter()
1141 .filter(|op| {
1142 matches!(
1143 op,
1144 StagingOp::AddEdge {
1145 kind: EdgeKind::Exports { .. },
1146 ..
1147 }
1148 )
1149 })
1150 .count()
1151 }
1152
1153 #[test]
1154 fn test_sql_graph_builder_new() {
1155 let builder = SqlGraphBuilder::new();
1156 assert_eq!(builder.language(), Language::Sql);
1157 }
1158
1159 #[test]
1160 fn test_select_creates_table_read_edge() {
1161 let sql = r"
1162 CREATE FUNCTION get_users()
1163 RETURNS TABLE (id INT, name TEXT) AS $$
1164 SELECT * FROM users;
1165 $$ LANGUAGE sql;
1166 ";
1167
1168 let tree = parse_sql(sql);
1169 let mut staging = StagingGraph::new();
1170 let builder = SqlGraphBuilder::new();
1171 let file = PathBuf::from("test.sql");
1172
1173 builder
1174 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1175 .expect("Graph building should succeed");
1176
1177 let read_count = count_table_read_edges(&staging);
1178 assert!(
1179 read_count >= 1,
1180 "Expected at least 1 TableRead edge, got {read_count}"
1181 );
1182 }
1183
1184 #[test]
1185 fn test_insert_creates_table_write_edge() {
1186 let sql = r"
1187 CREATE FUNCTION create_user(user_name TEXT)
1188 RETURNS VOID AS $$
1189 INSERT INTO users (name) VALUES (user_name);
1190 $$ LANGUAGE sql;
1191 ";
1192
1193 let tree = parse_sql(sql);
1194 let mut staging = StagingGraph::new();
1195 let builder = SqlGraphBuilder::new();
1196 let file = PathBuf::from("test.sql");
1197
1198 builder
1199 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1200 .expect("Graph building should succeed");
1201
1202 let insert_count = count_table_write_edges_by_op(&staging, TableWriteOp::Insert);
1203 assert!(
1204 insert_count >= 1,
1205 "Expected at least 1 TableWrite(Insert) edge, got {insert_count}"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_update_creates_table_write_edge() {
1211 let sql = r"
1212 CREATE FUNCTION update_user(user_id INT, new_name TEXT)
1213 RETURNS VOID AS $$
1214 UPDATE users SET name = new_name WHERE id = user_id;
1215 $$ LANGUAGE sql;
1216 ";
1217
1218 let tree = parse_sql(sql);
1219 let mut staging = StagingGraph::new();
1220 let builder = SqlGraphBuilder::new();
1221 let file = PathBuf::from("test.sql");
1222
1223 builder
1224 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1225 .expect("Graph building should succeed");
1226
1227 let update_count = count_table_write_edges_by_op(&staging, TableWriteOp::Update);
1228 assert!(
1229 update_count >= 1,
1230 "Expected at least 1 TableWrite(Update) edge, got {update_count}"
1231 );
1232 }
1233
1234 #[test]
1235 fn test_delete_creates_table_write_edge() {
1236 let sql = r"
1237 CREATE FUNCTION delete_user(user_id INT)
1238 RETURNS VOID AS $$
1239 DELETE FROM users WHERE id = user_id;
1240 $$ LANGUAGE sql;
1241 ";
1242
1243 let tree = parse_sql(sql);
1244 let mut staging = StagingGraph::new();
1245 let builder = SqlGraphBuilder::new();
1246 let file = PathBuf::from("test.sql");
1247
1248 builder
1249 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1250 .expect("Graph building should succeed");
1251
1252 let delete_count = count_table_write_edges_by_op(&staging, TableWriteOp::Delete);
1253 assert!(
1254 delete_count >= 1,
1255 "Expected at least 1 TableWrite(Delete) edge, got {delete_count}"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_join_creates_table_read_edge_for_primary_table() {
1261 let sql = r"
1264 CREATE FUNCTION get_user_orders()
1265 RETURNS TABLE (user_name TEXT, order_id INT) AS $$
1266 SELECT u.name, o.id FROM users u JOIN orders o ON u.id = o.user_id;
1267 $$ LANGUAGE sql;
1268 ";
1269
1270 let tree = parse_sql(sql);
1271 let mut staging = StagingGraph::new();
1272 let builder = SqlGraphBuilder::new();
1273 let file = PathBuf::from("test.sql");
1274
1275 builder
1276 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1277 .expect("Graph building should succeed");
1278
1279 let read_count = count_table_read_edges(&staging);
1281 assert!(
1282 read_count >= 1,
1283 "Expected at least 1 TableRead edge for FROM clause, got {read_count}"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_multiple_joins_creates_table_read_edge_for_primary_table() {
1289 let sql = r"
1291 CREATE FUNCTION get_order_details()
1292 RETURNS TABLE (user_name TEXT, product_name TEXT, quantity INT) AS $$
1293 SELECT u.name, p.name, o.quantity
1294 FROM users u
1295 JOIN orders o ON u.id = o.user_id
1296 LEFT JOIN products p ON o.product_id = p.id;
1297 $$ LANGUAGE sql;
1298 ";
1299
1300 let tree = parse_sql(sql);
1301 let mut staging = StagingGraph::new();
1302 let builder = SqlGraphBuilder::new();
1303 let file = PathBuf::from("test.sql");
1304
1305 builder
1306 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1307 .expect("Graph building should succeed");
1308
1309 let read_count = count_table_read_edges(&staging);
1311 assert!(
1312 read_count >= 1,
1313 "Expected at least 1 TableRead edge for FROM clause, got {read_count}"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_mixed_read_write_operations() {
1319 let sql = r"
1321 CREATE FUNCTION transfer_funds(from_id INT, to_id INT, amount DECIMAL)
1322 RETURNS VOID AS $$
1323 BEGIN
1324 SELECT balance FROM accounts WHERE id = from_id;
1325 UPDATE accounts SET balance = balance - amount WHERE id = from_id;
1326 UPDATE accounts SET balance = balance + amount WHERE id = to_id;
1327 INSERT INTO transactions (from_account, to_account, amount) VALUES (from_id, to_id, amount);
1328 END;
1329 $$ LANGUAGE plpgsql;
1330 ";
1331
1332 let tree = parse_sql(sql);
1333 let mut staging = StagingGraph::new();
1334 let builder = SqlGraphBuilder::new();
1335 let file = PathBuf::from("test.sql");
1336
1337 builder
1338 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1339 .expect("Graph building should succeed");
1340
1341 let read_count = count_table_read_edges(&staging);
1342 let write_count = count_table_write_edges(&staging);
1343
1344 assert!(
1346 read_count >= 1,
1347 "Expected at least 1 TableRead edge, got {read_count}"
1348 );
1349 assert!(
1350 write_count >= 1,
1351 "Expected at least 1 TableWrite edge, got {write_count}"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_plpgsql_assignment_function_calls_create_call_edges() {
1357 let sql = r"
1358 CREATE FUNCTION add(a INT, b INT) RETURNS INT AS $$
1359 BEGIN
1360 RETURN a + b;
1361 END;
1362 $$ LANGUAGE plpgsql;
1363
1364 CREATE FUNCTION multiply(a INT, b INT) RETURNS INT AS $$
1365 BEGIN
1366 RETURN a * b;
1367 END;
1368 $$ LANGUAGE plpgsql;
1369
1370 CREATE FUNCTION compute(x INT, y INT, z INT) RETURNS INT AS $$
1371 DECLARE
1372 sum_val INT;
1373 BEGIN
1374 sum_val := add(x, y);
1375 RETURN multiply(sum_val, z);
1376 END;
1377 $$ LANGUAGE plpgsql;
1378 ";
1379
1380 let tree = parse_sql(sql);
1381 let mut staging = StagingGraph::new();
1382 let builder = SqlGraphBuilder::new();
1383 let file = PathBuf::from("nested_calls.sql");
1384
1385 builder
1386 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1387 .expect("Graph building should succeed");
1388
1389 let call_count = count_call_edges(&staging);
1390 assert!(
1391 call_count >= 2,
1392 "Expected at least 2 call edges for add() and multiply(), got {call_count}"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_plpgsql_multiple_assignment_calls_create_call_edges() {
1398 let sql = r"
1399 CREATE FUNCTION helper_one() RETURNS INT AS $$
1400 BEGIN
1401 RETURN 42;
1402 END;
1403 $$ LANGUAGE plpgsql;
1404
1405 CREATE FUNCTION helper_two() RETURNS INT AS $$
1406 BEGIN
1407 RETURN 100;
1408 END;
1409 $$ LANGUAGE plpgsql;
1410
1411 CREATE FUNCTION orchestrator() RETURNS INT AS $$
1412 DECLARE
1413 val1 INT;
1414 val2 INT;
1415 BEGIN
1416 val1 := helper_one();
1417 val2 := helper_two();
1418 RETURN val1 + val2;
1419 END;
1420 $$ LANGUAGE plpgsql;
1421 ";
1422
1423 let tree = parse_sql(sql);
1424 let mut staging = StagingGraph::new();
1425 let builder = SqlGraphBuilder::new();
1426 let file = PathBuf::from("multiple_assignment_calls.sql");
1427
1428 builder
1429 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1430 .expect("Graph building should succeed");
1431
1432 let call_count = count_call_edges(&staging);
1433 assert!(
1434 call_count >= 2,
1435 "Expected at least 2 call edges for helper_one() and helper_two(), got {call_count}"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_schema_qualified_table_name() {
1441 let sql = r"
1442 CREATE FUNCTION get_public_users()
1443 RETURNS TABLE (id INT, name TEXT) AS $$
1444 SELECT * FROM public.users;
1445 $$ LANGUAGE sql;
1446 ";
1447
1448 let tree = parse_sql(sql);
1449 let mut staging = StagingGraph::new();
1450 let builder = SqlGraphBuilder::new();
1451 let file = PathBuf::from("test.sql");
1452
1453 let result = builder.build_graph(&tree, sql.as_bytes(), &file, &mut staging);
1455 assert!(result.is_ok(), "Should handle schema-qualified table names");
1456 }
1457
1458 #[test]
1459 fn test_split_schema_table_with_schema() {
1460 let (schema, table) = split_schema_table("public.users");
1461 assert_eq!(schema, Some("public"));
1462 assert_eq!(table, "users");
1463 }
1464
1465 #[test]
1466 fn test_split_schema_table_without_schema() {
1467 let (schema, table) = split_schema_table("users");
1468 assert_eq!(schema, None);
1469 assert_eq!(table, "users");
1470 }
1471
1472 #[test]
1473 fn test_split_schema_table_with_whitespace() {
1474 let (schema, table) = split_schema_table(" public . users ");
1475 assert_eq!(schema, Some("public"));
1476 assert_eq!(table, "users");
1477 }
1478
1479 #[test]
1480 fn test_empty_sql_file() {
1481 let sql = "";
1482 let tree = parse_sql(sql);
1483 let mut staging = StagingGraph::new();
1484 let builder = SqlGraphBuilder::new();
1485 let file = PathBuf::from("empty.sql");
1486
1487 let result = builder.build_graph(&tree, sql.as_bytes(), &file, &mut staging);
1488 assert!(result.is_ok(), "Should handle empty SQL files");
1489 }
1490
1491 #[test]
1492 fn test_standalone_select_without_function() {
1493 let sql = "SELECT * FROM users;";
1495
1496 let tree = parse_sql(sql);
1497 let mut staging = StagingGraph::new();
1498 let builder = SqlGraphBuilder::new();
1499 let file = PathBuf::from("query.sql");
1500
1501 builder
1502 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1503 .expect("Graph building should succeed");
1504
1505 let read_count = count_table_read_edges(&staging);
1508 assert_eq!(
1509 read_count, 0,
1510 "Standalone SELECT should not create edges without enclosing function"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_export_edges_for_table_definitions() {
1516 let sql = r"
1517 CREATE TABLE users (
1518 id SERIAL PRIMARY KEY,
1519 name TEXT NOT NULL
1520 );
1521
1522 CREATE TABLE orders (
1523 id SERIAL PRIMARY KEY,
1524 user_id INTEGER REFERENCES users(id)
1525 );
1526 ";
1527
1528 let tree = parse_sql(sql);
1529 let mut staging = StagingGraph::new();
1530 let builder = SqlGraphBuilder::new();
1531 let file = PathBuf::from("schema.sql");
1532
1533 builder
1534 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1535 .expect("Graph building should succeed");
1536
1537 let export_count = count_export_edges(&staging);
1538 assert_eq!(
1539 export_count, 2,
1540 "Expected 2 Export edges (users and orders), got {export_count}"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_export_edges_for_view_definitions() {
1546 let sql = r"
1547 CREATE TABLE users (id INT, created_at TIMESTAMP);
1548
1549 CREATE VIEW active_users AS
1550 SELECT * FROM users WHERE created_at > NOW() - INTERVAL '30 days';
1551
1552 CREATE MATERIALIZED VIEW user_stats AS
1553 SELECT COUNT(*) as total FROM users;
1554 ";
1555
1556 let tree = parse_sql(sql);
1557 let mut staging = StagingGraph::new();
1558 let builder = SqlGraphBuilder::new();
1559 let file = PathBuf::from("views.sql");
1560
1561 builder
1562 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1563 .expect("Graph building should succeed");
1564
1565 let export_count = count_export_edges(&staging);
1566 assert_eq!(
1568 export_count, 3,
1569 "Expected 3 Export edges (1 table + 2 views), got {export_count}"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_export_edges_for_functions_and_triggers() {
1575 let sql = r"
1576 CREATE FUNCTION get_balance(account_id INT) RETURNS BIGINT AS $$
1577 BEGIN
1578 RETURN 42;
1579 END;
1580 $$ LANGUAGE plpgsql;
1581
1582 CREATE FUNCTION update_balance() RETURNS TRIGGER AS $$
1583 BEGIN
1584 RETURN NEW;
1585 END;
1586 $$ LANGUAGE plpgsql;
1587
1588 CREATE TRIGGER balance_updated
1589 BEFORE INSERT ON accounts
1590 FOR EACH ROW
1591 EXECUTE FUNCTION update_balance();
1592 ";
1593
1594 let tree = parse_sql(sql);
1595 let mut staging = StagingGraph::new();
1596 let builder = SqlGraphBuilder::new();
1597 let file = PathBuf::from("banking.sql");
1598
1599 builder
1600 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1601 .expect("Graph building should succeed");
1602
1603 let export_count = count_export_edges(&staging);
1604 assert!(
1608 export_count >= 3,
1609 "Expected at least 3 Export edges (2 functions + 1 trigger), got {export_count}"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_export_edges_with_schema_qualified_names() {
1615 let sql = r"
1616 CREATE TABLE public.customers (
1617 id SERIAL PRIMARY KEY,
1618 name TEXT NOT NULL
1619 );
1620
1621 CREATE FUNCTION public.get_customer_name(cust_id INT) RETURNS TEXT AS $$
1622 BEGIN
1623 RETURN 'test';
1624 END;
1625 $$ LANGUAGE plpgsql;
1626 ";
1627
1628 let tree = parse_sql(sql);
1629 let mut staging = StagingGraph::new();
1630 let builder = SqlGraphBuilder::new();
1631 let file = PathBuf::from("public_schema.sql");
1632
1633 builder
1634 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1635 .expect("Graph building should succeed");
1636
1637 let export_count = count_export_edges(&staging);
1638 assert_eq!(
1640 export_count, 2,
1641 "Expected 2 Export edges (table + function), got {export_count}"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_mixed_database_objects_exports() {
1647 let sql = r"
1648 CREATE TABLE accounts (
1649 id SERIAL PRIMARY KEY,
1650 balance_cents BIGINT NOT NULL
1651 );
1652
1653 CREATE VIEW positive_balances AS
1654 SELECT * FROM accounts WHERE balance_cents > 0;
1655
1656 CREATE FUNCTION get_balance(account_id INT) RETURNS BIGINT AS $$
1657 BEGIN
1658 RETURN (SELECT balance_cents FROM accounts WHERE id = account_id);
1659 END;
1660 $$ LANGUAGE plpgsql;
1661 ";
1662
1663 let tree = parse_sql(sql);
1664 let mut staging = StagingGraph::new();
1665 let builder = SqlGraphBuilder::new();
1666 let file = PathBuf::from("mixed.sql");
1667
1668 builder
1669 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1670 .expect("Graph building should succeed");
1671
1672 let export_count = count_export_edges(&staging);
1673 assert_eq!(
1675 export_count, 3,
1676 "Expected 3 Export edges (table + view + function), got {export_count}"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_no_exports_for_empty_file() {
1682 let sql = "";
1683 let tree = parse_sql(sql);
1684 let mut staging = StagingGraph::new();
1685 let builder = SqlGraphBuilder::new();
1686 let file = PathBuf::from("empty.sql");
1687
1688 builder
1689 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1690 .expect("Graph building should succeed");
1691
1692 let export_count = count_export_edges(&staging);
1693 assert_eq!(
1694 export_count, 0,
1695 "Expected 0 Export edges for empty file, got {export_count}"
1696 );
1697 }
1698}
1699
1700#[cfg(test)]
1701mod shape_tests {
1702 use super::*;
1703 use sqry_core::graph::unified::build::shape::{ShapeBudget, compute_shape_descriptor};
1704
1705 const SAMPLE: &str = include_str!(concat!(
1706 env!("CARGO_MANIFEST_DIR"),
1707 "/../test-fixtures/shape/data/sample.sql"
1708 ));
1709
1710 fn parse(src: &str) -> Tree {
1711 let mut parser = tree_sitter::Parser::new();
1712 parser
1713 .set_language(&tree_sitter_sequel::LANGUAGE.into())
1714 .expect("load sql grammar");
1715 parser.parse(src, None).expect("parse")
1716 }
1717
1718 fn first_create_function(node: Node<'_>) -> Option<Node<'_>> {
1720 if node.kind() == "create_function" {
1721 return Some(node);
1722 }
1723 let mut cursor = node.walk();
1724 for child in node.children(&mut cursor) {
1725 if let Some(found) = first_create_function(child) {
1726 return Some(found);
1727 }
1728 }
1729 None
1730 }
1731
1732 #[test]
1733 fn cf_map_is_non_empty_and_covers_real_kinds() {
1734 let mapping = sql_shape_mapping();
1735 let populated = mapping.cf_by_kind_id.iter().filter(|s| s.is_some()).count();
1736 assert!(
1737 populated > 0,
1738 "SQL cf map must map at least one real grammar kind"
1739 );
1740
1741 let lang: tree_sitter::Language = tree_sitter_sequel::LANGUAGE.into();
1744 let case_id = lang.id_for_node_kind("case", true);
1745 let when_id = lang.id_for_node_kind("when_clause", true);
1746 assert_eq!(mapping.cf_bucket(case_id), Some(CfBucket::Match));
1747 assert_eq!(mapping.cf_bucket(when_id), Some(CfBucket::Branch));
1748 }
1749
1750 #[test]
1751 fn descriptor_counts_case_control_flow_in_sql_body() {
1752 let tree = parse(SAMPLE);
1753 let func = first_create_function(tree.root_node()).expect("create_function in fixture");
1754 let descriptor = compute_shape_descriptor(
1755 func,
1756 SAMPLE.as_bytes(),
1757 sql_shape_mapping(),
1758 &ShapeBudget::default(),
1759 );
1760 assert!(
1761 !descriptor.is_unhashable(),
1762 "a function with a parsed CASE body must be hashable"
1763 );
1764 assert!(
1765 descriptor.cf_histogram[CfBucket::Match.index()] >= 1,
1766 "the CASE expression must be counted in the Match bucket"
1767 );
1768 }
1769
1770 #[test]
1771 fn signature_shape_reads_arguments_and_defaults() {
1772 let tree = parse(SAMPLE);
1773 let func = first_create_function(tree.root_node()).expect("create_function in fixture");
1774 let shape = sql_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
1775 assert_eq!(
1776 shape.arity_positional, 2,
1777 "grade(score, bonus) has two arguments"
1778 );
1779 assert!(
1780 shape.has_defaults,
1781 "the DEFAULT 0 argument must set has_defaults"
1782 );
1783 }
1784}