1use sqry_core::graph::{
10 GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span,
11 unified::{GraphBuildHelper, StagingGraph},
12};
13use std::path::Path;
14use streaming_iterator::StreamingIterator;
15use tree_sitter::{Query, QueryCursor, Tree};
16
17#[derive(Debug, Clone)]
18struct SqlCallable {
19 node_id: sqry_core::graph::unified::NodeId,
20 start_byte: usize,
21 end_byte: usize,
22}
23
24#[derive(Debug, Clone)]
25struct SqlDatabaseObject {
26 node_id: sqry_core::graph::unified::NodeId,
27}
28
29#[derive(Debug, Clone)]
30enum SqlTableOpKind {
31 Read,
32 Write(sqry_core::graph::unified::TableWriteOp),
33}
34
35#[derive(Debug, Clone)]
36struct SqlTableOp {
37 op_span_bytes: (usize, usize),
38 kind: SqlTableOpKind,
39 table_name: String,
40 schema: Option<String>,
41 table_node_id: sqry_core::graph::unified::NodeId,
42 span: Span,
43}
44
45const FILE_MODULE_NAME: &str = "<file_module>";
50
51#[derive(Debug, Default, Clone, Copy)]
60pub struct SqlGraphBuilder;
61
62impl SqlGraphBuilder {
63 #[must_use]
65 pub fn new() -> Self {
66 Self
67 }
68}
69
70impl GraphBuilder for SqlGraphBuilder {
71 fn build_graph(
72 &self,
73 tree: &Tree,
74 content: &[u8],
75 file: &Path,
76 staging: &mut StagingGraph,
77 ) -> GraphResult<()> {
78 let mut helper = GraphBuildHelper::new(staging, file, Language::Sql);
80
81 let language = tree_sitter_sequel::LANGUAGE.into();
83 let queries = SqlQueries::new(&language)?;
84
85 let mut callables = extract_procedures(tree, content, &queries.procedures, &mut helper);
87
88 callables.extend(extract_triggers(
90 tree,
91 content,
92 &queries.triggers,
93 &mut helper,
94 ));
95
96 let table_reads = extract_table_reads(tree, content, &queries.table_reads, &mut helper);
98
99 let table_writes = extract_table_writes(tree, content, &queries.table_writes, &mut helper);
101
102 let function_calls = extract_function_calls(tree, content, &queries.function_calls);
104
105 let table_definitions =
107 extract_table_definitions(tree, content, &queries.table_definitions, &mut helper);
108
109 let view_definitions =
111 extract_view_definitions(tree, content, &queries.view_definitions, &mut helper);
112
113 for op in table_reads.into_iter().chain(table_writes) {
115 let Some(caller) = find_enclosing_callable(&callables, op.op_span_bytes) else {
116 continue;
117 };
118
119 match op.kind {
120 SqlTableOpKind::Read => helper.add_table_read_edge_with_span(
121 caller.node_id,
122 op.table_node_id,
123 &op.table_name,
124 op.schema.as_deref(),
125 vec![op.span],
126 ),
127 SqlTableOpKind::Write(operation) => helper.add_table_write_edge_with_span(
128 caller.node_id,
129 op.table_node_id,
130 &op.table_name,
131 op.schema.as_deref(),
132 operation,
133 vec![op.span],
134 ),
135 }
136 }
137
138 for call in function_calls {
140 if let Some(caller) = find_enclosing_callable(&callables, call.span_bytes) {
142 let callee_id =
144 helper.add_function(&call.callee_name, Some(call.span), false, false);
145 helper.add_call_edge_full_with_span(
146 caller.node_id,
147 callee_id,
148 255,
149 false,
150 vec![call.span],
151 );
152 }
153 }
156
157 extract_trigger_execute_function_calls(
160 tree,
161 content,
162 &queries.trigger_execute_function,
163 &callables,
164 &mut helper,
165 );
166
167 emit_exports(
169 &mut helper,
170 &callables,
171 &table_definitions,
172 &view_definitions,
173 );
174
175 Ok(())
176 }
177
178 fn language(&self) -> Language {
179 Language::Sql
180 }
181}
182
183struct SqlQueries {
185 procedures: Query,
186 triggers: Query,
187 trigger_execute_function: Query,
188 table_reads: Query,
189 table_writes: Query,
190 function_calls: Query,
191 table_definitions: Query,
192 view_definitions: Query,
193}
194
195impl SqlQueries {
196 #[allow(clippy::too_many_lines)]
198 fn new(language: &tree_sitter::Language) -> GraphResult<Self> {
199 let procedures = Query::new(
201 language,
202 r"
203 (create_function
204 (object_reference
205 name: (identifier) @func.name)) @func
206 ",
207 )
208 .map_err(|e| GraphBuilderError::ParseError {
209 span: Span::default(),
210 reason: format!("Failed to compile procedure query: {e}"),
211 })?;
212
213 let triggers = Query::new(
216 language,
217 r"
218 (create_trigger
219 (object_reference
220 name: (identifier) @trigger.name)
221 (keyword_on)
222 (object_reference
223 name: (identifier) @trigger.table)) @trigger
224 ",
225 )
226 .map_err(|e| GraphBuilderError::ParseError {
227 span: Span::default(),
228 reason: format!("Failed to compile trigger query: {e}"),
229 })?;
230
231 let trigger_execute_function = Query::new(
234 language,
235 r"
236 (create_trigger
237 (object_reference
238 name: (identifier) @trigger.name)
239 (keyword_execute)
240 (keyword_function)
241 (object_reference
242 name: (identifier) @func.name)) @trigger_exec
243 ",
244 )
245 .map_err(|e| GraphBuilderError::ParseError {
246 span: Span::default(),
247 reason: format!("Failed to compile trigger_execute_function query: {e}"),
248 })?;
249
250 let table_reads = Query::new(
253 language,
254 r"
255 (statement
256 (select) @select
257 (from
258 (keyword_from)
259 (relation
260 (object_reference
261 name: (identifier) @table.name))))
262 ",
263 )
264 .map_err(|e| GraphBuilderError::ParseError {
265 span: Span::default(),
266 reason: format!("Failed to compile table_reads query: {e}"),
267 })?;
268
269 let table_writes = Query::new(
274 language,
275 r"
276 [
277 (insert
278 (object_reference
279 name: (identifier) @table.name)) @write
280
281 (update
282 (relation
283 (object_reference
284 name: (identifier) @table.name))) @write
285
286 (statement
287 (delete) @write
288 (from
289 (keyword_from)
290 (object_reference
291 name: (identifier) @table.name)))
292 ]
293 ",
294 )
295 .map_err(|e| GraphBuilderError::ParseError {
296 span: Span::default(),
297 reason: format!("Failed to compile table_writes query: {e}"),
298 })?;
299
300 let function_calls = Query::new(
304 language,
305 r"
306 (invocation
307 (object_reference
308 name: (identifier) @call.name)) @call
309 ",
310 )
311 .map_err(|e| GraphBuilderError::ParseError {
312 span: Span::default(),
313 reason: format!("Failed to compile function_calls query: {e}"),
314 })?;
315
316 let table_definitions = Query::new(
318 language,
319 r"
320 (create_table
321 (object_reference
322 name: (identifier) @table.name)) @table
323 ",
324 )
325 .map_err(|e| GraphBuilderError::ParseError {
326 span: Span::default(),
327 reason: format!("Failed to compile table_definitions query: {e}"),
328 })?;
329
330 let view_definitions = Query::new(
332 language,
333 r"
334 [
335 (create_view
336 (object_reference
337 name: (identifier) @view.name)) @view
338 (create_materialized_view
339 (object_reference
340 name: (identifier) @view.name)) @view
341 ]
342 ",
343 )
344 .map_err(|e| GraphBuilderError::ParseError {
345 span: Span::default(),
346 reason: format!("Failed to compile view_definitions query: {e}"),
347 })?;
348
349 Ok(Self {
350 procedures,
351 triggers,
352 trigger_execute_function,
353 table_reads,
354 table_writes,
355 function_calls,
356 table_definitions,
357 view_definitions,
358 })
359 }
360}
361
362fn extract_procedures(
364 tree: &Tree,
365 content: &[u8],
366 query: &Query,
367 helper: &mut GraphBuildHelper,
368) -> Vec<SqlCallable> {
369 let mut callables = Vec::new();
370 let mut cursor = QueryCursor::new();
371 let capture_names = query.capture_names();
372 let mut matches = cursor.matches(query, tree.root_node(), content);
373
374 while let Some(m) = matches.next() {
375 let mut func_name = None;
376 let mut func_node = None;
377
378 for capture in m.captures {
379 let name = capture_names[capture.index as usize];
380 if name == "func.name"
381 && let Ok(text) = capture.node.utf8_text(content)
382 {
383 func_name = Some(text.to_string());
384 }
385 if name == "func" {
386 func_node = Some(capture.node);
387 }
388 }
389
390 if let (Some(name), Some(node)) = (func_name, func_node) {
391 let span = Span::from_node(&node);
392 let node_id = helper.add_function(&name, Some(span), false, false);
393 callables.push(SqlCallable {
394 node_id,
395 start_byte: node.start_byte(),
396 end_byte: node.end_byte(),
397 });
398 }
399 }
400
401 callables
402}
403
404fn extract_triggers(
406 tree: &Tree,
407 content: &[u8],
408 query: &Query,
409 helper: &mut GraphBuildHelper,
410) -> Vec<SqlCallable> {
411 let mut callables = Vec::new();
412 let mut cursor = QueryCursor::new();
413 let capture_names = query.capture_names();
414 let mut matches = cursor.matches(query, tree.root_node(), content);
415
416 while let Some(m) = matches.next() {
417 let mut trigger_name = None;
418 let mut table_name = None;
419 let mut trigger_node = None;
420
421 for capture in m.captures {
422 let name = capture_names[capture.index as usize];
423 match name {
424 "trigger.name" => {
425 if let Ok(text) = capture.node.utf8_text(content) {
426 trigger_name = Some(text.to_string());
427 }
428 }
429 "trigger.table" => {
430 if let Ok(text) = capture.node.utf8_text(content) {
431 table_name = Some(text.to_string());
432 }
433 }
434 "trigger" => {
435 trigger_node = Some(capture.node);
436 }
437 _ => {}
438 }
439 }
440
441 if let (Some(trigger), Some(table), Some(node)) = (trigger_name, table_name, trigger_node) {
442 let (schema, table_only) = split_schema_table(&table);
443 let span = Span::from_node(&node);
444
445 let trigger_id = helper.add_function(&trigger, Some(span), false, false);
446 callables.push(SqlCallable {
447 node_id: trigger_id,
448 start_byte: node.start_byte(),
449 end_byte: node.end_byte(),
450 });
451
452 let table_id = helper.add_variable(table_only, Some(span));
453 helper.add_triggered_by_edge_with_span(
454 trigger_id,
455 table_id,
456 &trigger,
457 schema,
458 vec![span],
459 );
460 }
461 }
462
463 callables
464}
465
466fn extract_trigger_execute_function_calls(
471 tree: &Tree,
472 content: &[u8],
473 query: &Query,
474 callables: &[SqlCallable],
475 helper: &mut GraphBuildHelper,
476) {
477 let mut cursor = QueryCursor::new();
478 let capture_names = query.capture_names();
479 let mut matches = cursor.matches(query, tree.root_node(), content);
480
481 while let Some(m) = matches.next() {
482 let mut trigger_name = None;
483 let mut func_name = None;
484 let mut trigger_node = None;
485
486 for capture in m.captures {
487 let name = capture_names[capture.index as usize];
488 match name {
489 "trigger.name" => {
490 if let Ok(text) = capture.node.utf8_text(content) {
491 trigger_name = Some(text.to_string());
492 }
493 }
494 "func.name" => {
495 if let Ok(text) = capture.node.utf8_text(content) {
496 func_name = Some(text.to_string());
497 }
498 }
499 "trigger_exec" => {
500 trigger_node = Some(capture.node);
501 }
502 _ => {}
503 }
504 }
505
506 if let (Some(_trigger), Some(func), Some(node)) = (trigger_name, func_name, trigger_node) {
507 let span = Span::from_node(&node);
508
509 if let Some(trigger_callable) = callables.iter().find(|c| {
511 c.start_byte <= node.start_byte() && node.end_byte() <= c.end_byte
513 }) {
514 let callee_id = helper.add_function(&func, Some(span), false, false);
516 helper.add_call_edge_full_with_span(
517 trigger_callable.node_id,
518 callee_id,
519 255,
520 false,
521 vec![span],
522 );
523 }
524 }
525 }
526}
527
528fn extract_table_reads(
530 tree: &Tree,
531 content: &[u8],
532 query: &Query,
533 helper: &mut GraphBuildHelper,
534) -> Vec<SqlTableOp> {
535 let mut ops = Vec::new();
536 let mut cursor = QueryCursor::new();
537 let capture_names = query.capture_names();
538 let mut matches = cursor.matches(query, tree.root_node(), content);
539
540 while let Some(m) = matches.next() {
541 let mut table_name = None;
542 let mut op_node = None;
543
544 for capture in m.captures {
545 let name = capture_names[capture.index as usize];
546 match name {
547 "table.name" => {
548 if let Ok(text) = capture.node.utf8_text(content) {
549 table_name = Some(text.to_string());
550 }
551 }
552 "select" => op_node = Some(capture.node),
553 _ => {}
554 }
555 }
556
557 if let (Some(table_name), Some(node)) = (table_name, op_node) {
558 let (schema, table_only) = split_schema_table(&table_name);
559 let span = Span::from_node(&node);
560 let table_node_id = helper.add_variable(table_only, Some(span));
561 ops.push(SqlTableOp {
562 op_span_bytes: (node.start_byte(), node.end_byte()),
563 kind: SqlTableOpKind::Read,
564 table_name: table_only.to_string(),
565 schema: schema.map(str::to_string),
566 table_node_id,
567 span,
568 });
569 }
570 }
571
572 ops
573}
574
575fn extract_table_writes(
577 tree: &Tree,
578 content: &[u8],
579 query: &Query,
580 helper: &mut GraphBuildHelper,
581) -> Vec<SqlTableOp> {
582 let mut ops = Vec::new();
583 let mut cursor = QueryCursor::new();
584 let capture_names = query.capture_names();
585 let mut matches = cursor.matches(query, tree.root_node(), content);
586
587 while let Some(m) = matches.next() {
588 let mut table_name = None;
589 let mut write_node = None;
590
591 for capture in m.captures {
592 let name = capture_names[capture.index as usize];
593 match name {
594 "table.name" => {
595 if let Ok(text) = capture.node.utf8_text(content) {
596 table_name = Some(text.to_string());
597 }
598 }
599 "write" => write_node = Some(capture.node),
600 _ => {}
601 }
602 }
603
604 let Some(table_name) = table_name else {
605 continue;
606 };
607 let Some(node) = write_node else {
608 continue;
609 };
610
611 let operation = match node.kind() {
612 "insert" => sqry_core::graph::unified::TableWriteOp::Insert,
613 "delete" => sqry_core::graph::unified::TableWriteOp::Delete,
614 _ => sqry_core::graph::unified::TableWriteOp::Update,
615 };
616
617 let (schema, table_only) = split_schema_table(&table_name);
618 let span = Span::from_node(&node);
619 let table_node_id = helper.add_variable(table_only, Some(span));
620 ops.push(SqlTableOp {
621 op_span_bytes: (node.start_byte(), node.end_byte()),
622 kind: SqlTableOpKind::Write(operation),
623 table_name: table_only.to_string(),
624 schema: schema.map(str::to_string),
625 table_node_id,
626 span,
627 });
628 }
629
630 ops
631}
632
633#[derive(Debug)]
635struct SqlFunctionCall {
636 callee_name: String,
637 span_bytes: (usize, usize),
638 span: Span,
639}
640
641fn extract_function_calls(tree: &Tree, content: &[u8], query: &Query) -> Vec<SqlFunctionCall> {
643 let mut calls = Vec::new();
644 let mut cursor = QueryCursor::new();
645 let capture_names = query.capture_names();
646 let mut matches = cursor.matches(query, tree.root_node(), content);
647
648 while let Some(m) = matches.next() {
649 let mut call_name = None;
650 let mut call_node = None;
651
652 for capture in m.captures {
653 let name = capture_names[capture.index as usize];
654 match name {
655 "call.name" => {
656 if let Ok(text) = capture.node.utf8_text(content) {
657 call_name = Some(text.trim().to_string());
658 }
659 }
660 "call" => call_node = Some(capture.node),
661 _ => {}
662 }
663 }
664
665 if let (Some(name), Some(node)) = (call_name, call_node)
666 && !name.is_empty()
667 {
668 calls.push(SqlFunctionCall {
669 callee_name: name,
670 span_bytes: (node.start_byte(), node.end_byte()),
671 span: Span::from_node(&node),
672 });
673 }
674 }
675
676 calls
677}
678
679fn extract_table_definitions(
681 tree: &Tree,
682 content: &[u8],
683 query: &Query,
684 helper: &mut GraphBuildHelper,
685) -> Vec<SqlDatabaseObject> {
686 let mut objects = Vec::new();
687 let mut cursor = QueryCursor::new();
688 let capture_names = query.capture_names();
689 let mut matches = cursor.matches(query, tree.root_node(), content);
690
691 while let Some(m) = matches.next() {
692 let mut table_name = None;
693 let mut table_node = None;
694
695 for capture in m.captures {
696 let name = capture_names[capture.index as usize];
697 match name {
698 "table.name" => {
699 if let Ok(text) = capture.node.utf8_text(content) {
700 table_name = Some(text.to_string());
701 }
702 }
703 "table" => table_node = Some(capture.node),
704 _ => {}
705 }
706 }
707
708 if let (Some(name), Some(node)) = (table_name, table_node) {
709 let (_, table_only) = split_schema_table(&name);
711 let span = Span::from_node(&node);
712 let node_id = helper.add_variable(table_only, Some(span));
713 objects.push(SqlDatabaseObject { node_id });
714 }
715 }
716
717 objects
718}
719
720fn extract_view_definitions(
722 tree: &Tree,
723 content: &[u8],
724 query: &Query,
725 helper: &mut GraphBuildHelper,
726) -> Vec<SqlDatabaseObject> {
727 let mut objects = Vec::new();
728 let mut cursor = QueryCursor::new();
729 let capture_names = query.capture_names();
730 let mut matches = cursor.matches(query, tree.root_node(), content);
731
732 while let Some(m) = matches.next() {
733 let mut view_name = None;
734 let mut view_node = None;
735
736 for capture in m.captures {
737 let name = capture_names[capture.index as usize];
738 match name {
739 "view.name" => {
740 if let Ok(text) = capture.node.utf8_text(content) {
741 view_name = Some(text.to_string());
742 }
743 }
744 "view" => view_node = Some(capture.node),
745 _ => {}
746 }
747 }
748
749 if let (Some(name), Some(node)) = (view_name, view_node) {
750 let (_, view_only) = split_schema_table(&name);
752 let span = Span::from_node(&node);
753 let node_id = helper.add_variable(view_only, Some(span));
754 objects.push(SqlDatabaseObject { node_id });
755 }
756 }
757
758 objects
759}
760
761fn find_enclosing_callable(
762 callables: &[SqlCallable],
763 op_span_bytes: (usize, usize),
764) -> Option<&SqlCallable> {
765 let (start_byte, end_byte) = op_span_bytes;
766 callables
767 .iter()
768 .filter(|c| c.start_byte <= start_byte && end_byte <= c.end_byte)
769 .min_by_key(|c| c.end_byte.saturating_sub(c.start_byte))
770}
771
772fn split_schema_table(name: &str) -> (Option<&str>, &str) {
773 let mut parts = name.splitn(2, '.');
774 let first = parts.next().unwrap_or(name).trim();
775 let second = parts.next().map(str::trim);
776 match second {
777 Some(table) if !table.is_empty() => (Some(first), table),
778 _ => (None, first),
779 }
780}
781
782trait SpanExt {
784 fn from_node(node: &tree_sitter::Node) -> Self;
785}
786
787impl SpanExt for Span {
788 fn from_node(node: &tree_sitter::Node) -> Self {
789 Span::new(
790 Position::new(node.start_position().row, node.start_position().column),
791 Position::new(node.end_position().row, node.end_position().column),
792 )
793 }
794}
795
796fn emit_exports(
802 helper: &mut GraphBuildHelper,
803 callables: &[SqlCallable],
804 tables: &[SqlDatabaseObject],
805 views: &[SqlDatabaseObject],
806) {
807 if callables.is_empty() && tables.is_empty() && views.is_empty() {
809 return;
810 }
811
812 let module_id = helper.add_module(FILE_MODULE_NAME, None);
814
815 for callable in callables {
817 helper.add_export_edge(module_id, callable.node_id);
818 }
819
820 for table in tables {
822 helper.add_export_edge(module_id, table.node_id);
823 }
824
825 for view in views {
827 helper.add_export_edge(module_id, view.node_id);
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834 use sqry_core::graph::unified::StagingOp;
835 use sqry_core::graph::unified::TableWriteOp;
836 use sqry_core::graph::unified::edge::EdgeKind;
837 use std::path::PathBuf;
838
839 fn parse_sql(sql: &str) -> Tree {
840 let mut parser = tree_sitter::Parser::new();
841 parser
842 .set_language(&tree_sitter_sequel::LANGUAGE.into())
843 .expect("Failed to set SQL language");
844 parser
845 .parse(sql.as_bytes(), None)
846 .expect("Failed to parse SQL")
847 }
848
849 #[allow(dead_code)]
851 fn get_table_read_edges(staging: &StagingGraph) -> Vec<String> {
852 staging
853 .operations()
854 .iter()
855 .filter_map(|op| {
856 if let StagingOp::AddEdge {
857 kind: EdgeKind::TableRead { table_name, .. },
858 ..
859 } = op
860 {
861 Some(format!("TableRead({:?})", table_name))
864 } else {
865 None
866 }
867 })
868 .collect()
869 }
870
871 #[allow(dead_code)]
873 fn get_table_write_edges(staging: &StagingGraph) -> Vec<(String, TableWriteOp)> {
874 staging
875 .operations()
876 .iter()
877 .filter_map(|op| {
878 if let StagingOp::AddEdge {
879 kind:
880 EdgeKind::TableWrite {
881 table_name,
882 operation,
883 ..
884 },
885 ..
886 } = op
887 {
888 Some((format!("TableWrite({:?})", table_name), *operation))
889 } else {
890 None
891 }
892 })
893 .collect()
894 }
895
896 fn count_table_read_edges(staging: &StagingGraph) -> usize {
898 staging
899 .operations()
900 .iter()
901 .filter(|op| {
902 matches!(
903 op,
904 StagingOp::AddEdge {
905 kind: EdgeKind::TableRead { .. },
906 ..
907 }
908 )
909 })
910 .count()
911 }
912
913 fn count_table_write_edges(staging: &StagingGraph) -> usize {
914 staging
915 .operations()
916 .iter()
917 .filter(|op| {
918 matches!(
919 op,
920 StagingOp::AddEdge {
921 kind: EdgeKind::TableWrite { .. },
922 ..
923 }
924 )
925 })
926 .count()
927 }
928
929 fn count_table_write_edges_by_op(staging: &StagingGraph, expected_op: TableWriteOp) -> usize {
930 staging
931 .operations()
932 .iter()
933 .filter(|op| {
934 matches!(
935 op,
936 StagingOp::AddEdge { kind: EdgeKind::TableWrite { operation, .. }, .. }
937 if *operation == expected_op
938 )
939 })
940 .count()
941 }
942
943 fn count_export_edges(staging: &StagingGraph) -> usize {
945 staging
946 .operations()
947 .iter()
948 .filter(|op| {
949 matches!(
950 op,
951 StagingOp::AddEdge {
952 kind: EdgeKind::Exports { .. },
953 ..
954 }
955 )
956 })
957 .count()
958 }
959
960 #[test]
961 fn test_sql_graph_builder_new() {
962 let builder = SqlGraphBuilder::new();
963 assert_eq!(builder.language(), Language::Sql);
964 }
965
966 #[test]
967 fn test_select_creates_table_read_edge() {
968 let sql = r"
969 CREATE FUNCTION get_users()
970 RETURNS TABLE (id INT, name TEXT) AS $$
971 SELECT * FROM users;
972 $$ LANGUAGE sql;
973 ";
974
975 let tree = parse_sql(sql);
976 let mut staging = StagingGraph::new();
977 let builder = SqlGraphBuilder::new();
978 let file = PathBuf::from("test.sql");
979
980 builder
981 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
982 .expect("Graph building should succeed");
983
984 let read_count = count_table_read_edges(&staging);
985 assert!(
986 read_count >= 1,
987 "Expected at least 1 TableRead edge, got {read_count}"
988 );
989 }
990
991 #[test]
992 fn test_insert_creates_table_write_edge() {
993 let sql = r"
994 CREATE FUNCTION create_user(user_name TEXT)
995 RETURNS VOID AS $$
996 INSERT INTO users (name) VALUES (user_name);
997 $$ LANGUAGE sql;
998 ";
999
1000 let tree = parse_sql(sql);
1001 let mut staging = StagingGraph::new();
1002 let builder = SqlGraphBuilder::new();
1003 let file = PathBuf::from("test.sql");
1004
1005 builder
1006 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1007 .expect("Graph building should succeed");
1008
1009 let insert_count = count_table_write_edges_by_op(&staging, TableWriteOp::Insert);
1010 assert!(
1011 insert_count >= 1,
1012 "Expected at least 1 TableWrite(Insert) edge, got {insert_count}"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_update_creates_table_write_edge() {
1018 let sql = r"
1019 CREATE FUNCTION update_user(user_id INT, new_name TEXT)
1020 RETURNS VOID AS $$
1021 UPDATE users SET name = new_name WHERE id = user_id;
1022 $$ LANGUAGE sql;
1023 ";
1024
1025 let tree = parse_sql(sql);
1026 let mut staging = StagingGraph::new();
1027 let builder = SqlGraphBuilder::new();
1028 let file = PathBuf::from("test.sql");
1029
1030 builder
1031 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1032 .expect("Graph building should succeed");
1033
1034 let update_count = count_table_write_edges_by_op(&staging, TableWriteOp::Update);
1035 assert!(
1036 update_count >= 1,
1037 "Expected at least 1 TableWrite(Update) edge, got {update_count}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_delete_creates_table_write_edge() {
1043 let sql = r"
1044 CREATE FUNCTION delete_user(user_id INT)
1045 RETURNS VOID AS $$
1046 DELETE FROM users WHERE id = user_id;
1047 $$ LANGUAGE sql;
1048 ";
1049
1050 let tree = parse_sql(sql);
1051 let mut staging = StagingGraph::new();
1052 let builder = SqlGraphBuilder::new();
1053 let file = PathBuf::from("test.sql");
1054
1055 builder
1056 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1057 .expect("Graph building should succeed");
1058
1059 let delete_count = count_table_write_edges_by_op(&staging, TableWriteOp::Delete);
1060 assert!(
1061 delete_count >= 1,
1062 "Expected at least 1 TableWrite(Delete) edge, got {delete_count}"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_join_creates_table_read_edge_for_primary_table() {
1068 let sql = r"
1071 CREATE FUNCTION get_user_orders()
1072 RETURNS TABLE (user_name TEXT, order_id INT) AS $$
1073 SELECT u.name, o.id FROM users u JOIN orders o ON u.id = o.user_id;
1074 $$ LANGUAGE sql;
1075 ";
1076
1077 let tree = parse_sql(sql);
1078 let mut staging = StagingGraph::new();
1079 let builder = SqlGraphBuilder::new();
1080 let file = PathBuf::from("test.sql");
1081
1082 builder
1083 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1084 .expect("Graph building should succeed");
1085
1086 let read_count = count_table_read_edges(&staging);
1088 assert!(
1089 read_count >= 1,
1090 "Expected at least 1 TableRead edge for FROM clause, got {read_count}"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_multiple_joins_creates_table_read_edge_for_primary_table() {
1096 let sql = r"
1098 CREATE FUNCTION get_order_details()
1099 RETURNS TABLE (user_name TEXT, product_name TEXT, quantity INT) AS $$
1100 SELECT u.name, p.name, o.quantity
1101 FROM users u
1102 JOIN orders o ON u.id = o.user_id
1103 LEFT JOIN products p ON o.product_id = p.id;
1104 $$ LANGUAGE sql;
1105 ";
1106
1107 let tree = parse_sql(sql);
1108 let mut staging = StagingGraph::new();
1109 let builder = SqlGraphBuilder::new();
1110 let file = PathBuf::from("test.sql");
1111
1112 builder
1113 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1114 .expect("Graph building should succeed");
1115
1116 let read_count = count_table_read_edges(&staging);
1118 assert!(
1119 read_count >= 1,
1120 "Expected at least 1 TableRead edge for FROM clause, got {read_count}"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_mixed_read_write_operations() {
1126 let sql = r"
1128 CREATE FUNCTION transfer_funds(from_id INT, to_id INT, amount DECIMAL)
1129 RETURNS VOID AS $$
1130 BEGIN
1131 SELECT balance FROM accounts WHERE id = from_id;
1132 UPDATE accounts SET balance = balance - amount WHERE id = from_id;
1133 UPDATE accounts SET balance = balance + amount WHERE id = to_id;
1134 INSERT INTO transactions (from_account, to_account, amount) VALUES (from_id, to_id, amount);
1135 END;
1136 $$ LANGUAGE plpgsql;
1137 ";
1138
1139 let tree = parse_sql(sql);
1140 let mut staging = StagingGraph::new();
1141 let builder = SqlGraphBuilder::new();
1142 let file = PathBuf::from("test.sql");
1143
1144 builder
1145 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1146 .expect("Graph building should succeed");
1147
1148 let read_count = count_table_read_edges(&staging);
1149 let write_count = count_table_write_edges(&staging);
1150
1151 assert!(
1153 read_count >= 1,
1154 "Expected at least 1 TableRead edge, got {read_count}"
1155 );
1156 assert!(
1157 write_count >= 1,
1158 "Expected at least 1 TableWrite edge, got {write_count}"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_schema_qualified_table_name() {
1164 let sql = r"
1165 CREATE FUNCTION get_public_users()
1166 RETURNS TABLE (id INT, name TEXT) AS $$
1167 SELECT * FROM public.users;
1168 $$ LANGUAGE sql;
1169 ";
1170
1171 let tree = parse_sql(sql);
1172 let mut staging = StagingGraph::new();
1173 let builder = SqlGraphBuilder::new();
1174 let file = PathBuf::from("test.sql");
1175
1176 let result = builder.build_graph(&tree, sql.as_bytes(), &file, &mut staging);
1178 assert!(result.is_ok(), "Should handle schema-qualified table names");
1179 }
1180
1181 #[test]
1182 fn test_split_schema_table_with_schema() {
1183 let (schema, table) = split_schema_table("public.users");
1184 assert_eq!(schema, Some("public"));
1185 assert_eq!(table, "users");
1186 }
1187
1188 #[test]
1189 fn test_split_schema_table_without_schema() {
1190 let (schema, table) = split_schema_table("users");
1191 assert_eq!(schema, None);
1192 assert_eq!(table, "users");
1193 }
1194
1195 #[test]
1196 fn test_split_schema_table_with_whitespace() {
1197 let (schema, table) = split_schema_table(" public . users ");
1198 assert_eq!(schema, Some("public"));
1199 assert_eq!(table, "users");
1200 }
1201
1202 #[test]
1203 fn test_empty_sql_file() {
1204 let sql = "";
1205 let tree = parse_sql(sql);
1206 let mut staging = StagingGraph::new();
1207 let builder = SqlGraphBuilder::new();
1208 let file = PathBuf::from("empty.sql");
1209
1210 let result = builder.build_graph(&tree, sql.as_bytes(), &file, &mut staging);
1211 assert!(result.is_ok(), "Should handle empty SQL files");
1212 }
1213
1214 #[test]
1215 fn test_standalone_select_without_function() {
1216 let sql = "SELECT * FROM users;";
1218
1219 let tree = parse_sql(sql);
1220 let mut staging = StagingGraph::new();
1221 let builder = SqlGraphBuilder::new();
1222 let file = PathBuf::from("query.sql");
1223
1224 builder
1225 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1226 .expect("Graph building should succeed");
1227
1228 let read_count = count_table_read_edges(&staging);
1231 assert_eq!(
1232 read_count, 0,
1233 "Standalone SELECT should not create edges without enclosing function"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_export_edges_for_table_definitions() {
1239 let sql = r"
1240 CREATE TABLE users (
1241 id SERIAL PRIMARY KEY,
1242 name TEXT NOT NULL
1243 );
1244
1245 CREATE TABLE orders (
1246 id SERIAL PRIMARY KEY,
1247 user_id INTEGER REFERENCES users(id)
1248 );
1249 ";
1250
1251 let tree = parse_sql(sql);
1252 let mut staging = StagingGraph::new();
1253 let builder = SqlGraphBuilder::new();
1254 let file = PathBuf::from("schema.sql");
1255
1256 builder
1257 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1258 .expect("Graph building should succeed");
1259
1260 let export_count = count_export_edges(&staging);
1261 assert_eq!(
1262 export_count, 2,
1263 "Expected 2 Export edges (users and orders), got {export_count}"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_export_edges_for_view_definitions() {
1269 let sql = r"
1270 CREATE TABLE users (id INT, created_at TIMESTAMP);
1271
1272 CREATE VIEW active_users AS
1273 SELECT * FROM users WHERE created_at > NOW() - INTERVAL '30 days';
1274
1275 CREATE MATERIALIZED VIEW user_stats AS
1276 SELECT COUNT(*) as total FROM users;
1277 ";
1278
1279 let tree = parse_sql(sql);
1280 let mut staging = StagingGraph::new();
1281 let builder = SqlGraphBuilder::new();
1282 let file = PathBuf::from("views.sql");
1283
1284 builder
1285 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1286 .expect("Graph building should succeed");
1287
1288 let export_count = count_export_edges(&staging);
1289 assert_eq!(
1291 export_count, 3,
1292 "Expected 3 Export edges (1 table + 2 views), got {export_count}"
1293 );
1294 }
1295
1296 #[test]
1297 fn test_export_edges_for_functions_and_triggers() {
1298 let sql = r"
1299 CREATE FUNCTION get_balance(account_id INT) RETURNS BIGINT AS $$
1300 BEGIN
1301 RETURN 42;
1302 END;
1303 $$ LANGUAGE plpgsql;
1304
1305 CREATE FUNCTION update_balance() RETURNS TRIGGER AS $$
1306 BEGIN
1307 RETURN NEW;
1308 END;
1309 $$ LANGUAGE plpgsql;
1310
1311 CREATE TRIGGER balance_updated
1312 BEFORE INSERT ON accounts
1313 FOR EACH ROW
1314 EXECUTE FUNCTION update_balance();
1315 ";
1316
1317 let tree = parse_sql(sql);
1318 let mut staging = StagingGraph::new();
1319 let builder = SqlGraphBuilder::new();
1320 let file = PathBuf::from("banking.sql");
1321
1322 builder
1323 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1324 .expect("Graph building should succeed");
1325
1326 let export_count = count_export_edges(&staging);
1327 assert!(
1331 export_count >= 3,
1332 "Expected at least 3 Export edges (2 functions + 1 trigger), got {export_count}"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_export_edges_with_schema_qualified_names() {
1338 let sql = r"
1339 CREATE TABLE public.customers (
1340 id SERIAL PRIMARY KEY,
1341 name TEXT NOT NULL
1342 );
1343
1344 CREATE FUNCTION public.get_customer_name(cust_id INT) RETURNS TEXT AS $$
1345 BEGIN
1346 RETURN 'test';
1347 END;
1348 $$ LANGUAGE plpgsql;
1349 ";
1350
1351 let tree = parse_sql(sql);
1352 let mut staging = StagingGraph::new();
1353 let builder = SqlGraphBuilder::new();
1354 let file = PathBuf::from("public_schema.sql");
1355
1356 builder
1357 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1358 .expect("Graph building should succeed");
1359
1360 let export_count = count_export_edges(&staging);
1361 assert_eq!(
1363 export_count, 2,
1364 "Expected 2 Export edges (table + function), got {export_count}"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_mixed_database_objects_exports() {
1370 let sql = r"
1371 CREATE TABLE accounts (
1372 id SERIAL PRIMARY KEY,
1373 balance_cents BIGINT NOT NULL
1374 );
1375
1376 CREATE VIEW positive_balances AS
1377 SELECT * FROM accounts WHERE balance_cents > 0;
1378
1379 CREATE FUNCTION get_balance(account_id INT) RETURNS BIGINT AS $$
1380 BEGIN
1381 RETURN (SELECT balance_cents FROM accounts WHERE id = account_id);
1382 END;
1383 $$ LANGUAGE plpgsql;
1384 ";
1385
1386 let tree = parse_sql(sql);
1387 let mut staging = StagingGraph::new();
1388 let builder = SqlGraphBuilder::new();
1389 let file = PathBuf::from("mixed.sql");
1390
1391 builder
1392 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1393 .expect("Graph building should succeed");
1394
1395 let export_count = count_export_edges(&staging);
1396 assert_eq!(
1398 export_count, 3,
1399 "Expected 3 Export edges (table + view + function), got {export_count}"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_no_exports_for_empty_file() {
1405 let sql = "";
1406 let tree = parse_sql(sql);
1407 let mut staging = StagingGraph::new();
1408 let builder = SqlGraphBuilder::new();
1409 let file = PathBuf::from("empty.sql");
1410
1411 builder
1412 .build_graph(&tree, sql.as_bytes(), &file, &mut staging)
1413 .expect("Graph building should succeed");
1414
1415 let export_count = count_export_edges(&staging);
1416 assert_eq!(
1417 export_count, 0,
1418 "Expected 0 Export edges for empty file, got {export_count}"
1419 );
1420 }
1421}