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::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
49/// File-level module name for SQL exports.
50///
51/// Used to represent the SQL file as a module that exports all top-level
52/// symbols (functions, procedures, views, tables, triggers).
53const FILE_MODULE_NAME: &str = "<file_module>";
54
55/// SQL-specific `GraphBuilder` implementation.
56///
57/// Performs multi-pass analysis:
58/// 1. Extract procedure/function definitions
59/// 2. Extract trigger definitions
60/// 3. Extract table access patterns (reads and writes)
61/// 4. Synthesize edges between entities
62/// 5. Emit Export edges from file module to exported symbols
63#[derive(Debug, Default, Clone, Copy)]
64pub struct SqlGraphBuilder;
65
66impl SqlGraphBuilder {
67    /// Create a new SQL `GraphBuilder`.
68    #[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        // Create helper for staging graph population
83        let mut helper = GraphBuildHelper::new(staging, file, Language::Sql);
84
85        // Compile tree-sitter queries
86        let language = tree_sitter_sequel::LANGUAGE.into();
87        let queries = SqlQueries::new(&language)?;
88
89        // Extract procedure/function definitions
90        let mut callables = extract_procedures(tree, content, &queries.procedures, &mut helper);
91
92        // Extract trigger definitions
93        callables.extend(extract_triggers(
94            tree,
95            content,
96            &queries.triggers,
97            &mut helper,
98        ));
99
100        // Extract table read operations
101        let table_reads = extract_table_reads(tree, content, &queries.table_reads, &mut helper);
102
103        // Extract table write operations
104        let table_writes = extract_table_writes(tree, content, &queries.table_writes, &mut helper);
105
106        // Extract function/procedure calls
107        let function_calls = extract_function_calls(tree, content, &queries.function_calls);
108
109        // Extract table definitions (CREATE TABLE)
110        let table_definitions =
111            extract_table_definitions(tree, content, &queries.table_definitions, &mut helper);
112
113        // Extract view definitions (CREATE VIEW, CREATE MATERIALIZED VIEW)
114        let view_definitions =
115            extract_view_definitions(tree, content, &queries.view_definitions, &mut helper);
116
117        // Synthesize edges from callables to table operations based on lexical containment.
118        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        // Synthesize call edges from callables to called functions based on lexical containment.
143        for call in function_calls {
144            // Find enclosing callable (procedure/function/trigger)
145            if let Some(caller) = find_enclosing_callable(&callables, call.span_bytes) {
146                // Create callee function node and add call edge
147                // Call-site extent, not a declaration of the callee (issue #748).
148                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            // Note: Top-level calls outside procedures are skipped to avoid
159            // creating a synthetic module-level caller (which could cause node kind collisions)
160        }
161
162        // Extract and create call edges for trigger EXECUTE FUNCTION
163        // This captures the relationship: trigger -> executed function
164        extract_trigger_execute_function_calls(
165            tree,
166            content,
167            &queries.trigger_execute_function,
168            &callables,
169            &mut helper,
170        );
171
172        // Emit Export edges for all database objects (callables, tables, views)
173        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
192/// Per-language [`ShapeMapping`] for SQL (tree-sitter-sequel).
193///
194/// SQL functions and procedures are real graph callables (`create_function`),
195/// so the body-shape descriptor applies to them. The control flow that the
196/// grammar parses lives inside `LANGUAGE sql` bodies (CASE expressions, set
197/// operations, subqueries); `LANGUAGE plpgsql` and other dollar-quoted bodies
198/// are opaque text to this grammar, so a procedural body with no parsed CF
199/// kinds yields a near-empty histogram, which is the honest result. The
200/// mapping is built once from the grammar and shared process-wide.
201pub 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        // `create_function` exposes the argument list as a `function_arguments`
236        // node (no field name), so locate it among the named children.
237        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                        // A `keyword_default` token inside the argument marks a
245                        // default value.
246                        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
260/// Map one tree-sitter-sequel grammar node-kind name to its canonical
261/// control-flow bucket. Additive-only against the frozen [`CfBucket`] set.
262fn cf_bucket_for_sql_kind(name: &str) -> Option<CfBucket> {
263    let bucket = match name {
264        // `CASE ... WHEN ... THEN ... END` is the SQL conditional construct.
265        "case" => CfBucket::Match,
266        "when_clause" => CfBucket::Branch,
267        // Procedural assignment (`x := y`) inside parsed routine bodies.
268        "assignment" => CfBucket::Assign,
269        // Function/aggregate invocations carried by the query body.
270        "invocation" => CfBucket::Call,
271        _ => return None,
272    };
273    Some(bucket)
274}
275
276/// The process-wide SQL shape mapping, built once on first use.
277#[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
283/// Tree-sitter queries for SQL relationship extraction.
284struct 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    // Query construction is verbose but kept together for clarity.
297    #[allow(clippy::too_many_lines)]
298    fn new(language: &tree_sitter::Language) -> GraphResult<Self> {
299        // Query for procedure/function definitions (covers both)
300        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        // Query for trigger definitions
314        // Note: Both trigger name and table name are in object_reference nodes
315        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        // Query for trigger EXECUTE FUNCTION (to create call edges)
332        // Captures trigger name and the function being executed
333        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        // Query for table reads (SELECT statements)
351        // AST structure: (statement (select ...) (from (keyword_from) (relation (object_reference name: (identifier)))))
352        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        // Query for table writes (INSERT, UPDATE, DELETE statements)
370        // INSERT: (insert ... (object_reference name: (identifier)))
371        // UPDATE: (update (relation (object_reference name: (identifier))))
372        // DELETE: (statement (delete) (from (keyword_from) (object_reference name: (identifier))))
373        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        // Query for function/procedure calls.
401        // Includes:
402        // - normal invocations like function_name(args) or schema.function_name(args)
403        // - PL/pgSQL assignment forms that tree-sitter-sequel currently surfaces as
404        //   ERROR nodes, e.g. `sum_val := add(x, y);`
405        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        // Query for table definitions (CREATE TABLE)
428        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        // Query for view definitions (CREATE VIEW, CREATE MATERIALIZED VIEW)
442        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
473/// Extract procedure/function definitions from the AST.
474fn 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            // issue #394: real declaration; opt dual-use bare helper into is_definition
505            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
517/// Extract trigger definitions from the AST.
518fn 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            // issue #394: real declaration; opt dual-use bare helper into is_definition
560            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
581/// Extract trigger EXECUTE FUNCTION call edges.
582///
583/// Creates call edges from triggers to the functions they execute via
584/// the `EXECUTE FUNCTION function_name()` clause.
585fn 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            // Find the trigger's node ID from callables
625            if let Some(trigger_callable) = callables.iter().find(|c| {
626                // Match by byte range overlap (trigger definition contains this execute)
627                c.start_byte <= node.start_byte() && node.end_byte() <= c.end_byte
628            }) {
629                // Create callee function node (or reuse existing) and add call edge
630                // EXECUTE FUNCTION extent, not a declaration (issue #748).
631                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
644/// Extract table read operations (SELECT statements).
645fn 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
691/// Extract table write operations (INSERT, UPDATE, DELETE statements).
692fn 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/// SQL function call information
750#[derive(Debug)]
751struct SqlFunctionCall {
752    callee_name: String,
753    span_bytes: (usize, usize),
754    span: Span,
755}
756
757/// Extract function/procedure calls from the AST.
758fn 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
866/// Extract table definitions from the AST (CREATE TABLE statements).
867fn 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            // Strip schema prefix if present (e.g., "public.users" -> "users")
897            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            // issue #394: real declaration; opt dual-use bare helper into is_definition
901            helper.mark_definition(node_id);
902            objects.push(SqlDatabaseObject { node_id });
903        }
904    }
905
906    objects
907}
908
909/// Extract view definitions from the AST (CREATE VIEW and CREATE MATERIALIZED VIEW statements).
910fn 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            // Strip schema prefix if present
940            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            // issue #394: real declaration; opt dual-use bare helper into is_definition
944            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
973/// Emit Export edges for all SQL database objects in the file.
974///
975/// Creates a file-level module and establishes Export edges from it to all
976/// top-level SQL symbols (callables, tables, views). This allows queries to
977/// discover exported symbols.
978fn emit_exports(
979    helper: &mut GraphBuildHelper,
980    callables: &[SqlCallable],
981    tables: &[SqlDatabaseObject],
982    views: &[SqlDatabaseObject],
983) {
984    // Only create module if there are objects to export
985    if callables.is_empty() && tables.is_empty() && views.is_empty() {
986        return;
987    }
988
989    // Create the file-level module node
990    let module_id = helper.add_module(FILE_MODULE_NAME, None);
991
992    // Emit Export edges for callables (procedures, functions, triggers)
993    for callable in callables {
994        helper.add_export_edge(module_id, callable.node_id);
995    }
996
997    // Emit Export edges for tables
998    for table in tables {
999        helper.add_export_edge(module_id, table.node_id);
1000    }
1001
1002    // Emit Export edges for views
1003    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    /// Helper to extract table read edges from staging operations
1027    #[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                    // table_name is a StringId, we need to look it up
1039                    // For testing, we just check that the edge was created
1040                    Some(format!("TableRead({table_name:?})"))
1041                } else {
1042                    None
1043                }
1044            })
1045            .collect()
1046    }
1047
1048    /// Helper to extract table write edges from staging operations
1049    #[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    /// Helper to count edges of a specific kind
1074    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    /// Helper to count Export edges from staging operations
1137    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        // NOTE: tree-sitter-sequel only captures the primary FROM table, not JOINed tables
1262        // This is a grammar limitation, not a bug in our query
1263        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        // Should have at least 1 TableRead edge (users - the primary FROM table)
1280        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        // NOTE: tree-sitter-sequel only captures the primary FROM table, not JOINed tables
1290        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        // Should have at least 1 TableRead edge (users - the primary FROM table)
1310        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        // NOTE: tree-sitter-sequel requires BEGIN...END; for multiple statements
1320        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        // We expect at least 1 read (accounts) and multiple writes
1345        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        // Should not fail on schema-qualified names
1454        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        // Standalone SELECT should not create edges (no enclosing callable)
1494        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        // Table reads outside functions are NOT synthesized into edges
1506        // because there's no enclosing callable to serve as the edge source
1507        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        // Expect 3 exports: 1 table (users) + 2 views (active_users, user_stats)
1567        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        // Note: The trigger "BEFORE INSERT ON accounts" creates a variable node for "accounts" table.
1605        // Tree-sitter might also capture the INSERT keyword, creating an additional table node.
1606        // We expect at least 3 exports (2 functions + 1 trigger), but may get 4 due to the table reference.
1607        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        // Expect 2 exports: 1 table (customers, schema stripped) + 1 function (get_customer_name)
1639        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        // Expect 3 exports: 1 table (accounts) + 1 view (positive_balances) + 1 function (get_balance)
1674        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    /// Resolve the first `create_function` node anywhere in the tree.
1719    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        // The grammar exposes `case`/`when_clause` as named kinds; confirm the
1742        // mapping resolves them to the canonical buckets by id, not by guesswork.
1743        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}