Skip to main content

sqry_lang_sql/relations/
graph_builder.rs

1//! SQL `GraphBuilder` implementation for code graph construction.
2//!
3//! Extracts SQL-specific relationships:
4//! - Procedure/function definitions and calls
5//! - Trigger definitions and activations
6//! - Table read operations (SELECT)
7//! - Table write operations (INSERT, UPDATE, DELETE, CREATE/DROP/ALTER TABLE)
8
9use 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
45/// File-level module name for SQL exports.
46///
47/// Used to represent the SQL file as a module that exports all top-level
48/// symbols (functions, procedures, views, tables, triggers).
49const FILE_MODULE_NAME: &str = "<file_module>";
50
51/// SQL-specific `GraphBuilder` implementation.
52///
53/// Performs multi-pass analysis:
54/// 1. Extract procedure/function definitions
55/// 2. Extract trigger definitions
56/// 3. Extract table access patterns (reads and writes)
57/// 4. Synthesize edges between entities
58/// 5. Emit Export edges from file module to exported symbols
59#[derive(Debug, Default, Clone, Copy)]
60pub struct SqlGraphBuilder;
61
62impl SqlGraphBuilder {
63    /// Create a new SQL `GraphBuilder`.
64    #[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        // Create helper for staging graph population
79        let mut helper = GraphBuildHelper::new(staging, file, Language::Sql);
80
81        // Compile tree-sitter queries
82        let language = tree_sitter_sequel::LANGUAGE.into();
83        let queries = SqlQueries::new(&language)?;
84
85        // Extract procedure/function definitions
86        let mut callables = extract_procedures(tree, content, &queries.procedures, &mut helper);
87
88        // Extract trigger definitions
89        callables.extend(extract_triggers(
90            tree,
91            content,
92            &queries.triggers,
93            &mut helper,
94        ));
95
96        // Extract table read operations
97        let table_reads = extract_table_reads(tree, content, &queries.table_reads, &mut helper);
98
99        // Extract table write operations
100        let table_writes = extract_table_writes(tree, content, &queries.table_writes, &mut helper);
101
102        // Extract function/procedure calls
103        let function_calls = extract_function_calls(tree, content, &queries.function_calls);
104
105        // Extract table definitions (CREATE TABLE)
106        let table_definitions =
107            extract_table_definitions(tree, content, &queries.table_definitions, &mut helper);
108
109        // Extract view definitions (CREATE VIEW, CREATE MATERIALIZED VIEW)
110        let view_definitions =
111            extract_view_definitions(tree, content, &queries.view_definitions, &mut helper);
112
113        // Synthesize edges from callables to table operations based on lexical containment.
114        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        // Synthesize call edges from callables to called functions based on lexical containment.
139        for call in function_calls {
140            // Find enclosing callable (procedure/function/trigger)
141            if let Some(caller) = find_enclosing_callable(&callables, call.span_bytes) {
142                // Create callee function node and add call edge
143                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            // Note: Top-level calls outside procedures are skipped to avoid
154            // creating a synthetic module-level caller (which could cause node kind collisions)
155        }
156
157        // Extract and create call edges for trigger EXECUTE FUNCTION
158        // This captures the relationship: trigger -> executed function
159        extract_trigger_execute_function_calls(
160            tree,
161            content,
162            &queries.trigger_execute_function,
163            &callables,
164            &mut helper,
165        );
166
167        // Emit Export edges for all database objects (callables, tables, views)
168        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
183/// Tree-sitter queries for SQL relationship extraction.
184struct 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    // Query construction is verbose but kept together for clarity.
197    #[allow(clippy::too_many_lines)]
198    fn new(language: &tree_sitter::Language) -> GraphResult<Self> {
199        // Query for procedure/function definitions (covers both)
200        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        // Query for trigger definitions
214        // Note: Both trigger name and table name are in object_reference nodes
215        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        // Query for trigger EXECUTE FUNCTION (to create call edges)
232        // Captures trigger name and the function being executed
233        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        // Query for table reads (SELECT statements)
251        // AST structure: (statement (select ...) (from (keyword_from) (relation (object_reference name: (identifier)))))
252        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        // Query for table writes (INSERT, UPDATE, DELETE statements)
270        // INSERT: (insert ... (object_reference name: (identifier)))
271        // UPDATE: (update (relation (object_reference name: (identifier))))
272        // DELETE: (statement (delete) (from (keyword_from) (object_reference name: (identifier))))
273        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        // Query for function/procedure calls
301        // Includes function invocations like: function_name(args) or schema.function_name(args)
302        // Note: All invocation nodes use (object_reference name: (identifier)) pattern
303        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        // Query for table definitions (CREATE TABLE)
317        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        // Query for view definitions (CREATE VIEW, CREATE MATERIALIZED VIEW)
331        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
362/// Extract procedure/function definitions from the AST.
363fn 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
404/// Extract trigger definitions from the AST.
405fn 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
466/// Extract trigger EXECUTE FUNCTION call edges.
467///
468/// Creates call edges from triggers to the functions they execute via
469/// the `EXECUTE FUNCTION function_name()` clause.
470fn 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            // Find the trigger's node ID from callables
510            if let Some(trigger_callable) = callables.iter().find(|c| {
511                // Match by byte range overlap (trigger definition contains this execute)
512                c.start_byte <= node.start_byte() && node.end_byte() <= c.end_byte
513            }) {
514                // Create callee function node (or reuse existing) and add call edge
515                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
528/// Extract table read operations (SELECT statements).
529fn 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
575/// Extract table write operations (INSERT, UPDATE, DELETE statements).
576fn 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/// SQL function call information
634#[derive(Debug)]
635struct SqlFunctionCall {
636    callee_name: String,
637    span_bytes: (usize, usize),
638    span: Span,
639}
640
641/// Extract function/procedure calls from the AST.
642fn 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
679/// Extract table definitions from the AST (CREATE TABLE statements).
680fn 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            // Strip schema prefix if present (e.g., "public.users" -> "users")
710            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
720/// Extract view definitions from the AST (CREATE VIEW and CREATE MATERIALIZED VIEW statements).
721fn 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            // Strip schema prefix if present
751            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
782// Helper extension trait for Span creation
783trait 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
796/// Emit Export edges for all SQL database objects in the file.
797///
798/// Creates a file-level module and establishes Export edges from it to all
799/// top-level SQL symbols (callables, tables, views). This allows queries to
800/// discover exported symbols.
801fn emit_exports(
802    helper: &mut GraphBuildHelper,
803    callables: &[SqlCallable],
804    tables: &[SqlDatabaseObject],
805    views: &[SqlDatabaseObject],
806) {
807    // Only create module if there are objects to export
808    if callables.is_empty() && tables.is_empty() && views.is_empty() {
809        return;
810    }
811
812    // Create the file-level module node
813    let module_id = helper.add_module(FILE_MODULE_NAME, None);
814
815    // Emit Export edges for callables (procedures, functions, triggers)
816    for callable in callables {
817        helper.add_export_edge(module_id, callable.node_id);
818    }
819
820    // Emit Export edges for tables
821    for table in tables {
822        helper.add_export_edge(module_id, table.node_id);
823    }
824
825    // Emit Export edges for views
826    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    /// Helper to extract table read edges from staging operations
850    #[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                    // table_name is a StringId, we need to look it up
862                    // For testing, we just check that the edge was created
863                    Some(format!("TableRead({:?})", table_name))
864                } else {
865                    None
866                }
867            })
868            .collect()
869    }
870
871    /// Helper to extract table write edges from staging operations
872    #[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    /// Helper to count edges of a specific kind
897    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    /// Helper to count Export edges from staging operations
944    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        // NOTE: tree-sitter-sequel only captures the primary FROM table, not JOINed tables
1069        // This is a grammar limitation, not a bug in our query
1070        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        // Should have at least 1 TableRead edge (users - the primary FROM table)
1087        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        // NOTE: tree-sitter-sequel only captures the primary FROM table, not JOINed tables
1097        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        // Should have at least 1 TableRead edge (users - the primary FROM table)
1117        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        // NOTE: tree-sitter-sequel requires BEGIN...END; for multiple statements
1127        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        // We expect at least 1 read (accounts) and multiple writes
1152        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        // Should not fail on schema-qualified names
1177        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        // Standalone SELECT should not create edges (no enclosing callable)
1217        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        // Table reads outside functions are NOT synthesized into edges
1229        // because there's no enclosing callable to serve as the edge source
1230        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        // Expect 3 exports: 1 table (users) + 2 views (active_users, user_stats)
1290        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        // Note: The trigger "BEFORE INSERT ON accounts" creates a variable node for "accounts" table.
1328        // Tree-sitter might also capture the INSERT keyword, creating an additional table node.
1329        // We expect at least 3 exports (2 functions + 1 trigger), but may get 4 due to the table reference.
1330        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        // Expect 2 exports: 1 table (customers, schema stripped) + 1 function (get_customer_name)
1362        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        // Expect 3 exports: 1 table (accounts) + 1 view (positive_balances) + 1 function (get_balance)
1397        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}