diff --git a/crates/mehen-sql/src/composite.rs b/crates/mehen-sql/src/composite.rs index f4d7e4a20..eba3c9e93 100644 --- a/crates/mehen-sql/src/composite.rs +++ b/crates/mehen-sql/src/composite.rs @@ -80,8 +80,10 @@ pub(crate) fn compute( } } -/// SQL Structural Complexity (research foundation §8.1). -fn structural(f: &SqlFileFacts) -> f64 { +/// SQL Structural Complexity (research foundation §8.1). Also reused by the +/// procedural module to score the query constructs embedded in one routine +/// (`sql.structural_complexity.max_embedded_query`, §9.3). +pub(crate) fn structural(f: &SqlFileFacts) -> f64 { 1.00 * f.query_block_count as f64 + 0.80 * f.ctes.count as f64 + 1.20 * f.ctes.max_dependency_depth as f64 @@ -175,11 +177,9 @@ fn modularization_credit(f: &SqlFileFacts) -> f64 { /// SQL Change Risk Score (research foundation §8.4). /// -/// Phase-1 deviation: the spec's `+ 5 * dynamic_sql_count` term is omitted -/// because dynamic SQL (`EXECUTE IMMEDIATE`, `sp_executesql`, …) is a -/// procedural-dialect construct not yet tracked (Phase 3). Every other term -/// matches the spec weights exactly. When dynamic-SQL detection lands, add the -/// `+ 5 * dynamic_sql_count` term here. +/// Every term matches the spec weights exactly, including the +/// `+ 5 × dynamic_sql_count` term (Phase 3 — `EXECUTE IMMEDIATE`, +/// `sp_executesql`, `EXEC('…')`, `DBMS_SQL`). fn change_risk(f: &SqlFileFacts) -> f64 { let o = &f.objects; ChangeRiskFactor::Drop.amount() * o.drop_count as f64 @@ -188,6 +188,7 @@ fn change_risk(f: &SqlFileFacts) -> f64 { + ChangeRiskFactor::DeleteWithoutWhere.amount() * o.delete_without_where_count as f64 + ChangeRiskFactor::UpdateWithoutWhere.amount() * o.update_without_where_count as f64 + ChangeRiskFactor::GrantRevoke.amount() * o.grant_revoke_count as f64 + + ChangeRiskFactor::DynamicSql.amount() * f.procedural.dynamic_sql_count as f64 + ChangeRiskFactor::Merge.amount() * o.merge_count as f64 + ChangeRiskFactor::CreateOrReplace.amount() * o.create_or_replace_count as f64 + ChangeRiskFactor::TransactionControl.amount() * o.transaction_control_count as f64 diff --git a/crates/mehen-sql/src/facts.rs b/crates/mehen-sql/src/facts.rs index 0b907c06f..5175e4310 100644 --- a/crates/mehen-sql/src/facts.rs +++ b/crates/mehen-sql/src/facts.rs @@ -18,7 +18,7 @@ //! (see [`extract_cte_graph`]). use mehen_core::SourceSpan; -use sqruff_lib_core::dialects::Dialect; +use sqruff_lib_core::dialects::init::DialectKind; use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet}; use sqruff_lib_core::parser::segments::ErasedSegment; @@ -45,6 +45,12 @@ pub(crate) enum StatementKind { TransactionControl, Explain, Procedural, + /// `DECLARE … BEGIN … END` anonymous blocks and top-level procedural + /// scripting statements (T-SQL `IF`/`WHILE`/`BEGIN` batch statements, + /// BigQuery scripting). Unlike a routine definition, these *execute when + /// the file is applied*, so their body DML/TCL feeds the object-touch + /// and change-risk scans (research foundation §5.2 `anonymous_block`). + AnonymousBlock, SetOperation, Unknown, } @@ -71,6 +77,7 @@ impl StatementKind { StatementKind::TransactionControl, StatementKind::Explain, StatementKind::Procedural, + StatementKind::AnonymousBlock, StatementKind::SetOperation, StatementKind::Unknown, ]; @@ -96,6 +103,7 @@ impl StatementKind { StatementKind::TransactionControl => "transaction_control", StatementKind::Explain => "explain", StatementKind::Procedural => "procedural", + StatementKind::AnonymousBlock => "anonymous_block", StatementKind::SetOperation => "set_operation", StatementKind::Unknown => "unknown", } @@ -119,14 +127,19 @@ pub(crate) struct JoinFacts { pub total: u32, } -/// Predicate / boolean-logic facts (research foundation §6.7). +/// Predicate / boolean-logic facts (research foundation §6.7). `IN`/`LIKE`/ +/// `BETWEEN` predicates fold into `comparison_count` per §6.7; IN-subqueries +/// are counted separately as `sql.subquery.in_count`. #[derive(Clone, Debug, Default)] pub(crate) struct PredicateFacts { pub boolean_operator_count: u32, pub max_boolean_depth: u32, pub not_count: u32, + /// Byte ranges of the counted `NOT` tokens — one `MetricEvidence` entry + /// each under `sql.predicate.not_count` (Codex P1). Lines are resolved + /// at collection time in `lib.rs`. + pub not_spans: Vec<(u32, u32)>, pub comparison_count: u32, - pub in_count: u32, /// `NOT IN`, `= NULL`, `<> NULL` and similar dialect-risky NULL logic. pub null_semantics_risk_count: u32, } @@ -268,6 +281,7 @@ pub(crate) enum ChangeRiskFactor { DeleteWithoutWhere, UpdateWithoutWhere, GrantRevoke, + DynamicSql, Merge, CreateOrReplace, TransactionControl, @@ -280,7 +294,7 @@ impl ChangeRiskFactor { match self { Self::Drop | Self::Truncate => 8.0, Self::Alter | Self::DeleteWithoutWhere | Self::UpdateWithoutWhere => 6.0, - Self::GrantRevoke => 5.0, + Self::GrantRevoke | Self::DynamicSql => 5.0, Self::Merge | Self::CreateOrReplace => 4.0, Self::TransactionControl => 3.0, Self::WriteObject => 2.0, @@ -296,6 +310,7 @@ impl ChangeRiskFactor { Self::DeleteWithoutWhere => "sql.change_risk.delete_without_where", Self::UpdateWithoutWhere => "sql.change_risk.update_without_where", Self::GrantRevoke => "sql.change_risk.grant_revoke", + Self::DynamicSql => "sql.change_risk.dynamic_sql", Self::Merge => "sql.change_risk.merge", Self::CreateOrReplace => "sql.change_risk.create_or_replace", Self::TransactionControl => "sql.change_risk.transaction_control", @@ -312,6 +327,17 @@ pub(crate) struct ChangeRiskEvidence { pub factor: ChangeRiskFactor, } +/// One evidence entry for a raw object-family counter (`sql.dml.*`, +/// `sql.ddl.*`, `sql.dcl.*`, `sql.transaction.*`): the statement or block +/// DML node that moved the metric (Codex P1). `metric == Σ evidence` holds +/// per key because every increment site records one entry. +#[derive(Clone, Debug)] +pub(crate) struct ObjectEvidence { + pub metric: &'static str, + pub reason: &'static str, + pub span: SourceSpan, +} + /// Per-statement facts with source span (research foundation §5.2). #[derive(Clone, Debug)] pub(crate) struct StatementFacts { @@ -341,6 +367,16 @@ pub(crate) struct ProceduralUnitFacts { pub end_line: u32, pub start_byte: u32, pub end_byte: u32, + /// Per-unit procedural composite tallies (Phase 3): the share of the + /// file's cyclomatic/cognitive increments whose source position falls + /// inside this unit (innermost-unit attribution), plus this unit's own + /// entry path. Zero for units whose bodies the parser lost to a sibling + /// `Unparsable` run — those increments stay file-level. + pub cyclomatic_complexity: f64, + pub cognitive_complexity: f64, + /// `sql.structural_complexity` of the query constructs embedded in this + /// unit's subtree (§9.3 `max_embedded_query` feeds from these). + pub embedded_query_structural: f64, } /// Halstead operator/operand tallies (research foundation §7). Operators and @@ -360,6 +396,8 @@ pub(crate) struct SqlFileFacts { pub statements: Vec, /// Procedural units in pre-order (see [`ProceduralUnitFacts`]). pub procedural_units: Vec, + /// Procedural control-flow facts (research foundation §6.17, Phase 3). + pub procedural: crate::procedural::ProceduralFacts, pub query_block_count: u32, pub query_block_max_depth: u32, pub select_item_total: u32, @@ -376,6 +414,8 @@ pub(crate) struct SqlFileFacts { pub ctes: CteFacts, pub objects: ObjectFacts, pub change_risk_evidence: Vec, + /// Evidence for the raw object-family counters (Codex P1). + pub object_evidence: Vec, pub halstead: HalsteadFacts, pub relation_ref_count: u32, /// Count of `SyntaxKind::Unparsable` segments (parser-health, §6.16). @@ -398,20 +438,129 @@ pub(crate) struct SqlFileFacts { const SELECT_STATEMENT: SyntaxSet = SyntaxSet::single(SyntaxKind::SelectStatement); -/// Build facts for `root` (the parsed `File` segment) under `dialect`. +// ── dialect-folding kind sets ────────────────────────────────────────── +// +// sqruff's Oracle dialect emits its own parallel statement/reference kinds +// (`OracleUpdateStatement`, `OracleTableReference`, …) instead of the ANSI +// ones, and T-SQL adds `BulkInsertStatement`. Every scan that matched only +// the ANSI kind silently skipped Oracle DML — top-level `UPDATE`/`INSERT`/ +// `DELETE`/`COMMIT` in an Oracle file classified as `unknown` and appeared +// in no `sql.dml.*`, object-touch, or change-risk metric. These sets fold +// the dialect variants so every consumer sees one vocabulary (verified +// against the sqruff v0.40.0 `SyntaxKind` inventory: Oracle is the only +// dialect with parallel DML kinds). + +/// `table_reference` in any dialect spelling. +const TABLE_REFERENCES: SyntaxSet = + SyntaxSet::new(&[SyntaxKind::TableReference, SyntaxKind::OracleTableReference]); + +const INSERT_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::InsertStatement, + SyntaxKind::OracleInsertStatement, + SyntaxKind::BulkInsertStatement, +]); + +const UPDATE_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::UpdateStatement, + SyntaxKind::OracleUpdateStatement, +]); + +const DELETE_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::DeleteStatement, + SyntaxKind::OracleDeleteStatement, +]); + +const TRANSACTION_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::TransactionStatement, + SyntaxKind::OracleTransactionStatement, +]); + +const CREATE_TABLE_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::CreateTableStatement, + SyntaxKind::OracleCreateTableStatement, +]); + +const CREATE_VIEW_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::CreateViewStatement, + SyntaxKind::CreateMaterializedViewStatement, + SyntaxKind::OracleCreateViewStatement, +]); + +const ALTER_TABLE_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::AlterTableStatement, + SyntaxKind::OracleAlterTableStatement, +]); + +const DROP_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::DropTableStatement, + SyntaxKind::DropViewStatement, + SyntaxKind::DropIndexStatement, + SyntaxKind::DropStatement, + SyntaxKind::DropFunctionStatement, + SyntaxKind::DropSchemaStatement, + SyntaxKind::OracleDropPackageStatement, + SyntaxKind::OracleDropProcedureStatement, + SyntaxKind::OracleDropSynonymStatement, + SyntaxKind::OracleDropDatabaseLinkStatement, +]); + +/// Non-table/view CREATE kinds that the per-statement path classifies as +/// `create_other` (by its raw-text CREATE fallback). The node-based +/// anonymous-block scan cannot use raw-text classification, so it mirrors +/// the family with the typed kinds an executing block can realistically +/// contain — `IF … BEGIN CREATE INDEX ix ON t(c); END` is real migration DDL +/// (Codex P2). Routine/trigger definitions are deliberately absent: they are +/// procedural and the `PROCEDURAL_DEFINITIONS` boundary excludes them. +const CREATE_OTHER_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::CreateIndexStatement, + SyntaxKind::CreateSequenceStatement, + SyntaxKind::CreateSchemaStatement, + SyntaxKind::CreateSynonymStatement, + SyntaxKind::CreateDatabaseStatement, + SyntaxKind::CreateDomainStatement, + SyntaxKind::CreateExtensionStatement, + SyntaxKind::CreateTypeStatement, + SyntaxKind::CreateUserStatement, + SyntaxKind::CreateRoleStatement, +]); + +/// Build facts for `root` (the parsed `File` segment). `dialect` is the +/// effective dialect: T-SQL's batch model changes statement-ownership +/// semantics (a routine body extends to the next `GO`), and BigQuery's bare +/// scripting `BEGIN`/`END` statements parse as transaction-statement +/// wrappers that must not classify as TCL (Codex P1). pub(crate) fn extract( root: &ErasedSegment, - dialect: &Dialect, line_at: impl Fn(u32) -> u32, emit_contributions: bool, + dialect: DialectKind, ) -> SqlFileFacts { + let tsql = dialect == DialectKind::Tsql; + let mysql = dialect == DialectKind::Mysql; + let oracle = dialect == DialectKind::Oracle; + let bigquery = dialect == DialectKind::Bigquery; let mut facts = SqlFileFacts::default(); + // ── procedural units (function-shaped scopes) ─────────────────── + // Extracted *before* statement classification: BigQuery-style grammars + // wrap routines in segments without a top-level `Statement` node, so the + // routine's body statements surface as top-level statements themselves — + // classification needs the unit ranges to recognize them as + // routine-owned (Codex P1). + extract_procedural_units(root, &line_at, tsql, mysql, oracle, &mut facts); + // ── statements ────────────────────────────────────────────────── - classify_statements(root, &line_at, &mut facts); + let unit_ranges: Vec<(u32, u32)> = facts + .procedural_units + .iter() + .map(|u| (u.start_byte, u.end_byte)) + .collect(); + classify_statements(root, &line_at, tsql, bigquery, &unit_ranges, &mut facts); - // ── procedural units (function-shaped scopes) ─────────────────── - extract_procedural_units(root, &line_at, &mut facts); + // ── procedural control flow (research foundation §6.17) ───────── + // Needs statement classification and units; contributes dynamic-SQL + // change-risk evidence alongside `extract_objects`' below. + crate::procedural::extract(root, &line_at, emit_contributions, dialect, &mut facts); // ── unparsable / parser health ────────────────────────────────── let unparsables = root.recursive_crawl( @@ -481,13 +630,13 @@ pub(crate) fn extract( // ── relation references ───────────────────────────────────────── facts.relation_ref_count = - count_anywhere(root, SyntaxKind::TableReference) + facts.subqueries.derived_table_count; + count_any(root, &TABLE_REFERENCES) + facts.subqueries.derived_table_count; // ── CTE graph (via sqruff Query analysis) ─────────────────────── - extract_cte_graph(root, dialect, &mut facts.ctes); + extract_cte_graph(root, &mut facts.ctes); // ── object-touch / DML-DDL risk ───────────────────────────────── - extract_objects(root, &line_at, &mut facts, emit_contributions); + extract_objects(root, &line_at, bigquery, &mut facts, emit_contributions); // ── Halstead ──────────────────────────────────────────────────── extract_halstead(root, &mut facts.halstead); @@ -500,23 +649,73 @@ pub(crate) fn extract( fn classify_statements( root: &ErasedSegment, line_at: &impl Fn(u32) -> u32, + tsql: bool, + bigquery: bool, + unit_ranges: &[(u32, u32)], facts: &mut SqlFileFacts, ) { // Top-level `Statement` nodes are direct children of `File`; do not // recurse into nested statements (a subquery `SELECT` is a query block, // not a top-level statement). - let statements = root.recursive_crawl( - &SyntaxSet::single(SyntaxKind::Statement), - false, - &SyntaxSet::EMPTY, - false, - ); + let statements = top_level_statements(root, bigquery); + // Whether the previous statement was (or continued) a routine + // definition. sqruff's tsql grammar splits long procedure bodies into + // sibling statements; T-SQL batch semantics say the body extends to the + // next `GO`/EOF — `CREATE PROCEDURE` must be alone in its batch — so + // under the tsql dialect *every* statement after a routine definition is + // the routine's body until a `GO` separator (Codex P1). Other dialects + // have real statement terminators, so only *control-shaped* fragments + // (keyword-led T-SQL shapes, MySQL's per-branch typed statements) + // reclassify there; a plain `UPDATE` after an Oracle routine is + // independent. Typed blocks (Oracle `BEGIN…END`) never reclassify — + // they are genuine anonymous blocks wherever they appear. `unknown` + // statements (parse debris between spills) keep the chain alive. + let mut prev_procedural = false; for stmt in &statements { - let kind = classify_statement(stmt); let (start_byte, end_byte) = stmt .get_position_marker() .map(|pm| (pm.source_slice.start as u32, pm.source_slice.end as u32)) .unwrap_or((0, 0)); + let mut kind = classify_statement(stmt, bigquery); + // A statement whose bytes live *inside* a routine unit is the + // routine's body — BigQuery-style grammars wrap `CREATE PROCEDURE` + // in a segment without a top-level `Statement` node, so the body's + // statements surface as top-level statements themselves. They run + // when the routine is *called*, not when the file is applied, so + // they must not publish executing DML, touched objects, or + // missing-WHERE risk (Codex P1). Strict containment: a definition + // statement contains its unit, never the reverse. + let routine_owned = kind != StatementKind::Procedural + && unit_ranges.iter().any(|&(s, e)| { + s <= start_byte && end_byte <= e && (s, e) != (start_byte, end_byte) + }); + if routine_owned { + kind = StatementKind::Procedural; + } + let is_go = kind == StatementKind::Unknown && is_go_separator(stmt); + if prev_procedural && !is_go && kind != StatementKind::Procedural { + let continuation = if tsql { + true + } else { + kind == StatementKind::AnonymousBlock + && matches!( + anonymous_block_shape(stmt), + Some(AnonymousBlockShape::KeywordLed | AnonymousBlockShape::TypedControl) + ) + }; + if continuation { + kind = StatementKind::Procedural; + } + } + prev_procedural = match kind { + _ if is_go => false, + // Body statements owned by containment never seed a + // continuation chain — attribution is complete without one. + StatementKind::Procedural if routine_owned => prev_procedural, + StatementKind::Procedural => true, + StatementKind::Unknown => prev_procedural, + _ => false, + }; facts.statements.push(StatementFacts { kind, start_line: line_at(start_byte), @@ -528,20 +727,35 @@ fn classify_statements( } /// Classify a `Statement` node by inspecting which statement-body kind it -/// contains. sqruff produces dialect-specific `Drop*`/`Create*` variants, so -/// we probe with a broad `SyntaxSet` and map by the first match. -fn classify_statement(stmt: &ErasedSegment) -> StatementKind { - let has = |k: SyntaxKind| { +/// contains. sqruff produces dialect-specific `Drop*`/`Create*`/Oracle DML +/// variants, so we probe with the dialect-folding `SyntaxSet`s above and map +/// by the first match. +fn classify_statement(stmt: &ErasedSegment, bigquery: bool) -> StatementKind { + let has_any = |set: &SyntaxSet| { !stmt - .recursive_crawl(&SyntaxSet::single(k), false, &SyntaxSet::EMPTY, true) + .recursive_crawl(set, false, &SyntaxSet::EMPTY, true) .is_empty() }; + let has = |k: SyntaxKind| has_any(&SyntaxSet::single(k)); + + // Anonymous blocks and top-level scripting statements come *before* the + // routine-definition check: an anonymous block may *declare* a nested + // procedure in its DECLARE section, and the nested definition must not + // make the executing outer block look like a routine definition (its + // UPDATE runs on apply! — Codex P1). The shape walk skips + // `PROCEDURAL_DEFINITIONS` subtrees, so a routine's *own* body block + // never marks the routine statement as an anonymous block, and the DML + // sniffing below stays unreachable for blocks (`BEGIN UPDATE t …; END;` + // contains an UpdateStatement, but the statement is the block). + if stmt_is_anonymous_block(stmt, bigquery) { + return StatementKind::AnonymousBlock; + } - // Procedural definitions are classified *first*: a `CREATE PROCEDURE` / - // `FUNCTION` / `TRIGGER` body commonly contains `INSERT`/`UPDATE`/…, but - // the top-level statement is the routine definition, not the nested DML — - // classifying it as DML would also wrongly feed `extract_objects`' - // DML/no-WHERE risk metrics (Codex P2). + // Routine definitions: `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER` + // bodies commonly contain `INSERT`/`UPDATE`/…, but the top-level + // statement is the routine definition, not the nested DML — classifying + // it as DML would also wrongly feed `extract_objects`' DML/no-WHERE risk + // metrics (Codex P2). if stmt_is_procedural(stmt) { return StatementKind::Procedural; } @@ -555,42 +769,36 @@ fn classify_statement(stmt: &ErasedSegment) -> StatementKind { if has(SyntaxKind::MergeStatement) { return StatementKind::Merge; } - if has(SyntaxKind::InsertStatement) { + if has_any(&INSERT_STATEMENTS) { return StatementKind::Insert; } - if has(SyntaxKind::UpdateStatement) { + if has_any(&UPDATE_STATEMENTS) { return StatementKind::Update; } - if has(SyntaxKind::DeleteStatement) { + if has_any(&DELETE_STATEMENTS) { return StatementKind::Delete; } if has(SyntaxKind::TruncateStatement) { return StatementKind::Truncate; } - if has(SyntaxKind::AlterTableStatement) { + if has_any(&ALTER_TABLE_STATEMENTS) { return StatementKind::AlterTable; } // CREATE family: distinguish CTAS, view, table, other. - if has(SyntaxKind::CreateTableStatement) { + if has_any(&CREATE_TABLE_STATEMENTS) { // CTAS = CREATE TABLE … AS SELECT — the statement embeds a select. if has(SyntaxKind::SelectStatement) || has(SyntaxKind::WithCompoundStatement) { return StatementKind::CreateTableAsSelect; } return StatementKind::CreateTable; } - if has(SyntaxKind::CreateViewStatement) || has(SyntaxKind::CreateMaterializedViewStatement) { + if has_any(&CREATE_VIEW_STATEMENTS) { return StatementKind::CreateView; } if stmt_contains_create(stmt) { return StatementKind::CreateOther; } - if has(SyntaxKind::DropTableStatement) - || has(SyntaxKind::DropViewStatement) - || has(SyntaxKind::DropIndexStatement) - || has(SyntaxKind::DropStatement) - || has(SyntaxKind::DropFunctionStatement) - || has(SyntaxKind::DropSchemaStatement) - { + if has_any(&DROP_STATEMENTS) { return StatementKind::Drop; } if has(SyntaxKind::AccessStatement) { @@ -601,8 +809,15 @@ fn classify_statement(stmt: &ErasedSegment) -> StatementKind { } return StatementKind::Grant; } - if has(SyntaxKind::TransactionStatement) { - return StatementKind::TransactionControl; + if has_any(&TRANSACTION_STATEMENTS) { + // BigQuery's bare scripting `END;` also parses as a transaction + // statement — it is the closer of a scripting block, not TCL, so it + // must not add transaction-control risk (Codex P1). The matching + // bare `BEGIN;` never reaches here (classified anonymous above). + // Falls through to `unknown`: a closer is no statement of its own. + if !(bigquery && bare_scripting_bracket(stmt).is_some()) { + return StatementKind::TransactionControl; + } } if has(SyntaxKind::ExplainStatement) { return StatementKind::Explain; @@ -662,6 +877,178 @@ fn stmt_is_procedural(stmt: &ErasedSegment) -> bool { .is_empty() } +/// Typed `BEGIN…END` block node kinds — genuine anonymous blocks wherever +/// they appear (Oracle `DECLARE…BEGIN…END`, T-SQL/ANSI blocks). +const TYPED_BLOCK_KINDS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::OracleBeginEndBlock, + SyntaxKind::BeginEndBlock, + SyntaxKind::AtomicBeginEndBlock, +]); + +/// Typed scripting/control statement node kinds. At top level these are +/// either genuine scripting (BigQuery) or — far more often — fragments of a +/// routine body that a dialect grammar split into sibling statements (MySQL +/// splits *every branch* of an `IF` into its own `IfThenStatement` +/// statement). The distinction is positional: fragments directly follow a +/// routine definition. +const TYPED_CONTROL_KINDS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::IfStatements, + SyntaxKind::IfStatement, + SyntaxKind::IfThenStatement, + SyntaxKind::WhileStatements, + SyntaxKind::WhileStatement, + SyntaxKind::LoopStatements, + SyntaxKind::LoopStatement, + SyntaxKind::RepeatStatement, + SyntaxKind::ForInStatement, +]); + +/// How a statement qualifies as an anonymous block. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AnonymousBlockShape { + /// A typed `BEGIN…END` block node (Oracle, T-SQL/ANSI) — a genuine + /// anonymous block wherever it appears. + TypedBlock, + /// A typed scripting/control statement (`IfThenStatement`, + /// `WhileStatement`, …) — genuine scripting when standalone, a routine + /// body fragment when it directly follows a routine definition. + TypedControl, + /// A T-SQL keyword-led control statement (`IF`/`WHILE`/`BEGIN` + nested + /// statements without a dedicated node kind) — same positional rule as + /// `TypedControl`. + KeywordLed, +} + +/// Whether a (non-routine) statement is an anonymous block / top-level +/// scripting statement, and which shape it takes. +/// +/// Three shapes, per the CST probes (parser comparison §9): +/// - a typed block node reached without crossing a `Bracketed` group (a +/// parenthesized subquery is not the statement's body) — Oracle blocks; +/// - a typed scripting/control statement — BigQuery scripting, MySQL body +/// fragments; +/// - a T-SQL keyword-led statement: the first substantive child is the bare +/// keyword `IF`/`WHILE`/`BEGIN` (sqruff's tsql dialect nests the controlled +/// statements under it without a dedicated node kind). `BEGIN` is checked +/// against `TRANSACTION`/`TRAN`/`WORK`/`DIALOG` so T-SQL transaction +/// control (which also parses keyword-led in fragments) stays TCL. +fn anonymous_block_shape(stmt: &ErasedSegment) -> Option { + fn contains_kind(node: &ErasedSegment, kinds: &SyntaxSet) -> bool { + for child in node.segments() { + if kinds.contains(child.get_type()) { + return true; + } + // A parenthesized subquery is not the statement's body, and a + // routine definition's own body block belongs to the routine — + // a `CREATE PROCEDURE` must never look like an anonymous block + // because of the `BEGIN…END`/scripting nodes inside it + // (Codex P1). + if child.is_type(SyntaxKind::Bracketed) + || PROCEDURAL_DEFINITIONS.contains(child.get_type()) + { + continue; + } + if contains_kind(child, kinds) { + return true; + } + } + false + } + if contains_kind(stmt, &TYPED_BLOCK_KINDS) { + return Some(AnonymousBlockShape::TypedBlock); + } + if contains_kind(stmt, &TYPED_CONTROL_KINDS) { + return Some(AnonymousBlockShape::TypedControl); + } + // T-SQL keyword-led shape: first two substantive children. + let mut lead = stmt + .segments() + .iter() + .filter(|s| !s.is_whitespace() && !s.is_meta() && !s.is_comment()); + let first = lead.next()?; + if !first.is_type(SyntaxKind::Keyword) { + return None; + } + let word = first.raw().to_ascii_uppercase(); + match word.as_str() { + "IF" | "WHILE" => Some(AnonymousBlockShape::KeywordLed), + "BEGIN" => { + let next = lead + .next() + .map(|s| s.raw().to_ascii_uppercase()) + .unwrap_or_default(); + if matches!( + next.as_str(), + "TRANSACTION" | "TRAN" | "WORK" | "DIALOG" | "DISTRIBUTED" + ) { + None + } else { + Some(AnonymousBlockShape::KeywordLed) + } + } + _ => None, + } +} + +fn stmt_is_anonymous_block(stmt: &ErasedSegment, bigquery: bool) -> bool { + anonymous_block_shape(stmt).is_some() + || (bigquery && bare_scripting_bracket(stmt) == Some(ScriptingBracket::Begin)) +} + +/// Which bare scripting bracket a BigQuery `TransactionStatement`-wrapped +/// statement is, if any. BigQuery's grammar parses the scripting block +/// openers/closers `BEGIN;`/`END;` as transaction statements whose *sole* +/// keyword is the bracket — real transaction control (`BEGIN TRANSACTION`) +/// carries more keywords and stays TCL (Codex P1). Only meaningful under +/// the BigQuery dialect: a bare `BEGIN;`/`END;` in PostgreSQL *is* +/// transaction control. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScriptingBracket { + Begin, + End, +} + +pub(crate) fn bare_scripting_bracket(stmt: &ErasedSegment) -> Option { + let wrapper = stmt + .segments() + .iter() + .find(|s| !s.is_whitespace() && !s.is_meta() && !s.is_comment())?; + if !TRANSACTION_STATEMENTS.contains(wrapper.get_type()) { + return None; + } + transaction_node_bracket(wrapper) +} + +/// The sole-keyword bracket shape of a transaction-statement *node*, if any. +pub(crate) fn transaction_node_bracket(node: &ErasedSegment) -> Option { + let keywords: Vec = node + .segments() + .iter() + .filter(|s| s.is_type(SyntaxKind::Keyword)) + .map(|s| s.raw().to_ascii_uppercase()) + .collect(); + match keywords.as_slice() { + [word] if word == "BEGIN" => Some(ScriptingBracket::Begin), + [word] if word == "END" => Some(ScriptingBracket::End), + _ => None, + } +} + +/// Whether a statement is a bare T-SQL `GO` batch separator (its only code +/// token is the keyword `GO`). +pub(crate) fn is_go_separator(stmt: &ErasedSegment) -> bool { + let mut code = stmt + .segments() + .iter() + .filter(|s| !s.is_whitespace() && !s.is_meta() && !s.is_comment()); + let Some(first) = code.next() else { + return false; + }; + code.next().is_none() + && first.is_type(SyntaxKind::Keyword) + && first.raw().eq_ignore_ascii_case("GO") +} + // ── joins ────────────────────────────────────────────────────────────── /// Count and classify explicit `JOIN` clauses. @@ -672,7 +1059,7 @@ fn stmt_is_procedural(stmt: &ErasedSegment) -> bool { /// separation risks false positives (e.g. `FROM a, LATERAL f(a.x)`). Explicit /// `CROSS JOIN` is counted; implicit cross-join detection is deferred (research /// foundation §6.5 lists it as a derive-later item). -fn extract_joins(root: &ErasedSegment, joins: &mut JoinFacts) { +pub(crate) fn extract_joins(root: &ErasedSegment, joins: &mut JoinFacts) { let clauses = root.recursive_crawl( &SyntaxSet::single(SyntaxKind::JoinClause), true, @@ -875,7 +1262,7 @@ fn operand_is_column_reference(seg: &ErasedSegment) -> bool { // ── set operations ─────────────────────────────────────────────────── -fn extract_set_ops(root: &ErasedSegment, set_ops: &mut SetOpFacts) { +pub(crate) fn extract_set_ops(root: &ErasedSegment, set_ops: &mut SetOpFacts) { let ops = root.recursive_crawl( &SyntaxSet::single(SyntaxKind::SetOperator), true, @@ -901,7 +1288,7 @@ fn extract_set_ops(root: &ErasedSegment, set_ops: &mut SetOpFacts) { // ── CASE ──────────────────────────────────────────────────────────────── -fn extract_cases(root: &ErasedSegment, cases: &mut CaseFacts) { +pub(crate) fn extract_cases(root: &ErasedSegment, cases: &mut CaseFacts) { let all = root.recursive_crawl( &SyntaxSet::single(SyntaxKind::CaseExpression), true, @@ -956,7 +1343,7 @@ fn count_anywhere_within_case(case: &ErasedSegment, kind: SyntaxKind) -> u32 { // ── window functions ───────────────────────────────────────────────── -fn extract_windows(root: &ErasedSegment, windows: &mut WindowFacts) { +pub(crate) fn extract_windows(root: &ErasedSegment, windows: &mut WindowFacts) { let overs = root.recursive_crawl( &SyntaxSet::single(SyntaxKind::OverClause), true, @@ -1019,7 +1406,7 @@ const AGGREGATE_NAMES: &[&str] = &[ "COUNT_BIG", ]; -fn extract_aggregates(root: &ErasedSegment, agg: &mut AggregateFacts) { +pub(crate) fn extract_aggregates(root: &ErasedSegment, agg: &mut AggregateFacts) { let functions = root.recursive_crawl( &SyntaxSet::single(SyntaxKind::Function), true, @@ -1077,7 +1464,7 @@ const PREDICATE_PARENTS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::JoinOnCondition, ]); -fn extract_predicates(root: &ErasedSegment, pred: &mut PredicateFacts) { +pub(crate) fn extract_predicates(root: &ErasedSegment, pred: &mut PredicateFacts) { // Boolean operators are `BinaryOperator` nodes whose raw text is AND/OR // (the CST does not distinguish boolean from arithmetic binary operators // by kind — empirically verified from a parse dump). @@ -1093,10 +1480,9 @@ fn extract_predicates(root: &ErasedSegment, pred: &mut PredicateFacts) { pred.boolean_operator_count += 1; } } - pred.not_count = count_keyword(root, "NOT"); + pred.not_spans = collect_predicate_nots(root); + pred.not_count = pred.not_spans.len() as u32; pred.comparison_count = count_anywhere(root, SyntaxKind::ComparisonOperator); - // `IN (...)` predicates. - pred.in_count = count_keyword(root, "IN"); // Max boolean nesting depth within predicate-bearing clauses. let parents = root.recursive_crawl(&PREDICATE_PARENTS, true, &SyntaxSet::EMPTY, true); @@ -1112,6 +1498,74 @@ fn extract_predicates(root: &ErasedSegment, pred: &mut PredicateFacts) { pred.null_semantics_risk_count = count_null_semantics_risk(root); } +/// Collect the source ranges of `NOT` keyword tokens that act as +/// predicate/boolean operators (§6.7) — `not_count` is their count and each +/// range becomes one `MetricEvidence` entry under the metric's own key +/// (Codex P1). Two non-predicate contexts a raw keyword count picks up are +/// excluded: +/// +/// - `NOT NULL` column constraints in DDL (`id INT NOT NULL`) — but only +/// with column-definition/constraint ancestry: a boolean `WHERE NOT NULL` +/// predicate is a genuine unary negation and counts (Codex P2). A +/// genuine `IS NOT NULL` predicate always counts (the `IS` before the +/// `NOT` distinguishes it). +/// - `IF NOT EXISTS` guards *inside CREATE/DROP statements* (`CREATE TABLE +/// IF NOT EXISTS`) — but the same token run as a procedural condition +/// (T-SQL `IF NOT EXISTS (SELECT …) BEGIN …`) is a genuine negation and +/// counts, so the exclusion requires DDL ancestry (Codex P2). A `WHERE +/// NOT EXISTS (…)` predicate has no `IF` and always counts. +/// +/// Works over sibling code tokens, mirroring `count_null_semantics_risk`. +fn collect_predicate_nots(root: &ErasedSegment) -> Vec<(u32, u32)> { + /// Statement kinds whose `IF NOT EXISTS` is a DDL guard. + const DDL_GUARD_CONTEXTS: SyntaxSet = CREATE_TABLE_STATEMENTS + .union(&CREATE_VIEW_STATEMENTS) + .union(&CREATE_OTHER_STATEMENTS) + .union(&ALTER_TABLE_STATEMENTS) + .union(&DROP_STATEMENTS); + /// Contexts whose `NOT NULL` is a column constraint, not a predicate. + const CONSTRAINT_CONTEXTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::ColumnDefinition, + SyntaxKind::ColumnConstraintSegment, + ]); + fn walk(node: &ErasedSegment, in_ddl: bool, in_constraint: bool, spans: &mut Vec<(u32, u32)>) { + let code: Vec<&ErasedSegment> = node + .segments() + .iter() + .filter(|s| !s.is_whitespace() && !s.is_meta() && !s.is_comment()) + .collect(); + for (i, seg) in code.iter().enumerate() { + if !seg.is_type(SyntaxKind::Keyword) || !seg.raw().eq_ignore_ascii_case("NOT") { + continue; + } + let neighbor = |j: Option| { + j.and_then(|k| code.get(k)) + .map(|s| s.raw().to_ascii_uppercase()) + .unwrap_or_default() + }; + let prev = neighbor(i.checked_sub(1)); + let next = neighbor(Some(i + 1)); + let null_constraint = in_constraint && next == "NULL" && prev != "IS"; + let ddl_guard = in_ddl && next == "EXISTS" && prev == "IF"; + if !null_constraint + && !ddl_guard + && let Some(pm) = seg.get_position_marker() + { + spans.push((pm.source_slice.start as u32, pm.source_slice.end as u32)); + } + } + for child in node.segments() { + let child_in_ddl = in_ddl || DDL_GUARD_CONTEXTS.contains(child.get_type()); + let child_in_constraint = + in_constraint || CONSTRAINT_CONTEXTS.contains(child.get_type()); + walk(child, child_in_ddl, child_in_constraint, spans); + } + } + let mut spans = Vec::new(); + walk(root, false, false, &mut spans); + spans +} + /// Count NULL-semantics risks from parsed tokens (comments/literals excluded): /// a `=`/`<>`/`!=` comparison whose neighboring code operand is a `NullLiteral` /// (each counted once), plus `NOT IN` keyword pairs. @@ -1244,7 +1698,11 @@ fn redundant_outer_bracket(node: &ErasedSegment) -> u32 { // ── subqueries / derived tables ─────────────────────────────────────── -fn extract_subqueries(root: &ErasedSegment, selects: &[ErasedSegment], sub: &mut SubqueryFacts) { +pub(crate) fn extract_subqueries( + root: &ErasedSegment, + selects: &[ErasedSegment], + sub: &mut SubqueryFacts, +) { // A subquery is any SELECT that is nested inside another query construct. // The outermost SELECT(s) of each top-level statement are not subqueries. for sel in selects { @@ -1403,12 +1861,7 @@ fn relation_names(node: &ErasedSegment) -> Vec { for elem in &from_elems { // Table reference(s) of this FROM element (again, not those inside a // derived-table subquery nested in the element). - for tr in elem.recursive_crawl( - &SyntaxSet::single(SyntaxKind::TableReference), - true, - &SELECT_STATEMENT, - true, - ) { + for tr in elem.recursive_crawl(&TABLE_REFERENCES, true, &SELECT_STATEMENT, true) { names.push(last_identifier(&tr)); } // The element's own (table) alias, if any. @@ -1498,7 +1951,7 @@ fn count_scalar_subqueries(root: &ErasedSegment) -> u32 { // ── expressions / functions ─────────────────────────────────────────── -fn extract_expressions(root: &ErasedSegment, expr: &mut ExpressionFacts) { +pub(crate) fn extract_expressions(root: &ErasedSegment, expr: &mut ExpressionFacts) { // Max expression AST depth across all Expression nodes. let expressions = root.recursive_crawl( &SyntaxSet::single(SyntaxKind::Expression), @@ -1720,7 +2173,7 @@ fn nearest_select_depth(root: &ErasedSegment, target: &ErasedSegment) -> u32 { // ── CTE graph (via sqruff Query analysis) ───────────────────────────── -fn extract_cte_graph(root: &ErasedSegment, _dialect: &Dialect, ctes: &mut CteFacts) { +pub(crate) fn extract_cte_graph(root: &ErasedSegment, ctes: &mut CteFacts) { // The CTE dependency graph is derived directly from `CommonTableExpression` // CST nodes: each carries a name identifier and a body whose // `TableReference`s name its dependencies. We deliberately avoid sqruff's @@ -1806,7 +2259,7 @@ fn extract_cte_graph(root: &ErasedSegment, _dialect: &Dialect, ctes: &mut CteFac // appears as a table reference *within this WITH block* and *outside* // its own definition body. let block_refs = with.recursive_crawl( - &SyntaxSet::single(SyntaxKind::TableReference), + &TABLE_REFERENCES, true, &SyntaxSet::single(SyntaxKind::WithCompoundStatement), false, @@ -1869,12 +2322,7 @@ fn is_trivial_cte(cte: &ErasedSegment) -> bool { // The CTE body is the bracketed SELECT after `AS`. A trivial body has // exactly one table reference and none of the structure-adding clauses. let table_refs = cte - .recursive_crawl( - &SyntaxSet::single(SyntaxKind::TableReference), - true, - &SyntaxSet::EMPTY, - true, - ) + .recursive_crawl(&TABLE_REFERENCES, true, &SyntaxSet::EMPTY, true) .len(); if table_refs != 1 { return false; @@ -1924,12 +2372,7 @@ fn cte_body_dependencies( cte: &ErasedSegment, cte_names: &[String], ) -> std::collections::BTreeSet { - let refs = cte.recursive_crawl( - &SyntaxSet::single(SyntaxKind::TableReference), - true, - &SyntaxSet::EMPTY, - true, - ); + let refs = cte.recursive_crawl(&TABLE_REFERENCES, true, &SyntaxSet::EMPTY, true); // Nested `WITH` blocks inside this body. A reference is *shadowed* when it // sits inside a nested block that defines the same name — it then resolves // to that inner CTE, not the enclosing block's. The shadowing scope is the @@ -2012,6 +2455,7 @@ fn longest_chain(edges: &std::collections::BTreeMap>, nodes: fn extract_objects( root: &ErasedSegment, line_at: &impl Fn(u32) -> u32, + bigquery: bool, facts: &mut SqlFileFacts, emit_contributions: bool, ) { @@ -2022,14 +2466,49 @@ fn extract_objects( }; let obj = &mut facts.objects; let evidence = &mut facts.change_risk_evidence; + let obj_evidence = &mut facts.object_evidence; // Per-statement-kind counters (used by the DML/DDL/TCL metric keys). for stmt in &facts.statements { match stmt.kind { - StatementKind::Insert => obj.insert_count += 1, - StatementKind::Update => obj.update_count += 1, - StatementKind::Delete => obj.delete_count += 1, + StatementKind::Insert => { + obj.insert_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.insert_count", + "sql.dml.insert", + statement_span(stmt), + ); + } + StatementKind::Update => { + obj.update_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.update_count", + "sql.dml.update", + statement_span(stmt), + ); + } + StatementKind::Delete => { + obj.delete_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.delete_count", + "sql.dml.delete", + statement_span(stmt), + ); + } StatementKind::Merge => { obj.merge_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.merge_count", + "sql.dml.merge", + statement_span(stmt), + ); record_change_risk( evidence, emit_contributions, @@ -2040,9 +2519,25 @@ fn extract_objects( StatementKind::CreateTable | StatementKind::CreateTableAsSelect | StatementKind::CreateView - | StatementKind::CreateOther => obj.create_count += 1, + | StatementKind::CreateOther => { + obj.create_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.create_count", + "sql.ddl.create", + statement_span(stmt), + ); + } StatementKind::AlterTable => { obj.alter_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.alter_count", + "sql.ddl.alter", + statement_span(stmt), + ); record_change_risk( evidence, emit_contributions, @@ -2052,6 +2547,13 @@ fn extract_objects( } StatementKind::Drop => { obj.drop_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.drop_count", + "sql.ddl.drop", + statement_span(stmt), + ); record_change_risk( evidence, emit_contributions, @@ -2061,6 +2563,13 @@ fn extract_objects( } StatementKind::Truncate => { obj.truncate_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.truncate_count", + "sql.ddl.truncate", + statement_span(stmt), + ); record_change_risk( evidence, emit_contributions, @@ -2070,6 +2579,13 @@ fn extract_objects( } StatementKind::Grant | StatementKind::Revoke => { obj.grant_revoke_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dcl.grant_revoke_count", + "sql.dcl.grant_revoke", + statement_span(stmt), + ); record_change_risk( evidence, emit_contributions, @@ -2077,8 +2593,27 @@ fn extract_objects( ChangeRiskFactor::GrantRevoke, ); } + StatementKind::AnonymousBlock => { + // The classification itself is an improved, evidence-backed + // fact: contribution output explains each executing block + // under its kind-count key (Codex P1). + record_object( + obj_evidence, + emit_contributions, + "sql.statement.kind_count.anonymous_block", + "sql.statement.anonymous_block", + statement_span(stmt), + ); + } StatementKind::TransactionControl => { obj.transaction_control_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.transaction.control_count", + "sql.transaction.control", + statement_span(stmt), + ); record_change_risk( evidence, emit_contributions, @@ -2090,13 +2625,39 @@ fn extract_objects( } } + // Anonymous blocks *execute when the file is applied* (unlike routine + // definitions, whose bodies only run when called), so DML/DDL/DCL/TCL + // inside them is real migration risk. The per-statement-kind loop above + // cannot see it — the statement classifies as `anonymous_block` — so the + // block bodies are scanned node-based here. Nested routine definitions + // (subprograms declared in a block's DECLARE section) stay excluded via + // the `PROCEDURAL_DEFINITIONS` crawl boundary, mirroring every other + // object scan. Statement nodes are paired with their classification by + // index — `top_level_statements` is the same crawl `classify_statements` + // consumed (CodeRabbit). + let statements = top_level_statements(root, bigquery); + debug_assert_eq!(statements.len(), facts.statements.len()); + for (node, stmt) in statements.iter().zip(facts.statements.iter()) { + if stmt.kind == StatementKind::AnonymousBlock { + scan_block_body_dml( + node, + line_at, + obj, + evidence, + obj_evidence, + emit_contributions, + ); + } + } + // Distinct read/write/touch object counts (research foundation §6.14: // "distinct objects read/written/touched"). Counting objects rather than // statements means a 10-table SELECT contributes 10 reads, and an object // both read and written is touched once. Read objects are table references // in FROM/JOIN positions; write objects are the statement-level targets of // write statements. Names are uppercased so case variants collapse. - let (read_objects, write_objects) = collect_touched_objects(root, line_at, emit_contributions); + let (read_objects, write_objects) = + collect_touched_objects(&statements, &facts.statements, line_at, emit_contributions); obj.read_object_count = read_objects.len() as u32; obj.write_object_count = write_objects.len() as u32; obj.touch_count = (read_objects.len() @@ -2105,65 +2666,97 @@ fn extract_objects( .filter(|name| !read_objects.contains_key(*name)) .count()) as u32; if emit_contributions { - for span in write_objects.values() { - record_change_risk( - evidence, + // One first-occurrence contribution per distinct object under the + // raw keys too — read/write/touch counts explain themselves + // (Codex P1). Touch = read ∪ write: every read span plus the + // write-only ones. + for (name, span) in &write_objects { + let span = span.unwrap_or(fallback_span); + record_change_risk(evidence, true, span, ChangeRiskFactor::WriteObject); + record_object( + obj_evidence, true, - span.unwrap_or(fallback_span), - ChangeRiskFactor::WriteObject, + "sql.object.write_count", + "sql.object.write", + span, ); + if !read_objects.contains_key(name) { + record_object( + obj_evidence, + true, + "sql.object.touch_count", + "sql.object.touch", + span, + ); + } } for span in read_objects.values() { - record_change_risk( - evidence, + let span = span.unwrap_or(fallback_span); + record_change_risk(evidence, true, span, ChangeRiskFactor::ReadObject); + record_object( + obj_evidence, true, - span.unwrap_or(fallback_span), - ChangeRiskFactor::ReadObject, + "sql.object.read_count", + "sql.object.read", + span, + ); + record_object( + obj_evidence, + true, + "sql.object.touch_count", + "sql.object.touch", + span, ); } } - // UPDATE/DELETE without WHERE. The WHERE crawl must stop at nested - // SELECT nodes: `UPDATE t SET v = (SELECT v FROM u WHERE u.id = t.id)` has no - // *statement-level* WHERE — it still rewrites every row — but a naive - // recursive crawl would find the subquery's WHERE and wrongly clear the - // no-WHERE flag (Codex P1). Passing `SELECT_STATEMENT` as the - // no-recurse set confines the search to the statement's own clauses. - let updates = root.recursive_crawl( - &SyntaxSet::single(SyntaxKind::UpdateStatement), - true, - &PROCEDURAL_DEFINITIONS, - true, - ); - for u in &updates { - if !has_own_where_clause(u) { - obj.update_without_where_count += 1; - if emit_contributions { - record_change_risk( - evidence, - true, - segment_span(u, line_at).unwrap_or(fallback_span), - ChangeRiskFactor::UpdateWithoutWhere, - ); + // UPDATE/DELETE without WHERE. Two scoping rules: + // - the WHERE crawl stops at nested SELECT nodes: `UPDATE t SET v = + // (SELECT v FROM u WHERE u.id = t.id)` has no *statement-level* WHERE — + // it still rewrites every row — but a naive recursive crawl would find + // the subquery's WHERE and wrongly clear the no-WHERE flag (Codex P1); + // - only non-`procedural` statements are scanned: a routine definition's + // body DML runs when called, not when the file is applied. The + // `PROCEDURAL_DEFINITIONS` crawl boundary covers well-formed routine + // nodes; skipping `procedural`-classified statements additionally + // covers T-SQL body fragments that sqruff splits into sibling + // statements (Codex P1). + for (node, stmt) in statements.iter().zip(facts.statements.iter()) { + if stmt.kind == StatementKind::Procedural { + continue; + } + let updates = node.recursive_crawl(&UPDATE_STATEMENTS, true, &PROCEDURAL_DEFINITIONS, true); + for u in &updates { + if !has_own_where_clause(u) { + obj.update_without_where_count += 1; + if emit_contributions { + let span = segment_span(u, line_at).unwrap_or(fallback_span); + record_object( + obj_evidence, + true, + "sql.dml.update_without_where_count", + "sql.dml.update_without_where", + span, + ); + record_change_risk(evidence, true, span, ChangeRiskFactor::UpdateWithoutWhere); + } } } - } - let deletes = root.recursive_crawl( - &SyntaxSet::single(SyntaxKind::DeleteStatement), - true, - &PROCEDURAL_DEFINITIONS, - true, - ); - for d in &deletes { - if !has_own_where_clause(d) { - obj.delete_without_where_count += 1; - if emit_contributions { - record_change_risk( - evidence, - true, - segment_span(d, line_at).unwrap_or(fallback_span), - ChangeRiskFactor::DeleteWithoutWhere, - ); + let deletes = node.recursive_crawl(&DELETE_STATEMENTS, true, &PROCEDURAL_DEFINITIONS, true); + for d in &deletes { + if !has_own_where_clause(d) { + obj.delete_without_where_count += 1; + if emit_contributions { + let span = segment_span(d, line_at).unwrap_or(fallback_span); + record_object( + obj_evidence, + true, + "sql.dml.delete_without_where_count", + "sql.dml.delete_without_where", + span, + ); + record_change_risk(evidence, true, span, ChangeRiskFactor::DeleteWithoutWhere); + } } } } @@ -2200,18 +2793,243 @@ fn extract_objects( // from `Keyword` tokens inside DML statements (INSERT/UPDATE/DELETE/MERGE). // The clause word is lexed as a `Keyword`, whereas a column or table named // `output`/`returning` is a `NakedIdentifier` — so this never fires on - // `UPDATE t SET output = 1` or `INSERT INTO output (…)`. + // `UPDATE t SET output = 1` or `INSERT INTO output (…)`. `procedural` + // statements (routine definitions and their split body fragments) are + // skipped like every other object scan. const DML_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::InsertStatement, + SyntaxKind::OracleInsertStatement, + SyntaxKind::BulkInsertStatement, SyntaxKind::UpdateStatement, + SyntaxKind::OracleUpdateStatement, SyntaxKind::DeleteStatement, + SyntaxKind::OracleDeleteStatement, SyntaxKind::MergeStatement, ]); - let dml_stmts = root.recursive_crawl(&DML_STATEMENTS, true, &PROCEDURAL_DEFINITIONS, true); - obj.returning_count = dml_stmts + for (node, _) in statements .iter() - .map(|s| count_keyword(s, "RETURNING") + count_keyword(s, "OUTPUT")) - .sum(); + .zip(facts.statements.iter()) + .filter(|(_, stmt)| stmt.kind != StatementKind::Procedural) + { + for dml in node.recursive_crawl(&DML_STATEMENTS, true, &PROCEDURAL_DEFINITIONS, true) { + for kw in keyword_tokens(&dml, "RETURNING") + .into_iter() + .chain(keyword_tokens(&dml, "OUTPUT")) + { + obj.returning_count += 1; + // Each counted clause is one evidence entry (Codex P1). + record_object( + obj_evidence, + emit_contributions, + "sql.dml.returning_count", + "sql.dml.returning", + segment_span(&kw, line_at).unwrap_or(fallback_span), + ); + } + } + } +} + +/// Node-based DML/DDL/DCL/TCL tally for one anonymous block's body — the +/// statement-kind counters (`sql.dml.*`, `sql.ddl.*`, +/// `sql.dcl.grant_revoke_count`, `sql.transaction.control_count`) and their +/// change-risk terms, mirroring the per-statement loop in `extract_objects` +/// arm for arm (Codex P1: `IF … DROP TABLE t; END IF` executes the drop when +/// the file is applied). Only statement kinds are counted here; object +/// touches and the without-WHERE risks are covered by the per-statement node +/// scans, which do not stop at anonymous blocks. +/// +/// Every crawl passes `recurse_into = false` so only the *outermost* match +/// on each path counts — sqruff double-wraps some kinds (an Oracle GRANT +/// parses as `AccessStatement > AccessStatement`), and a descend-into-match +/// crawl would count both layers. +fn scan_block_body_dml( + block: &ErasedSegment, + line_at: &impl Fn(u32) -> u32, + obj: &mut ObjectFacts, + evidence: &mut Vec, + obj_evidence: &mut Vec, + emit_contributions: bool, +) { + let span_of = + |seg: &ErasedSegment| segment_span(seg, line_at).unwrap_or_else(SourceSpan::empty); + for seg in block.recursive_crawl(&INSERT_STATEMENTS, false, &PROCEDURAL_DEFINITIONS, false) { + obj.insert_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.insert_count", + "sql.dml.insert", + span_of(&seg), + ); + } + for seg in block.recursive_crawl(&UPDATE_STATEMENTS, false, &PROCEDURAL_DEFINITIONS, false) { + obj.update_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.update_count", + "sql.dml.update", + span_of(&seg), + ); + } + for seg in block.recursive_crawl(&DELETE_STATEMENTS, false, &PROCEDURAL_DEFINITIONS, false) { + obj.delete_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.delete_count", + "sql.dml.delete", + span_of(&seg), + ); + } + for set in [ + &CREATE_TABLE_STATEMENTS, + &CREATE_VIEW_STATEMENTS, + &CREATE_OTHER_STATEMENTS, + ] { + for seg in block.recursive_crawl(set, false, &PROCEDURAL_DEFINITIONS, false) { + obj.create_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.create_count", + "sql.ddl.create", + span_of(&seg), + ); + } + } + for seg in block.recursive_crawl( + &SyntaxSet::single(SyntaxKind::MergeStatement), + false, + &PROCEDURAL_DEFINITIONS, + false, + ) { + obj.merge_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dml.merge_count", + "sql.dml.merge", + span_of(&seg), + ); + record_change_risk( + evidence, + emit_contributions, + span_of(&seg), + ChangeRiskFactor::Merge, + ); + } + for seg in block.recursive_crawl(&DROP_STATEMENTS, false, &PROCEDURAL_DEFINITIONS, false) { + obj.drop_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.drop_count", + "sql.ddl.drop", + span_of(&seg), + ); + record_change_risk( + evidence, + emit_contributions, + span_of(&seg), + ChangeRiskFactor::Drop, + ); + } + for seg in block.recursive_crawl( + &SyntaxSet::single(SyntaxKind::TruncateStatement), + false, + &PROCEDURAL_DEFINITIONS, + false, + ) { + obj.truncate_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.truncate_count", + "sql.ddl.truncate", + span_of(&seg), + ); + record_change_risk( + evidence, + emit_contributions, + span_of(&seg), + ChangeRiskFactor::Truncate, + ); + } + for seg in block.recursive_crawl( + &ALTER_TABLE_STATEMENTS, + false, + &PROCEDURAL_DEFINITIONS, + false, + ) { + obj.alter_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.ddl.alter_count", + "sql.ddl.alter", + span_of(&seg), + ); + record_change_risk( + evidence, + emit_contributions, + span_of(&seg), + ChangeRiskFactor::Alter, + ); + } + for seg in block.recursive_crawl( + &SyntaxSet::single(SyntaxKind::AccessStatement), + false, + &PROCEDURAL_DEFINITIONS, + false, + ) { + obj.grant_revoke_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.dcl.grant_revoke_count", + "sql.dcl.grant_revoke", + span_of(&seg), + ); + record_change_risk( + evidence, + emit_contributions, + span_of(&seg), + ChangeRiskFactor::GrantRevoke, + ); + } + for seg in block.recursive_crawl( + &TRANSACTION_STATEMENTS, + false, + &PROCEDURAL_DEFINITIONS, + false, + ) { + // Sole-keyword `BEGIN`/`END` wrappers are scripting-block brackets, + // not TCL (BigQuery — Codex P1). Real transaction control inside a + // block (`BEGIN TRANSACTION`, `COMMIT`, `ROLLBACK`) carries other + // keywords; the only dialects lexing bare-`BEGIN` TCL (PostgreSQL, + // MySQL) never surface it inside a typed block statement — MySQL + // blocks live in routines and PostgreSQL DO bodies are opaque + // dollar-quoted literals. + if transaction_node_bracket(&seg).is_some() { + continue; + } + obj.transaction_control_count += 1; + record_object( + obj_evidence, + emit_contributions, + "sql.transaction.control_count", + "sql.transaction.control", + span_of(&seg), + ); + record_change_risk( + evidence, + emit_contributions, + span_of(&seg), + ChangeRiskFactor::TransactionControl, + ); + } } /// The uppercased text of every *code* leaf token in `node` (comments, @@ -2276,16 +3094,24 @@ fn has_own_where_clause(stmt: &ErasedSegment) -> bool { } /// Write-statement kinds whose statement-level `table_reference` targets are -/// the objects they mutate. +/// the objects they mutate. Includes the Oracle parallel kinds (see the +/// dialect-folding sets above). const WRITE_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::InsertStatement, + SyntaxKind::OracleInsertStatement, + SyntaxKind::BulkInsertStatement, SyntaxKind::UpdateStatement, + SyntaxKind::OracleUpdateStatement, SyntaxKind::DeleteStatement, + SyntaxKind::OracleDeleteStatement, SyntaxKind::MergeStatement, SyntaxKind::TruncateStatement, SyntaxKind::AlterTableStatement, + SyntaxKind::OracleAlterTableStatement, SyntaxKind::CreateTableStatement, + SyntaxKind::OracleCreateTableStatement, SyntaxKind::CreateViewStatement, + SyntaxKind::OracleCreateViewStatement, SyntaxKind::CreateMaterializedViewStatement, SyntaxKind::CreateIndexStatement, SyntaxKind::DropTableStatement, @@ -2294,6 +3120,10 @@ const WRITE_STATEMENTS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::DropFunctionStatement, SyntaxKind::DropSchemaStatement, SyntaxKind::DropStatement, + SyntaxKind::OracleDropPackageStatement, + SyntaxKind::OracleDropProcedureStatement, + SyntaxKind::OracleDropSynonymStatement, + SyntaxKind::OracleDropDatabaseLinkStatement, ]); /// Procedural-definition statement kinds. DML/object scans pass this as their @@ -2324,7 +3154,7 @@ const PROCEDURAL_DEFINITIONS: SyntaxSet = SyntaxSet::new(&[ /// `SpaceKind::Function` space. Deliberately *excludes* the package/type-body /// containers in [`PROCEDURAL_DEFINITIONS`]: a package body is a module, and /// its routines are the function-shaped units inside it. -const PROCEDURAL_UNITS: SyntaxSet = SyntaxSet::new(&[ +pub(crate) const PROCEDURAL_UNITS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::CreateProcedureStatement, SyntaxKind::CreateFunctionStatement, SyntaxKind::CreateTriggerStatement, @@ -2346,6 +3176,85 @@ const UNIT_NAME_KINDS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::TriggerReference, ]); +/// The routine-definition CST nodes in the same pre-order as +/// [`extract_procedural_units`] collects `ProceduralUnitFacts` — callers zip +/// the two by index (e.g. for per-unit embedded-query scoring). Two filters +/// keep the sequence honest: +/// - nodes without a position marker are dropped so both sequences stay +/// index-aligned with the facts (which cannot represent a span-less unit) +/// — otherwise every unit after a skipped node would take its neighbor's +/// embedded score (CodeRabbit); +/// - Oracle member *prototypes* are dropped: a package/type specification +/// declares `PROCEDURE p;` with the same `OracleCreateProcedureStatement` +/// kind as an implementation, but has no `BEGIN…END` body — a prototype +/// is not an executable routine and must not grow an entry path or a +/// coverage space (Codex P2). The prototype test is *contextual*, not a +/// blanket body requirement: Oracle also has executable definitions +/// without an `OracleBeginEndBlock` — Java/C call-specs (`AS LANGUAGE +/// JAVA …`) and triggers whose body is a `CALL` clause — so only bodyless +/// routines sitting inside a package/type *specification* are dropped +/// (Codex P2). A bodyless forward declaration inside a package *body* is +/// kept: it is indistinguishable from a call-spec without deeper clause +/// parsing, and over-counting a declared routine is the safer error for a +/// coverage denominator. Non-Oracle kinds keep body-less shapes: a +/// PostgreSQL `$$`-quoted body is an opaque literal, not a block node. +pub(crate) fn procedural_unit_nodes(root: &ErasedSegment) -> Vec { + const ORACLE_ROUTINE_KINDS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::OracleCreateProcedureStatement, + SyntaxKind::OracleCreateFunctionStatement, + SyntaxKind::OracleCreateTriggerStatement, + ]); + // Package/type *specification* byte ranges. sqruff parses `CREATE + // PACKAGE` and `CREATE PACKAGE BODY` as the same node kind with an + // optional `BODY` keyword child, so the keyword picks the spec form. + const ORACLE_SPEC_CONTAINERS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::OracleCreatePackageStatement, + SyntaxKind::OracleCreateTypeStatement, + ]); + let spec_ranges: Vec<(u32, u32)> = root + .recursive_crawl(&ORACLE_SPEC_CONTAINERS, true, &SyntaxSet::EMPTY, false) + .into_iter() + .filter(|node| { + !node.is_type(SyntaxKind::OracleCreatePackageStatement) + || !node.segments().iter().any(|child| { + child.is_type(SyntaxKind::Keyword) && child.raw().eq_ignore_ascii_case("body") + }) + }) + .filter_map(|node| { + let pm = node.get_position_marker()?; + Some((pm.source_slice.start as u32, pm.source_slice.end as u32)) + }) + .collect(); + root.recursive_crawl(&PROCEDURAL_UNITS, true, &SyntaxSet::EMPTY, false) + .into_iter() + .filter(|unit| unit.get_position_marker().is_some()) + .filter(|unit| { + if !ORACLE_ROUTINE_KINDS.contains(unit.get_type()) { + return true; + } + let has_body = !unit + .recursive_crawl( + &SyntaxSet::single(SyntaxKind::OracleBeginEndBlock), + true, + &SyntaxSet::EMPTY, + true, + ) + .is_empty(); + if has_body { + return true; + } + // Bodyless: a prototype only in a spec context. + let Some(pm) = unit.get_position_marker() else { + return false; + }; + let (start, end) = (pm.source_slice.start as u32, pm.source_slice.end as u32); + !spec_ranges + .iter() + .any(|&(s, e)| s <= start && end <= e && (s, e) != (start, end)) + }) + .collect() +} + /// Collect procedural units (routine definitions) in pre-order. /// /// `recurse_into = true` descends into matched definitions, so routines @@ -2355,9 +3264,12 @@ const UNIT_NAME_KINDS: SyntaxSet = SyntaxSet::new(&[ fn extract_procedural_units( root: &ErasedSegment, line_at: &impl Fn(u32) -> u32, + tsql: bool, + mysql: bool, + oracle: bool, facts: &mut SqlFileFacts, ) { - let units = root.recursive_crawl(&PROCEDURAL_UNITS, true, &SyntaxSet::EMPTY, false); + let units = procedural_unit_nodes(root); for unit in &units { let Some(pm) = unit.get_position_marker() else { continue; @@ -2375,8 +3287,492 @@ fn extract_procedural_units( end_line: line_at(end_byte.saturating_sub(1)), start_byte, end_byte, + cyclomatic_complexity: 0.0, + cognitive_complexity: 0.0, + embedded_query_structural: 0.0, }); } + // sqruff 0.40's overridden T-SQL statement grammar leaves whole valid + // definitions (`CREATE FUNCTION dbo.f(@x int) RETURNS int AS BEGIN …`, + // `CREATE TRIGGER trg ON t FOR INSERT AS …`, standalone `ALTER + // FUNCTION|TRIGGER … AS …` — Codex P1 ×2) in root `Unparsable` nodes — + // no `PROCEDURAL_UNITS` kind ever forms. MySQL single-statement bodies + // (`CREATE PROCEDURE p() SIGNAL SQLSTATE '45000';`) share the fate + // (Codex P1). Recover units from the unambiguous header token shape at + // the start of a root run: `CREATE [OR ALTER|REPLACE] + // [DEFINER = ] FUNCTION|PROC[EDURE]|TRIGGER ` or `ALTER + // `. Scoped to the T-SQL/MySQL dialects and to *leading* + // headers so broken declarative SQL in other dialects stays + // unrecognized; the unit spans the whole run — the batch/delimiter + // semantics that already govern body ownership in those dialects. + if tsql || mysql { + // The client's *declared* delimiters (`DELIMITER %%`) are statement + // boundaries too — they aren't limited to any punctuation set + // (Codex P1). + let declared_delimiters = declared_delimiters(root); + for run in root.recursive_crawl( + &SyntaxSet::single(SyntaxKind::Unparsable), + true, + &SyntaxSet::EMPTY, + true, + ) { + let Some(pm) = run.get_position_marker() else { + continue; + }; + let (start_byte, end_byte) = (pm.source_slice.start as u32, pm.source_slice.end as u32); + // Runs inside an already-recognized unit belong to it. + if facts + .procedural_units + .iter() + .any(|u| u.start_byte <= start_byte && end_byte <= u.end_byte) + { + continue; + } + let mut run_tokens = Vec::new(); + leaf_tokens(&run, &mut run_tokens); + let headers = recovered_definition_headers(&run_tokens, tsql, &declared_delimiters); + if headers.is_empty() { + continue; + } + // Each header starts its own unit. Under MySQL a unit ends at + // its own balanced body / terminator, so intervening statements + // in the shared run stay outside its span (Codex P2); under + // T-SQL batch semantics it runs to the next header or the + // run's end. sqruff's error recovery often swallows *several* + // definitions (and even parseable statements between them) + // into one run — splitting keeps siblings independent + // (Codex P2). + for (k, (token_idx, header_start, name)) in headers.iter().enumerate() { + let fallback_end = headers + .get(k + 1) + .map(|&(_, next_start, _)| next_start) + .unwrap_or(end_byte); + let unit_end = if mysql { + mysql_recovered_unit_end(&run_tokens, *token_idx, &declared_delimiters) + .unwrap_or(fallback_end) + .min(fallback_end) + } else { + fallback_end + }; + facts.procedural_units.push(ProceduralUnitFacts { + name: Some(name.clone()), + start_line: line_at(*header_start), + end_line: line_at(unit_end.saturating_sub(1)), + start_byte: *header_start, + end_byte: unit_end, + cyclomatic_complexity: 0.0, + cognitive_complexity: 0.0, + embedded_query_structural: 0.0, + }); + } + } + // Keep the pre-order contract (parents before children, source + // order): synthetic units interleave with typed ones by position. + facts + .procedural_units + .sort_by_key(|u| (u.start_byte, std::cmp::Reverse(u.end_byte))); + } + // Oracle package *bodies* fall into the same parse gap: sqruff 0.40 + // emits `CREATE PACKAGE BODY pkg AS PROCEDURE p IS BEGIN … END p; …` + // wholly as a root `Unparsable` run (Codex P1). Recover the member + // routines: `PROCEDURE|FUNCTION ` headers at `;` boundaries + // inside a run *leading* with a package-body header. A member's span + // ends at its named `END ` when present (the PL/SQL convention), + // else at the next member or the run's end — so an initialization + // section stays file-level. + if oracle { + for run in root.recursive_crawl( + &SyntaxSet::single(SyntaxKind::Unparsable), + true, + &SyntaxSet::EMPTY, + true, + ) { + let Some(pm) = run.get_position_marker() else { + continue; + }; + let (start_byte, end_byte) = (pm.source_slice.start as u32, pm.source_slice.end as u32); + if facts + .procedural_units + .iter() + .any(|u| u.start_byte <= start_byte && end_byte <= u.end_byte) + { + continue; + } + let members = recovered_package_body_members(&run, end_byte); + for (member_start, member_end, name) in members { + facts.procedural_units.push(ProceduralUnitFacts { + name: Some(name), + start_line: line_at(member_start), + end_line: line_at(member_end.saturating_sub(1)), + start_byte: member_start, + end_byte: member_end, + cyclomatic_complexity: 0.0, + cognitive_complexity: 0.0, + embedded_query_structural: 0.0, + }); + } + } + facts + .procedural_units + .sort_by_key(|u| (u.start_byte, std::cmp::Reverse(u.end_byte))); + } +} + +/// The member routines recovered from an Oracle package-body parse gap: +/// `(start, end, name)` per `PROCEDURE|FUNCTION ` header at a `;` +/// boundary in a run that *leads* with `CREATE [OR REPLACE] +/// [EDITIONABLE|NONEDITIONABLE] PACKAGE BODY `. Package +/// *specifications* (no BODY keyword) declare prototypes, not members — +/// they recover nothing. +fn recovered_package_body_members(run: &ErasedSegment, run_end: u32) -> Vec<(u32, u32, String)> { + let mut tokens = Vec::new(); + leaf_tokens(run, &mut tokens); + let word = |j: usize| tokens.get(j).map(|(_, w)| w.as_str()).unwrap_or(""); + // Leading package-body header check. + let mut i = 0usize; + if !word(i).eq_ignore_ascii_case("CREATE") { + return Vec::new(); + } + i += 1; + if word(i).eq_ignore_ascii_case("OR") && word(i + 1).eq_ignore_ascii_case("REPLACE") { + i += 2; + } + if word(i).eq_ignore_ascii_case("EDITIONABLE") || word(i).eq_ignore_ascii_case("NONEDITIONABLE") + { + i += 1; + } + if !(word(i).eq_ignore_ascii_case("PACKAGE") && word(i + 1).eq_ignore_ascii_case("BODY")) { + return Vec::new(); + } + // Member headers at `;` boundaries (or right after the package's IS/AS). + let mut headers: Vec<(usize, u32, String)> = Vec::new(); + for (j, (token_start, _)) in tokens.iter().enumerate() { + let kind = word(j); + if !(kind.eq_ignore_ascii_case("PROCEDURE") || kind.eq_ignore_ascii_case("FUNCTION")) { + continue; + } + let prev = if j == 0 { "" } else { word(j - 1) }; + let boundary = + prev == ";" || prev.eq_ignore_ascii_case("IS") || prev.eq_ignore_ascii_case("AS"); + if !boundary { + continue; + } + let name = word(j + 1); + if name.is_empty() + || !name + .chars() + .next() + .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '"') + { + continue; + } + headers.push((j, *token_start, name.to_string())); + } + // Spans: to the END that balances the member's *body*. `BEGIN` opens + // the executable body — a declaration-level `CASE … END` expression + // balances without arming termination (Codex P2). Nested subprogram + // headers recurse, so an outer member's span survives its local + // routines instead of truncating at their headers (Codex P2). + // `END IF/LOOP/WHILE/REPEAT` close their own constructs; `END CASE` + // closes its CASE without re-opening. Fallback: the run's end. + let header_indices: std::collections::BTreeSet = + headers.iter().map(|&(token_idx, _, _)| token_idx).collect(); + fn member_end_index( + tokens: &[(u32, String)], + header_idx: usize, + header_indices: &std::collections::BTreeSet, + ) -> Option { + let word = |j: usize| tokens.get(j).map(|(_, w)| w.as_str()).unwrap_or(""); + // Prototype or body? From the header, the first of `;` vs `IS`/`AS` + // decides: `PROCEDURE p(a NUMBER);` is a bodyless prototype ending + // at its terminator, while a real member reaches `IS`/`AS` first + // (Codex P2 — the discrimination is per member, so a *nested* + // prototype is handled by its own recursion, never by an early + // return in the outer walk). + let mut j = header_idx + 1; + loop { + let w = word(j); + if w.is_empty() { + return None; + } + if w == ";" { + return Some(j); // a prototype: ends at its terminator + } + if w.eq_ignore_ascii_case("IS") || w.eq_ignore_ascii_case("AS") { + break; // a member with a body + } + j += 1; + } + // Balanced body walk: `BEGIN` opens (and arms termination), `CASE` + // opens, `END IF/LOOP/WHILE/REPEAT` close their own constructs, + // `END CASE` closes without re-opening, nested subprogram headers + // recurse (a nested prototype returns its `;`, a nested body its + // balanced END — either way the outer walk continues past it). + let mut depth = 0u32; + let mut opened = false; + while j < tokens.len() { + if header_indices.contains(&j) { + let nested_end = member_end_index(tokens, j, header_indices)?; + j = nested_end + 1; + continue; + } + let w = word(j); + if w.eq_ignore_ascii_case("BEGIN") { + depth += 1; + opened = true; + } else if w.eq_ignore_ascii_case("CASE") { + depth += 1; + } else if w.eq_ignore_ascii_case("END") { + let next = word(j + 1); + if next.eq_ignore_ascii_case("IF") + || next.eq_ignore_ascii_case("LOOP") + || next.eq_ignore_ascii_case("WHILE") + || next.eq_ignore_ascii_case("REPEAT") + { + j += 2; + continue; + } + depth = depth.saturating_sub(1); + if next.eq_ignore_ascii_case("CASE") { + // `END CASE` closes its statement; skip the CASE token + // so it doesn't re-open. + j += 2; + continue; + } + if opened && depth == 0 { + // Through the optional trailing name and terminator. + let mut t = j + 1; + if word(t) + .chars() + .next() + .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '"') + { + t += 1; + } + if word(t) == ";" { + t += 1; + } + return Some(t - 1); + } + } + j += 1; + } + None + } + let mut members = Vec::new(); + for (token_idx, member_start, name) in headers.iter() { + let member_end = member_end_index(&tokens, *token_idx, &header_indices) + .and_then(|idx| tokens.get(idx)) + .map(|(b, tok)| b + tok.len() as u32) + .unwrap_or(run_end); + members.push((*member_start, member_end, name.clone())); + } + members +} + +/// The recovered definition headers in a token run: each `(start byte, +/// name)` where a `CREATE [OR ALTER|REPLACE] [DEFINER = ] +/// FUNCTION|PROC[EDURE]|TRIGGER ` header sits at a statement +/// boundary — the run's start, or right after `;`, `GO`, or a MySQL custom +/// delimiter (`//`, `$$` — punctuation-only tokens, Codex P1). sqruff's +/// error recovery can swallow several definitions into one run, so a run +/// yields one unit *per header* (Codex P2). Standalone `ALTER ` +/// headers count only when `allow_alter` (T-SQL redefinitions) — MySQL's +/// `ALTER PROCEDURE p COMMENT …` alters metadata, not the body (Codex P2). +/// Names keep their original case; dotted qualification split by the lexer +/// is re-joined. +fn recovered_definition_headers( + tokens: &[(u32, String)], + allow_alter: bool, + declared_delimiters: &[String], +) -> Vec<(usize, u32, String)> { + fn header_at(tokens: &[(u32, String)], mut i: usize, allow_alter: bool) -> Option { + let word = |j: usize| tokens.get(j).map(|(_, w)| w.as_str()).unwrap_or(""); + let leading_alter = word(i).eq_ignore_ascii_case("ALTER"); + if !(word(i).eq_ignore_ascii_case("CREATE") || (leading_alter && allow_alter)) { + return None; + } + i += 1; + // `CREATE OR ALTER` (T-SQL) / `CREATE OR REPLACE` (MariaDB). + if word(i).eq_ignore_ascii_case("OR") + && (word(i + 1).eq_ignore_ascii_case("ALTER") + || word(i + 1).eq_ignore_ascii_case("REPLACE")) + { + i += 2; + } + // MySQL `DEFINER = ` between CREATE and the kind keyword: + // the account is a multi-token expression (`` `root`@`localhost` ``, + // `CURRENT_USER()`), so consume until the kind keyword appears + // (bounded — Codex P1). + if word(i).eq_ignore_ascii_case("DEFINER") { + let limit = i + 8; + i += 1; + while i <= limit { + let w = word(i); + if w.eq_ignore_ascii_case("FUNCTION") + || w.eq_ignore_ascii_case("PROCEDURE") + || w.eq_ignore_ascii_case("PROC") + || w.eq_ignore_ascii_case("TRIGGER") + || w.is_empty() + { + break; + } + i += 1; + } + } + let kind = word(i); + if !(kind.eq_ignore_ascii_case("FUNCTION") + || kind.eq_ignore_ascii_case("PROCEDURE") + || kind.eq_ignore_ascii_case("PROC") + || kind.eq_ignore_ascii_case("TRIGGER")) + { + return None; + } + i += 1; + let mut name = tokens.get(i)?.1.clone(); + // Identifier sanity: a name never starts with punctuation — quoted + // identifiers (`[bracketed]`, `"double"`, MySQL backticks — + // Codex P2) are names too. + if !name + .chars() + .next() + .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '[' || c == '"' || c == '`') + { + return None; + } + // Re-join dotted qualification the lexer split (`dbo` `.` `f`). + while tokens.get(i + 1).is_some_and(|(_, w)| w == ".") { + if let Some((_, part)) = tokens.get(i + 2) { + name = format!("{name}.{part}"); + i += 2; + } else { + break; + } + } + Some(name) + } + let mut headers = Vec::new(); + for j in 0..tokens.len() { + let boundary = j == 0 || { + let prev = tokens[j - 1].1.as_str(); + prev == ";" + || prev.eq_ignore_ascii_case("GO") + || is_custom_delimiter(prev) + || declared_delimiters.iter().any(|d| d == prev) + }; + if !boundary { + continue; + } + if let Some(name) = header_at(tokens, j, allow_alter) { + headers.push((j, tokens[j].0, name)); + } + } + headers +} + +/// Where a recovered MySQL definition *ends*: its balanced `BEGIN … END` +/// body through the following delimiter, or — for an opener-less +/// single-statement body — its first top-level `;`/delimiter. Ending at +/// the routine's own terminator keeps intervening statements out of the +/// unit's span and complexity (Codex P2). +fn mysql_recovered_unit_end( + tokens: &[(u32, String)], + header_idx: usize, + declared_delimiters: &[String], +) -> Option { + let word = |j: usize| tokens.get(j).map(|(_, w)| w.as_str()).unwrap_or(""); + let is_delim = + |w: &str| w == ";" || is_custom_delimiter(w) || declared_delimiters.iter().any(|d| d == w); + let token_end = |j: usize| tokens.get(j).map(|(b, w)| b + w.len() as u32); + let mut paren = 0i32; + let mut depth = 0u32; + let mut opened = false; + let mut j = header_idx + 1; + while j < tokens.len() { + let w = word(j); + if w == "(" { + paren += 1; + } else if w == ")" { + paren -= 1; + } else if paren <= 0 { + if w.eq_ignore_ascii_case("BEGIN") { + depth += 1; + opened = true; + } else if w.eq_ignore_ascii_case("CASE") { + depth += 1; + } else if w.eq_ignore_ascii_case("END") { + let next = word(j + 1); + if next.eq_ignore_ascii_case("IF") + || next.eq_ignore_ascii_case("LOOP") + || next.eq_ignore_ascii_case("WHILE") + || next.eq_ignore_ascii_case("REPEAT") + { + j += 2; + continue; + } + depth = depth.saturating_sub(1); + if next.eq_ignore_ascii_case("CASE") { + j += 2; + continue; + } + if opened && depth == 0 { + // Through the following delimiter when present. + let t = if is_delim(word(j + 1)) { j + 1 } else { j }; + return token_end(t); + } + } else if !opened && depth == 0 && is_delim(w) { + // A single-statement body ends at its terminator. + return token_end(j); + } + } + j += 1; + } + None +} + +/// The delimiters a MySQL client script *declares* (`DELIMITER %%`): each +/// token following a `DELIMITER` keyword anywhere in the file. Declared +/// delimiters are arbitrary strings — no punctuation whitelist covers them +/// (Codex P1). +fn declared_delimiters(root: &ErasedSegment) -> Vec { + let mut tokens = Vec::new(); + leaf_tokens(root, &mut tokens); + let mut delimiters = Vec::new(); + for pair in tokens.windows(2) { + if pair[0].1.eq_ignore_ascii_case("DELIMITER") && pair[1].1 != ";" { + delimiters.push(pair[1].1.clone()); + } + } + delimiters +} + +/// Whether a token looks like a MySQL client custom delimiter (`//`, `$$`, +/// `;;`): punctuation-only, drawn from the characters delimiters are made +/// of. Identifiers, quoted names, and operators with operands never match. +fn is_custom_delimiter(word: &str) -> bool { + !word.is_empty() + && word + .chars() + .all(|c| matches!(c, '/' | '$' | ';' | '|' | '!')) +} + +/// The leaf code tokens of a node with their start bytes, in source order. +fn leaf_tokens(node: &ErasedSegment, out: &mut Vec<(u32, String)>) { + let children = node.segments(); + if children.is_empty() { + if !(node.is_comment() || node.is_whitespace() || node.is_meta()) { + let raw = node.raw().trim(); + if !raw.is_empty() + && let Some(pm) = node.get_position_marker() + { + out.push((pm.source_slice.start as u32, raw.to_string())); + } + } + return; + } + for child in children { + leaf_tokens(child, out); + } } /// Collect the distinct read and write object names touched by the file. @@ -2387,8 +3783,15 @@ fn extract_procedural_units( /// children that are *not* inside a FROM/JOIN element (e.g. the `accounts` /// in `UPDATE accounts …`, the `target` in `INSERT INTO target …`). Names are /// uppercased so case variants collapse to one object. +/// +/// `procedural` statements are skipped entirely: a routine body's objects +/// are touched when the routine is *called*, not when the file is applied. +/// The skip covers both well-formed definitions (whose bodies the +/// `PROCEDURAL_DEFINITIONS` crawl boundary would exclude anyway) and T-SQL +/// body fragments that sqruff splits into sibling statements (Codex P1). fn collect_touched_objects( - root: &ErasedSegment, + statements: &[ErasedSegment], + kinds: &[StatementFacts], line_at: &impl Fn(u32) -> u32, emit_contributions: bool, ) -> ( @@ -2409,7 +3812,10 @@ fn collect_touched_objects( // So a reference is treated as CTE-local only when an *ancestor* // `WithCompoundStatement` defines its name. We resolve that by node // identity (`cte_local_refs`), not by a flat name set. - for stmt in &top_level_statements(root) { + for (stmt, facts) in statements.iter().zip(kinds.iter()) { + if facts.kind == StatementKind::Procedural { + continue; + } let cte_local = cte_local_refs(stmt); collect_statement_objects( stmt, @@ -2426,14 +3832,26 @@ fn collect_touched_objects( /// Top-level `Statement` nodes (one per DML/DDL/… statement in the file), /// not descending into nested statements (a procedural body or a subquery's -/// inner statement is handled within its owner's scope). -fn top_level_statements(root: &ErasedSegment) -> Vec { +/// inner statement is handled within its owner's scope). The single crawl +/// definition every statement-indexed consumer shares (`classify_statements`, +/// `extract_objects`, `procedural::extract`), so their zips stay aligned. +/// +/// Under BigQuery, the bare `END;` scripting bracket is filtered out here — +/// it is a syntactic closer, not a statement, and counting it would inflate +/// `sql.statement.count`, the `unknown` kind, and statement-kind entropy +/// (Codex P2). Filtering at the shared crawl keeps every consumer's +/// index-zip aligned. The matching `BEGIN;` stays: it *is* the anonymous +/// scripting block. +pub(crate) fn top_level_statements(root: &ErasedSegment, bigquery: bool) -> Vec { root.recursive_crawl( &SyntaxSet::single(SyntaxKind::Statement), false, &SyntaxSet::EMPTY, true, ) + .into_iter() + .filter(|stmt| !bigquery || bare_scripting_bracket(stmt) != Some(ScriptingBracket::End)) + .collect() } /// The `TableReference` nodes within `stmt` that resolve to a CTE alias visible @@ -2465,12 +3883,7 @@ fn cte_local_refs(stmt: &ErasedSegment) -> Vec { // Every table reference in this WITH's subtree (the CTE bodies and the // main query) is in-scope for these names; mark the ones whose name // matches as CTE-local. - for tr in w.recursive_crawl( - &SyntaxSet::single(SyntaxKind::TableReference), - true, - &SyntaxSet::EMPTY, - true, - ) { + for tr in w.recursive_crawl(&TABLE_REFERENCES, true, &SyntaxSet::EMPTY, true) { if names.contains(&tr.raw().to_ascii_uppercase()) { local.push(tr); } @@ -2500,9 +3913,21 @@ fn collect_statement_objects( // `recurse_into = false` returns the outermost match on each path. const TARGET_REFS: SyntaxSet = SyntaxSet::new(&[ SyntaxKind::TableReference, + SyntaxKind::OracleTableReference, SyntaxKind::FunctionName, SyntaxKind::DatabaseReference, ]); + // Oracle routine/package/synonym drops name their target through kinds + // that are far too generic to scan on every write statement (a + // `seq.nextval` in an INSERT is an `ObjectReference` too): `DROP + // PROCEDURE p` → `OracleFunctionName`, `DROP PACKAGE`/`DROP SYNONYM` → + // `ObjectReference`. Without them the statement counts in + // `sql.ddl.drop_count` but its target is missing from the write objects + // (Codex P2), so the extended set applies to the drop family only. + const DROP_TARGET_REFS: SyntaxSet = TARGET_REFS.union(&SyntaxSet::new(&[ + SyntaxKind::ObjectReference, + SyntaxKind::OracleFunctionName, + ])); // Writes: the mutated target of each write statement is its *first* // statement-level reference in document order (not inside a nested SELECT). @@ -2521,22 +3946,49 @@ fn collect_statement_objects( // no-op and only the read pass below runs). let write_stmts = stmt.recursive_crawl(&WRITE_STATEMENTS, true, &PROCEDURAL_DEFINITIONS, true); for ws in &write_stmts { - let stmt_tables = ws.recursive_crawl(&TARGET_REFS, false, &SELECT_STATEMENT, true); + // The Oracle drop family names its target through generic reference + // kinds; every other write statement uses the narrow set (see + // `DROP_TARGET_REFS`). + let refs = if matches!( + ws.get_type(), + SyntaxKind::OracleDropPackageStatement + | SyntaxKind::OracleDropProcedureStatement + | SyntaxKind::OracleDropSynonymStatement + | SyntaxKind::OracleDropDatabaseLinkStatement + ) { + &DROP_TARGET_REFS + } else { + &TARGET_REFS + }; + let stmt_tables = ws.recursive_crawl(refs, false, &SELECT_STATEMENT, true); // Multi-target DDL mutates *every* statement-level reference (`DROP - // TABLE a, b`, `TRUNCATE a, b`). Host-object shapes mutate only their - // first reference (the target); later references are reads: + // TABLE a, b`, `TRUNCATE a, b`), and so do INSERT statements — a plain + // `INSERT INTO t SELECT … FROM s` keeps `s` inside the SELECT (the + // crawl stops there), while Oracle `INSERT ALL INTO a … INTO b …` + // legitimately lists several statement-level targets, all of them + // written (Codex P2). Host-object shapes mutate only their first + // reference (the target); later references are reads: // - DML: `UPDATE dst … FROM src`, `MERGE INTO dst USING src` — `dst` // is written, sources are read. // - `CREATE INDEX idx ON t` / `DROP INDEX idx ON t` — `idx` is the // written object, the host table `t` is only a read. + // - `CREATE TABLE child (… REFERENCES parent(id))` and + // `ALTER TABLE … ADD CONSTRAINT … REFERENCES parent` mutate only + // their subject; the referenced table is read, not written + // (Codex P2). let first_target_only = matches!( ws.get_type(), - SyntaxKind::InsertStatement - | SyntaxKind::UpdateStatement + SyntaxKind::UpdateStatement + | SyntaxKind::OracleUpdateStatement | SyntaxKind::DeleteStatement + | SyntaxKind::OracleDeleteStatement | SyntaxKind::MergeStatement | SyntaxKind::CreateIndexStatement | SyntaxKind::DropIndexStatement + | SyntaxKind::CreateTableStatement + | SyntaxKind::OracleCreateTableStatement + | SyntaxKind::AlterTableStatement + | SyntaxKind::OracleAlterTableStatement ); let all_targets = !first_target_only; for (i, tr) in stmt_tables.iter().enumerate() { @@ -2568,12 +4020,7 @@ fn collect_statement_objects( true, ); for elem in &from_elems { - for tr in elem.recursive_crawl( - &SyntaxSet::single(SyntaxKind::TableReference), - true, - &SyntaxSet::EMPTY, - true, - ) { + for tr in elem.recursive_crawl(&TABLE_REFERENCES, true, &SyntaxSet::EMPTY, true) { let is_write_target = write_target_nodes.iter().any(|w| w.is(&tr)); if !is_cte_local(&tr) && !is_write_target { record_object_occurrence( @@ -2612,7 +4059,7 @@ fn record_object_occurrence( .or_insert(candidate); } -fn statement_span(statement: &StatementFacts) -> SourceSpan { +pub(crate) fn statement_span(statement: &StatementFacts) -> SourceSpan { SourceSpan::new( statement.start_byte, statement.end_byte, @@ -2644,6 +4091,22 @@ fn record_change_risk( } } +fn record_object( + evidence: &mut Vec, + enabled: bool, + metric: &'static str, + reason: &'static str, + span: SourceSpan, +) { + if enabled { + evidence.push(ObjectEvidence { + metric, + reason, + span, + }); + } +} + // ── Halstead ──────────────────────────────────────────────────────────── fn extract_halstead(root: &ErasedSegment, h: &mut HalsteadFacts) { @@ -2736,6 +4199,25 @@ fn count_anywhere(node: &ErasedSegment, kind: SyntaxKind) -> u32 { .len() as u32 } +/// Count occurrences of any kind in `set` anywhere in the subtree. +fn count_any(node: &ErasedSegment, set: &SyntaxSet) -> u32 { + node.recursive_crawl(set, true, &SyntaxSet::EMPTY, true) + .len() as u32 +} + +/// The keyword tokens whose raw text equals `word` (case-insensitive). +fn keyword_tokens(node: &ErasedSegment, word: &str) -> Vec { + node.recursive_crawl( + &SyntaxSet::single(SyntaxKind::Keyword), + true, + &SyntaxSet::EMPTY, + true, + ) + .into_iter() + .filter(|k| k.raw().eq_ignore_ascii_case(word)) + .collect() +} + /// Count keyword tokens whose raw text equals `word` (case-insensitive). fn count_keyword(node: &ErasedSegment, word: &str) -> u32 { let kws = node.recursive_crawl( diff --git a/crates/mehen-sql/src/lib.rs b/crates/mehen-sql/src/lib.rs index a5942253f..6ebd0071d 100644 --- a/crates/mehen-sql/src/lib.rs +++ b/crates/mehen-sql/src/lib.rs @@ -32,6 +32,7 @@ mod dialect; mod facts; mod loc; mod metrics; +mod procedural; use mehen_core::{ AnalysisBackend, AnalysisConfig, ContributionCollector, Language, LanguageAnalysis, @@ -132,7 +133,12 @@ impl LanguageAnalyzer for SqlAnalyzer { let line_index = &source.line_index; let line_at = |byte: u32| line_index.line_at(byte); - let mut file_facts = facts::extract(&parsed, &dialect, line_at, config.emit_contributions); + let mut file_facts = facts::extract( + &parsed, + line_at, + config.emit_contributions, + resolution.effective, + ); // Lexer errors (malformed tokens) are distinct from unparsable parse // segments. The current sqruff release never populates this vector, but // surface them into parser-health so a future version cannot make @@ -158,6 +164,55 @@ impl LanguageAnalyzer for SqlAnalyzer { item.factor.reason(), ); } + // Procedural composites are evidence-backed too: the published value + // equals the sum of its contributions by construction (§4.7). + for item in &file_facts.procedural.evidence { + let metric = match item.metric { + procedural::ProceduralMetric::Cyclomatic => "sql.procedural.cyclomatic_complexity", + procedural::ProceduralMetric::Cognitive => "sql.procedural.cognitive_complexity", + procedural::ProceduralMetric::EmbeddedQueryMax => { + "sql.structural_complexity.max_embedded_query" + } + procedural::ProceduralMetric::BlockCount => "sql.procedural.block_count", + procedural::ProceduralMetric::RoutineCount => "sql.procedural.routine_count", + procedural::ProceduralMetric::LoopCount => "sql.procedural.loop_count", + procedural::ProceduralMetric::IfCount => "sql.procedural.if_count", + procedural::ProceduralMetric::CaseStatementCount => { + "sql.procedural.case_statement_count" + } + procedural::ProceduralMetric::ExceptionHandlerCount => { + "sql.procedural.exception_handler_count" + } + procedural::ProceduralMetric::ReturnCount => "sql.procedural.return_count", + procedural::ProceduralMetric::RaiseThrowCount => "sql.procedural.raise_throw_count", + procedural::ProceduralMetric::DynamicSqlCount => "sql.procedural.dynamic_sql_count", + procedural::ProceduralMetric::MaxBlockDepth => "sql.procedural.max_block_depth", + }; + contribution_collector.record(metric, item.span, item.amount, item.reason); + } + // Predicate NOTs are evidence-backed too — the improved + // `sql.predicate.not_count` explains each counted negation with its + // token span (Codex P1). + for &(start, end) in &file_facts.predicates.not_spans { + contribution_collector.record( + "sql.predicate.not_count", + SourceSpan { + start_byte: start, + end_byte: end, + start_line: line_at(start), + end_line: line_at(end.saturating_sub(1)), + }, + 1.0, + "sql.predicate.not", + ); + } + // Raw object-family counters (`sql.dml.*`, `sql.ddl.*`, `sql.dcl.*`, + // `sql.transaction.*`) are evidence-backed: both the per-statement + // classification path and the anonymous-block body scan record one + // entry per increment (Codex P1). + for item in &file_facts.object_evidence { + contribution_collector.record(item.metric, item.span, 1.0, item.reason); + } // Per-statement spaces so top-offenders / nested reporting can attribute // metrics to a statement's line range (research foundation §4.4). @@ -286,6 +341,21 @@ fn attach_procedural_unit_spaces( }, ); space.name = unit.name.clone(); + // Per-routine procedural composites (Phase 3): the same keys as the + // file-level aggregates, scoped to this routine — the numbers + // `mehen top-offenders` shows next to a function name, and the + // complexity denominator CRAP will use. + space.metrics.insert( + "sql.procedural.cyclomatic_complexity", + unit.cyclomatic_complexity, + ); + space.metrics.insert( + "sql.procedural.cognitive_complexity", + unit.cognitive_complexity, + ); + space + .metrics + .insert("sql.structural_complexity", unit.embedded_query_structural); while let Some((_, _, open_end)) = stack.last() { if unit.start_byte >= *open_end { close_one(&mut stack, &mut top_level); @@ -519,6 +589,18 @@ pub const PUBLISHED_METRIC_KEYS: &[&str] = &[ "sql.predicate.max_boolean_depth", "sql.predicate.not_count", "sql.predicate.null_semantics_risk_count", + "sql.procedural.block_count", + "sql.procedural.case_statement_count", + "sql.procedural.cognitive_complexity", + "sql.procedural.cyclomatic_complexity", + "sql.procedural.dynamic_sql_count", + "sql.procedural.exception_handler_count", + "sql.procedural.if_count", + "sql.procedural.loop_count", + "sql.procedural.max_block_depth", + "sql.procedural.raise_throw_count", + "sql.procedural.return_count", + "sql.procedural.routine_count", "sql.query_block.avg_select_items", "sql.query_block.count", "sql.query_block.max_depth", @@ -540,6 +622,7 @@ pub const PUBLISHED_METRIC_KEYS: &[&str] = &[ "sql.statement.kind_entropy", "sql.statement.unparsed_count", "sql.structural_complexity", + "sql.structural_complexity.max_embedded_query", "sql.subquery.correlated_count", "sql.subquery.count", "sql.subquery.exists_count", diff --git a/crates/mehen-sql/src/metrics.rs b/crates/mehen-sql/src/metrics.rs index e3de66239..2b25e699d 100644 --- a/crates/mehen-sql/src/metrics.rs +++ b/crates/mehen-sql/src/metrics.rs @@ -15,31 +15,6 @@ use crate::dialect::{DialectResolution, dialect_label}; use crate::facts::{SqlFileFacts, StatementKind}; use crate::loc::SqlLoc; -/// All distinct statement kinds, so `kind_count.` keys are emitted with -/// an explicit `0` when absent (grepability over silent omission). -const ALL_STATEMENT_KINDS: &[StatementKind] = &[ - StatementKind::Select, - StatementKind::WithSelect, - StatementKind::Insert, - StatementKind::Update, - StatementKind::Delete, - StatementKind::Merge, - StatementKind::CreateView, - StatementKind::CreateTable, - StatementKind::CreateTableAsSelect, - StatementKind::CreateOther, - StatementKind::AlterTable, - StatementKind::Drop, - StatementKind::Truncate, - StatementKind::Grant, - StatementKind::Revoke, - StatementKind::TransactionControl, - StatementKind::Explain, - StatementKind::Procedural, - StatementKind::SetOperation, - StatementKind::Unknown, -]; - const JOIN_KINDS: &[&str] = &[ "inner", "left", "right", "full", "cross", "natural", "lateral", ]; @@ -66,6 +41,7 @@ pub(crate) fn publish( publish_expressions(facts, target); publish_output(facts, target); publish_objects(facts, target); + publish_procedural(facts, target); publish_dialect(facts, loc, dialect, target); publish_parser(facts, loc, target); publish_halstead(facts, target); @@ -97,8 +73,9 @@ fn publish_loc(loc: &SqlLoc, target: &mut MetricSet) { fn publish_statements(facts: &SqlFileFacts, target: &mut MetricSet) { set(target, "sql.statement.count", facts.statements.len()); - // kind_count. - for kind in ALL_STATEMENT_KINDS { + // kind_count. — every kind gets an explicit `0` when absent + // (grepability over silent omission). + for kind in StatementKind::ALL { let n = facts.statements.iter().filter(|s| s.kind == *kind).count(); set( target, @@ -377,6 +354,55 @@ fn publish_objects(facts: &SqlFileFacts, target: &mut MetricSet) { ); } +/// Procedural-SQL metrics (research foundation §6.17, Phase 3). Published +/// unconditionally — a purely declarative file reports explicit zeros, the +/// same contract as every other family. +fn publish_procedural(facts: &SqlFileFacts, target: &mut MetricSet) { + let p = &facts.procedural; + set(target, "sql.procedural.block_count", p.block_count); + set(target, "sql.procedural.routine_count", p.routine_count); + set( + target, + "sql.procedural.cyclomatic_complexity", + p.cyclomatic_complexity, + ); + set( + target, + "sql.procedural.cognitive_complexity", + p.cognitive_complexity, + ); + set(target, "sql.procedural.max_block_depth", p.max_block_depth); + set(target, "sql.procedural.loop_count", p.loop_count); + set(target, "sql.procedural.if_count", p.if_count); + set( + target, + "sql.procedural.case_statement_count", + p.case_statement_count, + ); + set( + target, + "sql.procedural.exception_handler_count", + p.exception_handler_count, + ); + set(target, "sql.procedural.return_count", p.return_count); + set( + target, + "sql.procedural.raise_throw_count", + p.raise_throw_count, + ); + set( + target, + "sql.procedural.dynamic_sql_count", + p.dynamic_sql_count, + ); + // §9.3: the worst embedded query inside any single routine. + set( + target, + "sql.structural_complexity.max_embedded_query", + p.max_embedded_query_structural, + ); +} + fn publish_dialect( facts: &SqlFileFacts, _loc: &SqlLoc, diff --git a/crates/mehen-sql/src/procedural.rs b/crates/mehen-sql/src/procedural.rs new file mode 100644 index 000000000..13760f930 --- /dev/null +++ b/crates/mehen-sql/src/procedural.rs @@ -0,0 +1,2250 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Konstantin Vyatkin + +//! Procedural SQL metrics (research foundation §6.17, Phase 3). +//! +//! PL/SQL, T-SQL, MySQL, and BigQuery-scripting control flow is measured by a +//! single dialect-agnostic **token state machine** rather than per-dialect +//! typed-node walks. The empirical basis (parser comparison §9, probed on +//! sqruff v0.40.0): +//! +//! - Oracle bodies parse into rich typed nodes (`OracleIfThenStatement`, +//! `WhileLoopStatement`, …), but their leaves are ordinary `Keyword` tokens; +//! - T-SQL bodies parse as keyword-led `Statement` shapes or spill into +//! top-level `Unparsable` runs; +//! - MySQL routine bodies are one `Unparsable` run; +//! - tokens inside `Unparsable` stay classified (`Word`/`SingleQuote`/ +//! `InlineComment`), so keyword scanning is trivia-safe: a comment +//! `-- exec this` or a literal `'goto'` can never false-match. +//! +//! One machine over the classified token stream therefore covers all four +//! families uniformly, cannot double-count a construct that happens to be +//! typed *and* keyword-visible, and degrades gracefully exactly where the +//! parser does. This is the "linter-grade" procedural depth the research +//! foundation scopes for Phase 3 — deeper T-SQL semantics would go through +//! the ANTLR `tsql` reserve path (parser comparison §7.4), not through more +//! token heuristics. +//! +//! ## What is measured where +//! +//! The machine scans three region kinds: +//! +//! 1. **Routine definitions** (statements classified `procedural`): +//! `CREATE PROCEDURE`/`FUNCTION`/`TRIGGER`, package/type bodies. +//! 2. **Anonymous blocks / scripting statements** (statements classified +//! `anonymous_block`): `DECLARE … BEGIN … END`, T-SQL `IF`/`WHILE`/ +//! `BEGIN`-led batch statements, BigQuery scripting statements. +//! 3. **Top-level `Unparsable` runs** outside those statements — this is +//! where T-SQL procedure bodies spill — gated by a marker pre-scan so a +//! broken `SELECT` never grows procedural metrics. +//! +//! Cyclomatic complexity follows Sonar's documented PL/SQL increments +//! (research foundation §3.1): +1 per routine/anonymous block entry, `IF`, +//! `ELSIF`, loop, `CASE`-statement `WHEN` arm, exception handler (`WHEN … +//! THEN` handler / `BEGIN CATCH`), `EXIT WHEN`/`CONTINUE WHEN`, raise/throw, +//! and boolean `AND`/`OR` inside bodies. One documented deviation: `WHEN` +//! arms of CASE **expressions** are *not* counted here — they already belong +//! to the declarative `sql.case.*` family, and mehen keeps the declarative +//! and procedural families disjoint where Sonar has a single number. +//! +//! Cognitive complexity mirrors the spirit of code cognitive complexity: +//! control structures cost `1 + nesting`, flat `ELSIF`/`ELSE` branches cost +//! 1, `GOTO` costs 1, and boolean operator *sequences* (not individual +//! operators) cost 1 each. +//! +//! Every increment emits [`ProceduralEvidence`] so the published metric is +//! the sum of its evidence by construction (the crate-wide explainability +//! invariant, `tests/contributions.rs`). + +use mehen_core::SourceSpan; +use sqruff_lib_core::dialects::init::DialectKind; +use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet}; +use sqruff_lib_core::parser::segments::ErasedSegment; + +use crate::facts::{ChangeRiskEvidence, ChangeRiskFactor, SqlFileFacts, StatementKind}; + +/// Which published metric a piece of procedural evidence contributes to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ProceduralMetric { + Cyclomatic, + Cognitive, + /// `sql.structural_complexity.max_embedded_query` — one entry, for the + /// winning routine. + EmbeddedQueryMax, + // Raw counts (Codex P1): every published `sql.procedural.*_count` is + // evidence-backed under its own key, so output can answer *why* a value + // moved. `max_block_depth` keeps the invariant with a single + // contribution at the deepest opener whose amount is the observed + // depth (Codex P1). + BlockCount, + RoutineCount, + LoopCount, + IfCount, + CaseStatementCount, + ExceptionHandlerCount, + ReturnCount, + RaiseThrowCount, + DynamicSqlCount, + MaxBlockDepth, +} + +/// One source-resolved increment of a procedural composite. `amount` is the +/// full contribution of the construct (for cognitive that is `1 + nesting`), +/// so `metric value == Σ evidence.amount` holds by construction. +#[derive(Clone, Debug)] +pub(crate) struct ProceduralEvidence { + pub span: SourceSpan, + pub metric: ProceduralMetric, + pub amount: f64, + pub reason: &'static str, +} + +/// Aggregated procedural facts for one file (research foundation §6.17). +#[derive(Clone, Debug, Default)] +pub(crate) struct ProceduralFacts { + /// `BEGIN … END` block openers (plain blocks, `BEGIN TRY`, `BEGIN CATCH`; + /// transaction-control `BEGIN` excluded). + pub block_count: u32, + /// Routine definitions — mirrors `procedural_units.len()`. + pub routine_count: u32, + pub cyclomatic_complexity: f64, + pub cognitive_complexity: f64, + /// Deepest `BEGIN … END` nesting observed. + pub max_block_depth: u32, + /// The opener where that deepest nesting was first observed — the span + /// of `max_block_depth`'s single evidence entry (Codex P1). + pub max_block_depth_span: Option, + /// Loops of any flavor: `LOOP`, `WHILE`, `FOR … LOOP/DO`, `REPEAT`. + pub loop_count: u32, + /// `IF` statements plus `ELSIF`/`ELSEIF` branches. + pub if_count: u32, + /// Procedural `CASE` **statements** (closed by `END CASE`) — CASE + /// expressions stay in the declarative `sql.case.*` family. + pub case_statement_count: u32, + /// PL/SQL `EXCEPTION WHEN … THEN` handlers and T-SQL `BEGIN CATCH`. + pub exception_handler_count: u32, + pub return_count: u32, + /// `RAISE`, `RAISE_APPLICATION_ERROR`, `THROW`, `RAISERROR`, `SIGNAL`, + /// `RESIGNAL`. + pub raise_throw_count: u32, + /// Dynamic SQL: `EXECUTE IMMEDIATE`, `sp_executesql`, `EXEC(…)`, + /// `DBMS_SQL` usage. + pub dynamic_sql_count: u32, + /// Max `sql.structural_complexity` over the query facts embedded in a + /// single routine (§9.3 `sql.structural_complexity.max_embedded_query`). + pub max_embedded_query_structural: f64, + /// Every cyclomatic/cognitive increment with span and reason. + pub evidence: Vec, +} + +/// Evidence reason codes (stable public identifiers). +mod reason { + pub(crate) const ENTRY: &str = "sql.procedural.entry"; + pub(crate) const IF: &str = "sql.procedural.if"; + pub(crate) const ELSIF: &str = "sql.procedural.elsif"; + pub(crate) const ELSE: &str = "sql.procedural.else"; + pub(crate) const LOOP: &str = "sql.procedural.loop"; + pub(crate) const CASE_STATEMENT: &str = "sql.procedural.case_statement"; + pub(crate) const CASE_WHEN: &str = "sql.procedural.case_when"; + pub(crate) const EXCEPTION_HANDLER: &str = "sql.procedural.exception_handler"; + pub(crate) const CONDITIONAL_EXIT: &str = "sql.procedural.conditional_exit"; + pub(crate) const RAISE_THROW: &str = "sql.procedural.raise_throw"; + pub(crate) const GOTO: &str = "sql.procedural.goto"; + pub(crate) const BOOLEAN_SEQUENCE: &str = "sql.procedural.boolean_sequence"; + pub(crate) const BOOLEAN_OPERATOR: &str = "sql.procedural.boolean_operator"; + pub(crate) const EMBEDDED_QUERY: &str = "sql.procedural.embedded_query"; + pub(crate) const BLOCK: &str = "sql.procedural.block"; + pub(crate) const DEEPEST_BLOCK: &str = "sql.procedural.deepest_block"; + pub(crate) const ROUTINE: &str = "sql.procedural.routine"; + pub(crate) const RETURN: &str = "sql.procedural.return"; + pub(crate) const DYNAMIC_SQL: &str = "sql.procedural.dynamic_sql"; +} + +// ── token model ──────────────────────────────────────────────────────── + +/// One classified code token (trivia excluded) with its source position. +struct PToken { + /// Uppercased raw text. + word: String, + /// Whether the lexer classified it as keyword-like. `Keyword` in parsed + /// regions; `Word` in `Unparsable` runs (where everything keyword-shaped + /// lexes as `Word`); `FunctionNameIdentifier` so `RAISE_APPLICATION_ERROR` + /// and `sp_executesql` count when they parse as calls. A `NakedIdentifier` + /// (e.g. a column named `raise` in parsed SQL) is *not* keyword-like and + /// can never trip the machine. + keyword_like: bool, + /// Whether the token is a parsed function-call name + /// (`FunctionNameIdentifier`) — distinguishes the scalar `IF(…)` function + /// from a statement-level `IF (cond)` with a parenthesized condition. + is_function_name: bool, + span: SourceSpan, +} + +/// Flatten the classified leaf tokens of `region`, excluding comments, +/// whitespace, and meta tokens. +fn tokens_of(region: &ErasedSegment, line_at: &impl Fn(u32) -> u32) -> Vec { + fn walk(node: &ErasedSegment, line_at: &impl Fn(u32) -> u32, out: &mut Vec) { + let children = node.segments(); + if children.is_empty() { + if node.is_comment() || node.is_whitespace() || node.is_meta() { + return; + } + let raw = node.raw(); + let raw = raw.trim(); + if raw.is_empty() { + return; + } + let kind = node.get_type(); + let span = node + .get_position_marker() + .map(|pm| { + let start = pm.source_slice.start as u32; + let end = pm.source_slice.end as u32; + SourceSpan::new(start, end, line_at(start), line_at(end.saturating_sub(1))) + }) + .unwrap_or_else(SourceSpan::empty); + out.push(PToken { + word: raw.to_ascii_uppercase(), + keyword_like: matches!( + kind, + SyntaxKind::Keyword | SyntaxKind::Word | SyntaxKind::FunctionNameIdentifier + ), + is_function_name: kind == SyntaxKind::FunctionNameIdentifier, + span, + }); + return; + } + for child in children { + walk(child, line_at, out); + } + } + let mut out = Vec::new(); + walk(region, line_at, &mut out); + out +} + +// ── region collection ────────────────────────────────────────────────── + +const UNPARSABLE: SyntaxSet = SyntaxSet::single(SyntaxKind::Unparsable); +const MULTI_STATEMENT: SyntaxSet = SyntaxSet::single(SyntaxKind::MultiStatementSegment); +const SELECT_STATEMENT: SyntaxSet = SyntaxSet::single(SyntaxKind::SelectStatement); + +/// The nodes that *are* embedded queries — the crawl roots for +/// [`query_facts_of`]. Procedural assignment/condition expressions outside +/// these do not feed `sql.structural_complexity`: `x := ((1 + 2) * 3);` +/// embeds no query and must not score one (Codex P2). Mirrors the +/// dialect-folded DML statement sets in `facts.rs`. +const QUERY_ROOTS: SyntaxSet = SyntaxSet::new(&[ + SyntaxKind::WithCompoundStatement, + SyntaxKind::SetExpression, + SyntaxKind::SelectStatement, + SyntaxKind::InsertStatement, + SyntaxKind::OracleInsertStatement, + SyntaxKind::BulkInsertStatement, + SyntaxKind::UpdateStatement, + SyntaxKind::OracleUpdateStatement, + SyntaxKind::DeleteStatement, + SyntaxKind::OracleDeleteStatement, + SyntaxKind::MergeStatement, +]); + +/// Whether an `Unparsable` run looks procedural. Gate before scanning so a +/// broken `SELECT` (or any non-procedural parse failure) never grows +/// procedural metrics. Markers are chosen to be unambiguous: reserved control +/// keywords and closer pairs that cannot appear in declarative SQL. +fn unparsable_is_procedural(tokens: &[PToken]) -> bool { + let word = |i: usize| tokens.get(i).map(|t| t.word.as_str()).unwrap_or(""); + for (i, t) in tokens.iter().enumerate() { + match t.word.as_str() { + // `BEGIN` (block, not `BEGIN TRANSACTION` / Service Broker + // `BEGIN CONVERSATION` / `BEGIN DIALOG`) is procedural context. + "BEGIN" + if !matches!( + word(i + 1), + "TRANSACTION" + | "TRAN" + | "WORK" + | "DISTRIBUTED" + | "CONVERSATION" + | "DIALOG" + | ";" + ) => + { + return true; + } + // Construct closers that only procedural dialects produce. + "END" if matches!(word(i + 1), "IF" | "LOOP" | "WHILE" | "CASE" | "REPEAT") => { + return true; + } + "ELSIF" | "ELSEIF" => return true, + // An `ELSE`-led fragment is the else branch of a control + // statement the grammar split off (T-SQL `IF …; ELSE …`). + "ELSE" if i == 0 => return true, + // A *leading* raise statement the grammar lost whole + // (standalone T-SQL `THROW 51000, …;` / `RAISERROR (…);` + // batches — Codex P2): measured like the same tokens inside a + // parsed block. Leading position keeps column references named + // `raise` out. + "THROW" | "RAISERROR" | "SIGNAL" | "RAISE" if i == 0 => return true, + // A *leading* `GOTO` is a standalone jump batch — the cognitive + // model explicitly charges it (Codex P2). + "GOTO" if i == 0 => return true, + // A *leading* control-position `IF` is a decision the grammar + // lost whole (T-SQL `IF @a = 1 THROW …` at file level — + // Codex P2). Mid-run `IF`s stay unadmitted (ambiguous with DDL + // guards, `DROP TABLE IF EXISTS`), and so do condition-less + // fragments (`if; end;` — debris of a partially parsed + // `END IF`). The scalar `IF(…)` carve-out still applies. + "IF" if i == 0 && !matches!(word(1), ";" | "") && !scalar_if_call(tokens, 0) => { + return true; + } + "SP_EXECUTESQL" => return true, + "EXECUTE" if word(i + 1) == "IMMEDIATE" => return true, + // T-SQL `EXEC('…')` — an immediately executed dynamic string + // batch (Codex P1). Plain `EXEC procname` is deliberately not a + // marker: a static call proves nothing procedural by itself. + "EXEC" | "EXECUTE" if word(i + 1) == "(" => return true, + // T-SQL variable-form `EXEC @sql` executes the variable's + // contents (Codex P1). Return-value capture is only static when + // the right-hand side is a literal procedure name — + // `EXEC @status = @proc_var` still executes a variable + // (CodeRabbit). + "EXEC" | "EXECUTE" + if word(i + 1).starts_with('@') + && (word(i + 2) != "=" || word(i + 3).starts_with('@')) => + { + return true; + } + // MySQL `PREPARE stmt FROM @sql` — dynamic SQL at any level + // (Codex P1). + "PREPARE" if word(i + 2) == "FROM" => return true, + // An exception-handler section (PL/SQL, BigQuery scripting). + "EXCEPTION" if word(i + 1) == "WHEN" => return true, + "WHILE" => return true, + _ => {} + } + } + false +} + +// ── the state machine ────────────────────────────────────────────────── + +/// Scanner state a region hands to its continuation: the open context +/// stack and whether the body gate was open. +type CarriedState = (Vec, bool); + +/// Whether the `IF` at `i` is the scalar conditional *function* +/// `IF(expr, a, b)` rather than a control statement. Inside `Unparsable` +/// runs every token is a plain `Word` — no `FunctionNameIdentifier` shape — +/// so two signals decide (Codex P2 ×2): +/// - argument commas: the function form carries commas at paren depth 1; +/// a comma-free parenthesized condition (`IF (@x > 0) BEGIN …`, +/// `IF (ready) THEN`) is control flow. Commas inside nested calls +/// (`IF (f(a, b) > 0) THEN`) sit deeper. +/// - a depth-1 comma alone is not proof: MySQL row constructors put commas +/// in real conditions (`IF (a, b) = (1, 2) THEN`). The preceding token +/// settles clear expression positions (`SET x = IF(…)`, `WHEN IF(…)`); +/// otherwise the statement shape decides — a control IF finds its own +/// `THEN` at paren depth 0 before the statement ends, a scalar call never +/// does. +/// +/// Unclosed parens err toward keeping control flow visible. +fn scalar_if_call(tokens: &[PToken], i: usize) -> bool { + let word = |j: usize| tokens.get(j).map(|t| t.word.as_str()).unwrap_or(""); + if word(i + 1) != "(" { + return false; + } + // Phase 1: scan the IF's parens for a depth-1 comma. + let mut depth = 0u32; + let mut top_level_comma = false; + let mut after_close = usize::MAX; + for (j, t) in tokens.iter().enumerate().skip(i + 1) { + match t.word.as_str() { + "(" => depth += 1, + ")" => { + depth = depth.saturating_sub(1); + if depth == 0 { + after_close = j + 1; + break; + } + } + "," if depth == 1 => top_level_comma = true, + _ => {} + } + } + if after_close == usize::MAX || !top_level_comma { + // Unclosed, or a comma-free condition: control. + return false; + } + // A token that can only precede an *operand* proves expression + // position. `THEN`/`ELSE`/`;`/run-start stay ambiguous — a nested + // control IF starts there too. + let expression_position = matches!( + i.checked_sub(1) + .map(|j| tokens[j].word.as_str()) + .unwrap_or(""), + "=" | "," + | "(" + | "+" + | "-" + | "*" + | "/" + | "%" + | "||" + | ">" + | "<" + | ">=" + | "<=" + | "<>" + | "!=" + | "SELECT" + | "RETURN" + | "AND" + | "OR" + | "NOT" + | "WHEN" + | "IF" + | "ELSIF" + | "ELSEIF" + | "WHILE" + | "UNTIL" + ); + if expression_position { + return true; + } + // Phase 2: statement shape. A control IF's condition continues to a + // depth-0 `THEN` (`IF (a, b) = (1, 2) THEN`); a scalar call reaches a + // terminator, the end of the run, or the closer of an enclosing + // expression first. + let mut depth = 0i32; + for t in &tokens[after_close..] { + match t.word.as_str() { + "(" => depth += 1, + ")" => { + depth -= 1; + if depth < 0 { + return true; // operand of an enclosing expression + } + } + "THEN" if depth == 0 && t.keyword_like => return false, + ";" if depth == 0 => return true, + _ => {} + } + } + true +} + +/// Open construct contexts. Plain `Block` tracks `BEGIN … END` depth and +/// whether its `EXCEPTION` section has started; `Case` tracks pending `WHEN` +/// arms until `END CASE` (statement) or bare `END` (expression) resolves +/// whether they count. +#[derive(Debug)] +enum Ctx { + Block { + exception_section: bool, + /// `Some(gate)` = this block is a routine's *body* — opened for a + /// pending `FUNCTION`/`PROCEDURE` header — carrying the body-gate + /// state saved at that header. Cognitive nesting resets at the + /// topmost such block so a nested subprogram's decisions don't + /// inherit the outer routine's lexical depth (Codex P2), and the + /// block's END restores the saved gate so a nested routine in a + /// DECLARE section doesn't leak `in_body` into the declarations + /// after it (Codex P2). + routine_body: Option, + }, + Try, + Catch, + /// `bound` = a `BEGIN` block opened directly under this (T-SQL) `IF`, so + /// the `IF` closes when that block closes, not at the next terminator. + /// `else_taken` = an `ELSE` already bound to this IF: the next `; ELSE` + /// belongs to an outer IF, and this one completes at that terminator + /// (Codex P2). + If { + with_then: bool, + bound: bool, + else_taken: bool, + }, + /// Pushed at the loop *header* (`WHILE`/`FOR`) or at a bare `LOOP`, so + /// the body carries the loop's nesting regardless of body shape: + /// `block_bound` loops (T-SQL `WHILE … BEGIN … END`) pop with their + /// block; header loops whose body opener was `LOOP`/`DO` pop at + /// `END LOOP/WHILE/REPEAT/FOR`; a loop still pending a body opener at + /// `;` was single-statement (`WHILE @x > 0 SET …;`) and pops there. + Loop { + block_bound: bool, + }, + Case { + whens: Vec, + nesting: u32, + open_span: SourceSpan, + }, + /// One `WHEN … THEN` exception-handler body (PL/SQL); popped by the next + /// handler or the enclosing block's `END`. + Handler, +} + +/// Per-region scanner state. +struct Machine<'a> { + facts: &'a mut ProceduralFacts, + /// Byte-range buckets for per-unit attribution: `(start, end, index)`. + unit_ranges: &'a [(u32, u32, usize)], + /// Per-unit `(cyclomatic, cognitive)` tallies, parallel to + /// `SqlFileFacts::procedural_units`. + unit_tallies: &'a mut [(f64, f64)], + change_risk: &'a mut Vec, + /// Mirrors `AnalysisConfig::emit_contributions`: counts and tallies are + /// always exact; the evidence vectors are only populated when a consumer + /// will read them (same gating as the change-risk evidence in + /// `extract_objects`). + emit: bool, + /// Unit index increments fall back to when no unit range *contains* + /// their span: the routine whose body sqruff split into sibling + /// statements or top-level `Unparsable` spills (the continuation + /// regions). `None` for standalone regions (anonymous blocks, top-level + /// scripting) — their increments are file-level only (Codex P2). + fallback_unit: Option, + stack: Vec, + /// Set once the region enters a body (`BEGIN`/`IS`/`AS` seen). Gates the + /// text-only patterns (`RETURN`, raise family, booleans, dynamic SQL) so + /// routine headers (`RETURN number IS`, `CREATE OR REPLACE`) and + /// identifier-shaped words in non-body positions never count. + in_body: bool, + /// Loop headers (`WHILE`/`FOR`) whose body opener has not arrived yet — + /// a *count*, because single-statement T-SQL loops nest + /// (`WHILE @a > 0 WHILE @b > 0 SET …;` completes both at one + /// terminator, Codex P2). `LOOP`/`DO` consume one; a `BEGIN` block + /// binds all pending loops (they close with it); a terminator closes + /// every loop still pending. + pending_loop_headers: u32, + /// Inside `BETWEEN … AND …`: the next `AND` is not a boolean operator. + pending_between: bool, + /// A nested routine header (`function inner_f return number is`) is + /// being read: between the `FUNCTION`/`PROCEDURE` keyword and its + /// `IS`/`AS`/`BEGIN`, a `RETURN` is the signature's return *type*, not a + /// return statement — even though the enclosing routine's body gate is + /// already open (Codex P2). + pending_routine_header: bool, + /// A `CREATE [OR REPLACE] PACKAGE [BODY]` / `CREATE TYPE` header is + /// being read: its `IS`/`AS` introduces *declarations*, not an + /// executable body — the body gate stays closed until a real `BEGIN` + /// (a package initialization block or a member routine's body), so + /// package-level declaration initializers create no paths (Codex P2). + pending_spec_header: bool, + /// Effective dialect is T-SQL: its `AS` opens a routine body directly + /// (`CREATE PROCEDURE p AS SELECT 1` — no BEGIN required), while + /// Oracle's `IS`/`AS` introduce a declaration section that is not a + /// path (Codex P2). + tsql: bool, + /// Effective dialect is Oracle: a mid-body `DECLARE` opens a nested + /// block's declaration section (gate closes until its `BEGIN`), while + /// T-SQL/MySQL/BigQuery `DECLARE`s are ordinary body statements + /// (Codex P2). + oracle: bool, + /// Effective dialect is MySQL: the `)` closing a routine's parameter + /// list ends its signature — a single-statement body (`… RETURNS INT + /// RETURN x + 1;`) has no `AS`/`BEGIN` opener, so the transition must + /// happen at the header's own boundary (Codex P2). The pending body + /// *marker* stays armed for bodies that do open a `BEGIN`. + mysql: bool, + /// Open-paren depth inside a MySQL routine signature: a parameter + /// type's own parens (`DECIMAL(10,2)`) must not end the signature — + /// only the *outer* parameter list's close does (Codex P2). + signature_paren_depth: u32, + /// Byte offsets where recovered definitions start *and end* inside + /// this region: one run can hold several opener-less definitions, and + /// each header must scan with a closed body gate and a fresh signature + /// — which closes again past the definition's end (Codex P2 ×2). + /// Sorted and deduplicated; empty for ordinary regions. + reset_gate_at: Vec, + /// Cursor into `reset_gate_at`: boundaries at or before the current + /// token have been applied. + reset_cursor: usize, + /// Definition headers whose body block hasn't opened yet — the next + /// plain `BEGIN`s consume these and tag their blocks `routine_body` + /// (nesting baselines, Codex P2). A stack, not a flag: an Oracle + /// DECLARE section can stack several nested subprogram headers before + /// their bodies open. Each entry is the body-gate state at its header, + /// restored when the routine's body block closes (Codex P2). + pending_routine_bodies: Vec, + /// Previous code token was a boolean operator with this text (`AND`/ + /// `OR`); any other token breaks the run. Used for sequence-based + /// cognitive counting (+1 per run of like operators, +1 on alternation). + last_bool: Option<&'static str>, +} + +impl Machine<'_> { + fn nesting(&self) -> u32 { + // Nesting is routine-local: count above the topmost routine-body + // block, so decisions inside a nested subprogram don't inherit the + // outer routine's open contexts (Codex P2). + let base = self + .stack + .iter() + .rposition(|c| { + matches!( + c, + Ctx::Block { + routine_body: Some(_), + .. + } + ) + }) + .map_or(0, |i| i + 1); + self.stack[base..] + .iter() + .filter(|c| { + matches!( + c, + Ctx::If { .. } + | Ctx::Loop { .. } + | Ctx::Case { .. } + | Ctx::Catch + | Ctx::Handler + ) + }) + .count() as u32 + } + + fn block_depth(&self) -> u32 { + self.stack + .iter() + .filter(|c| matches!(c, Ctx::Block { .. } | Ctx::Try | Ctx::Catch)) + .count() as u32 + } + + fn add( + &mut self, + metric: ProceduralMetric, + span: SourceSpan, + amount: f64, + reason: &'static str, + ) { + match metric { + ProceduralMetric::Cyclomatic => self.facts.cyclomatic_complexity += amount, + ProceduralMetric::Cognitive => self.facts.cognitive_complexity += amount, + _ => {} + } + // Innermost containing unit: units are pre-order, so among containing + // ranges the *last* is the deepest. Increments in continuation + // regions (split bodies, unparsable spills) lie *outside* every unit + // range and attribute to the owning routine via the region's + // fallback (Codex P2). + let unit = self + .unit_ranges + .iter() + .rfind(|(s, e, _)| *s <= span.start_byte && span.end_byte <= *e) + .map(|&(_, _, idx)| idx) + .or(self.fallback_unit); + if let Some(idx) = unit { + match metric { + ProceduralMetric::Cyclomatic => self.unit_tallies[idx].0 += amount, + ProceduralMetric::Cognitive => self.unit_tallies[idx].1 += amount, + _ => {} + } + } + if self.emit { + self.facts.evidence.push(ProceduralEvidence { + span, + metric, + amount, + reason, + }); + } + } + + fn cyclo(&mut self, span: SourceSpan, reason: &'static str) { + self.add(ProceduralMetric::Cyclomatic, span, 1.0, reason); + } + + fn cognitive(&mut self, span: SourceSpan, amount: f64, reason: &'static str) { + self.add(ProceduralMetric::Cognitive, span, amount, reason); + } + + /// A body opener binds the pending T-SQL single-statement controls: + /// pending loop headers become block-bound, otherwise a THEN-less IF on + /// top of the stack binds to the opener — the control closes when the + /// construct does, not at the first terminator inside it (Codex P2 ×2: + /// plain `BEGIN` blocks and `BEGIN TRY … END CATCH` constructs alike). + fn bind_pending_owner(&mut self) { + if self.pending_loop_headers > 0 { + let mut pending = self.pending_loop_headers as usize; + self.pending_loop_headers = 0; + for ctx in self.stack.iter_mut().rev() { + if pending == 0 { + break; + } + if let Ctx::Loop { block_bound: false } = ctx { + *ctx = Ctx::Loop { block_bound: true }; + pending -= 1; + } + } + } else if let Some(Ctx::If { + with_then: false, + bound, + .. + }) = self.stack.last_mut() + { + *bound = true; + } + } + + /// A completed construct (bare `END` of a block, `END CATCH` of a + /// paired TRY/CATCH) also completes the T-SQL single-statement contexts + /// it was the body of: loops bound to it, and THEN-less IFs — both + /// those bound directly and those whose single statement was a + /// just-popped loop or IF. The pops interleave because the contexts + /// nest in any order. An ELSE stops the IF pops: the decision continues + /// through the else branch, keeping its nesting for the else body + /// (Codex P2) — loops still pop, their bodies are done either way. + fn close_completed_owners(&mut self, next_word: &str) { + loop { + match self.stack.last() { + Some(Ctx::Loop { block_bound: true }) => { + self.stack.pop(); + } + Some(Ctx::If { + with_then: false, .. + }) if next_word != "ELSE" => { + self.stack.pop(); + } + _ => break, + } + } + } + + /// Track the deepest block nesting and remember its opener: the span + /// of `max_block_depth`'s single evidence entry (Codex P1). + fn note_block_depth(&mut self, span: SourceSpan) { + let depth = self.block_depth(); + if depth > self.facts.max_block_depth { + self.facts.max_block_depth = depth; + self.facts.max_block_depth_span = Some(span); + } + } + + /// Increment a raw `sql.procedural.*_count` and emit its evidence: raw + /// counts are evidence-backed under their own keys, so `metric == Σ + /// contributions` holds for every published count (Codex P1). Raw + /// increments are file-level only — per-unit tallies exist for the + /// composites alone. + fn raw_count(&mut self, metric: ProceduralMetric, span: SourceSpan, reason: &'static str) { + match metric { + ProceduralMetric::BlockCount => self.facts.block_count += 1, + ProceduralMetric::LoopCount => self.facts.loop_count += 1, + ProceduralMetric::IfCount => self.facts.if_count += 1, + ProceduralMetric::CaseStatementCount => self.facts.case_statement_count += 1, + ProceduralMetric::ExceptionHandlerCount => self.facts.exception_handler_count += 1, + ProceduralMetric::ReturnCount => self.facts.return_count += 1, + ProceduralMetric::RaiseThrowCount => self.facts.raise_throw_count += 1, + ProceduralMetric::DynamicSqlCount => self.facts.dynamic_sql_count += 1, + // Composites route through `add`; routine_count is emitted by + // `extract` per unit. + ProceduralMetric::Cyclomatic + | ProceduralMetric::Cognitive + | ProceduralMetric::EmbeddedQueryMax + | ProceduralMetric::RoutineCount + | ProceduralMetric::MaxBlockDepth => { + debug_assert!(false, "not a machine-raised raw count: {metric:?}"); + } + } + if self.emit { + self.facts.evidence.push(ProceduralEvidence { + span, + metric, + amount: 1.0, + reason, + }); + } + } + + /// Pop the topmost context matching `pred` and everything above it + /// (forgiving on malformed/unparsable input: contexts left open by a + /// parse gap are abandoned rather than corrupting deeper state). + fn pop_matching(&mut self, pred: impl Fn(&Ctx) -> bool) -> Option { + let idx = self.stack.iter().rposition(pred)?; + let ctx = self.stack.swap_remove(idx); + self.stack.truncate(idx); + Some(ctx) + } + + /// A statement terminator closes any T-SQL-style `IF` (no `THEN`, no + /// bound block) sitting on top of the stack. + fn close_unbound_ifs(&mut self) { + while matches!( + self.stack.last(), + Some(Ctx::If { + with_then: false, + bound: false, + .. + }) + ) { + self.stack.pop(); + } + } + + fn break_bool_run(&mut self) { + self.last_bool = None; + } + + fn scan(&mut self, tokens: &[PToken]) { + let word = |i: usize| tokens.get(i).map(|t| t.word.as_str()).unwrap_or(""); + + let mut i = 0usize; + while i < tokens.len() { + let t = &tokens[i]; + // A recovered definition boundary (header start or unit end): + // close the body gate and reset the signature so neither the + // next header's tokens (`CREATE OR REPLACE …`) nor statements + // between definitions count as body content (Codex P2 ×2). + let mut crossed_boundary = false; + while self + .reset_gate_at + .get(self.reset_cursor) + .is_some_and(|&b| b <= t.span.start_byte) + { + self.reset_cursor += 1; + crossed_boundary = true; + } + if crossed_boundary { + self.in_body = false; + self.pending_routine_header = false; + self.signature_paren_depth = 0; + } + let kw = t.keyword_like; + match t.word.as_str() { + "(" if self.mysql && self.pending_routine_header => { + self.signature_paren_depth += 1; + } + ")" if self.mysql && self.pending_routine_header => { + // MySQL signatures end at the *outer* parameter list's + // close — a parameter type's own parens + // (`DECIMAL(10,2)`) sit deeper (Codex P2). What follows + // (`RETURNS `, characteristics, then the body — + // possibly a bare single statement with no `BEGIN` + // opener) is past the signature, so the body gate opens + // here (Codex P2 ×2). + self.signature_paren_depth = self.signature_paren_depth.saturating_sub(1); + if self.signature_paren_depth == 0 { + self.pending_routine_header = false; + self.in_body = true; + } + } + "ROW" + if self.mysql + && self.pending_routine_header + && i.checked_sub(1) + .map(|j| tokens[j].word.as_str()) + .unwrap_or("") + == "EACH" => + { + // MySQL triggers have no parameter list: `FOR EACH ROW` + // ends the header and the (possibly single-statement) + // body follows (Codex P2). + self.pending_routine_header = false; + self.in_body = true; + } + ";" => { + // A routine header ending at the terminator without + // IS/AS/BEGIN is a *forward declaration* / prototype + // (`PROCEDURE helper;` in a spec or DECLARE section): + // no body block will open, so its pending body marker + // retires here — otherwise a later ordinary `BEGIN` + // would be mislabeled a routine body and reset cognitive + // nesting (Codex P2). + if self.pending_routine_header { + self.pending_routine_header = false; + self.pending_routine_bodies.pop(); + } + // Loop headers still pending a body opener at the + // terminator were single-statement loops (`WHILE @a > 0 + // WHILE @b > 0 SET …;`) — every one of them completes + // here, *before* the IF pass so an `IF … WHILE …;` chain + // exposes its outer IF for closing too (Codex P2). + while self.pending_loop_headers > 0 { + self.pending_loop_headers -= 1; + self.pop_matching(|c| matches!(c, Ctx::Loop { block_bound: false })); + } + // A single-statement then-body followed by ELSE keeps its + // IF open for the else branch (`IF @a > 0 SELECT 1; ELSE + // IF @b > 0 …`), mirroring the `END ELSE` block shape + // (Codex P2). But the upcoming ELSE binds the innermost + // IF still *without* an else branch: deeper IFs whose + // else just completed close here (`IF @a IF @b …; ELSE + // …; ELSE …` — the second ELSE is @a's, Codex P2). + if word(i + 1) != "ELSE" { + self.close_unbound_ifs(); + } else { + while matches!( + self.stack.last(), + Some(Ctx::If { + with_then: false, + bound: false, + else_taken: true + }) + ) { + self.stack.pop(); + } + } + self.pending_between = false; + self.break_bool_run(); + } + "IS" | "AS" if kw => { + // A package/type header's IS/AS introduces declarations + // — not a body (Codex P2). A routine header's IS/AS + // likewise introduces its *declaration section* (cursor + // queries, initializers — not paths): the body opens at + // the routine's BEGIN (Codex P2). T-SQL is the + // exception — its AS opens the body directly, no BEGIN + // required. Any other IS/AS (`cursor c IS select …`, a + // column alias) is no body opener at all. + if self.pending_spec_header { + self.pending_spec_header = false; + } else if self.pending_routine_header && self.tsql && t.word == "AS" { + self.in_body = true; + // AS *is* the T-SQL body opener: retire the pending + // marker so a later BEGIN inside the body is not + // mistagged as the routine-body nesting baseline + // (Codex P2). + self.pending_routine_bodies.pop(); + } + self.pending_routine_header = false; + // A call-spec body (`AS LANGUAGE JAVA …`, T-SQL CLR + // `AS EXTERNAL NAME …`) never opens a `BEGIN` block: + // retire the header's pending body marker (Codex P2). + if matches!(word(i + 1), "LANGUAGE" | "EXTERNAL") { + self.pending_routine_bodies.pop(); + } + self.break_bool_run(); + } + "FUNCTION" | "PROCEDURE" | "PROC" | "TRIGGER" if kw => { + // A definition header arms a routine-body marker for the + // block that will open it — reference positions (`DROP + // PROCEDURE`, `GRANT … ON PROCEDURE`, `END FUNCTION`, + // `COMMENT ON …`) name a routine without defining one + // (Codex P2). `ALTER FUNCTION|TRIGGER` is a + // *redefinition* under T-SQL semantics and arms too + // (Codex P1) — an Oracle `ALTER FUNCTION f COMPILE;` + // retires harmlessly at its terminator. + let prev = i + .checked_sub(1) + .map(|j| tokens[j].word.as_str()) + .unwrap_or(""); + if !matches!(prev, "DROP" | "ON" | "END" | "EXISTS") { + self.pending_routine_bodies.push(self.in_body); + } + // The signature (nested subprogram in a DECLARE section, + // or the outer definition itself) runs until + // IS/AS/BEGIN. + self.pending_routine_header = true; + self.break_bool_run(); + } + "PACKAGE" | "TYPE" + if kw + && matches!( + i.checked_sub(1) + .map(|j| tokens[j].word.as_str()) + .unwrap_or(""), + "CREATE" | "REPLACE" | "EDITIONABLE" | "NONEDITIONABLE" + ) => + { + // `CREATE [OR REPLACE] [NON]EDITIONABLE PACKAGE [BODY]` + // / `CREATE TYPE`: the upcoming IS/AS introduces + // declarations (Codex P2 ×2). The prev-token guard + // keeps `%TYPE` attributes and `DROP PACKAGE` out. + self.pending_spec_header = true; + self.break_bool_run(); + } + "DECLARE" if kw && self.oracle => { + // An Oracle `DECLARE` opens a nested block's declaration + // section: not a path until its `BEGIN` reopens the gate + // (`BEGIN DECLARE flag := TRUE AND FALSE; BEGIN … END; + // END;` — Codex P2). Region-leading `DECLARE` heads are + // handled at region init; this closes the gate for + // mid-body ones too, idempotently. Other dialects' + // `DECLARE` statements are ordinary body statements. + self.in_body = false; + self.break_bool_run(); + } + "BEGIN" if kw => { + self.break_bool_run(); + // A body opener also ends any routine signature being + // read (MySQL headers have no IS/AS before BEGIN) and + // any spec header still pending. + self.pending_routine_header = false; + self.pending_spec_header = false; + match word(i + 1) { + // Transaction control / Service Broker statements, + // not a block (Codex P2). + "TRANSACTION" | "TRAN" | "WORK" | "DIALOG" | "DISTRIBUTED" + | "CONVERSATION" | ";" => {} + "TRY" => { + self.in_body = true; + self.raw_count(ProceduralMetric::BlockCount, t.span, reason::BLOCK); + // The construct is the pending control's body: + // `IF @a > 0 BEGIN TRY …` closes the IF at + // `END CATCH`, not at the first `;` inside the + // try body (Codex P2). + self.bind_pending_owner(); + self.stack.push(Ctx::Try); + self.note_block_depth(t.span); + i += 1; // consume TRY + } + "CATCH" => { + self.in_body = true; + self.raw_count(ProceduralMetric::BlockCount, t.span, reason::BLOCK); + self.raw_count( + ProceduralMetric::ExceptionHandlerCount, + t.span, + reason::EXCEPTION_HANDLER, + ); + let nesting = self.nesting(); + self.cyclo(t.span, reason::EXCEPTION_HANDLER); + self.cognitive(t.span, 1.0 + nesting as f64, reason::EXCEPTION_HANDLER); + self.stack.push(Ctx::Catch); + self.note_block_depth(t.span); + i += 1; // consume CATCH + } + _ => { + self.in_body = true; + self.raw_count(ProceduralMetric::BlockCount, t.span, reason::BLOCK); + // The block is a loop body when loop headers + // are pending (T-SQL `WHILE … BEGIN`), otherwise + // it binds a fresh T-SQL IF on top: the control + // closes with the block (Codex P2). + self.bind_pending_owner(); + self.stack.push(Ctx::Block { + exception_section: false, + // Consume one armed header: this block is + // that routine's body, carrying the gate to + // restore at its END (Codex P2). + routine_body: self.pending_routine_bodies.pop(), + }); + self.note_block_depth(t.span); + } + } + } + "END" if kw => { + self.break_bool_run(); + // Compound closers consume their keyword only when a + // matching context actually pops — T-SQL puts a sibling + // statement right after a bare `END`, so the adjacent + // tokens of `… END IF @b > 0 …` or `… END WHILE @b … ` + // are *not* the PL/SQL closers: the block closes bare and + // the sibling keyword processes as its own statement + // (Codex P2). + match word(i + 1) { + "IF" if self + .pop_matching(|c| { + matches!( + c, + Ctx::If { + with_then: true, + .. + } + ) + }) + .is_some() => + { + i += 1; + } + "LOOP" | "WHILE" | "REPEAT" | "FOR" + if matches!( + self.stack.last(), + Some(Ctx::Loop { block_bound: false }) + ) => + { + // A dialect compound closer (`END LOOP`, + // `END WHILE`, …) only when the innermost open + // construct is a header-opened loop. A + // block-bound loop (T-SQL `WHILE … BEGIN … END`) + // closes through the bare-END path instead, so + // `… END WHILE @b > 0 …` keeps the sibling WHILE + // as its own statement (Codex P2). + self.stack.pop(); + i += 1; + } + "CASE" => { + // `END CASE` proves the CASE was a *statement*: + // count it and its WHEN arms. (T-SQL has no CASE + // statement, so no sibling-adjacency variant + // exists: a CASE context is always open here or + // the tokens are malformed.) + if let Some(Ctx::Case { + whens, + nesting, + open_span, + }) = self.pop_matching(|c| matches!(c, Ctx::Case { .. })) + { + self.raw_count( + ProceduralMetric::CaseStatementCount, + open_span, + reason::CASE_STATEMENT, + ); + self.cognitive( + open_span, + 1.0 + nesting as f64, + reason::CASE_STATEMENT, + ); + for when_span in whens { + self.cyclo(when_span, reason::CASE_WHEN); + } + } + i += 1; + } + "TRY" if self.pop_matching(|c| matches!(c, Ctx::Try)).is_some() => { + i += 1; + } + "CATCH" if self.pop_matching(|c| matches!(c, Ctx::Catch)).is_some() => { + // `END CATCH` completes the whole paired + // TRY/CATCH construct: controls bound at + // `BEGIN TRY` close now (Codex P2). The ELSE + // lookahead is past the consumed CATCH. + self.close_completed_owners(word(i + 2)); + i += 1; + } + _ => { + // Bare END closes the nearest block or CASE + // *expression* (whose WHEN arms stay + // declarative). + if let Some(Ctx::Block { routine_body, .. }) = self.pop_matching(|c| { + matches!( + c, + Ctx::Block { .. } | Ctx::Case { .. } | Ctx::Try | Ctx::Catch + ) + }) { + // A routine body's END restores the gate + // saved at its header: declarations after a + // nested routine stay declarations + // (Codex P2). + if let Some(saved_gate) = routine_body { + self.in_body = saved_gate; + } + // (`IF @a > 0 WHILE @b > 0 BEGIN … END` + // leaves the IF unbound because the BEGIN + // bound the pending loop — Codex P2.) + self.close_completed_owners(word(i + 1)); + } + } + } + } + "IF" if kw => { + self.break_bool_run(); + let prev = i + .checked_sub(1) + .map(|j| tokens[j].word.as_str()) + .unwrap_or(""); + // `DROP TABLE IF EXISTS` / `CREATE TABLE IF NOT EXISTS` + // guards and the `IF(…)` conditional *function* are not + // control flow. In parsed contexts the function is + // recognized by its parse shape + // (`FunctionNameIdentifier`), not by a following `(` — + // `IF (@count > 0) BEGIN … END` / `IF (ready) THEN` are + // ordinary statements with parenthesized conditions and + // must count (Codex P2). In unparsable runs everything + // is a `Word`, so the scalar form is recognized by its + // argument commas instead (`SET x = IF(flag, 1, 0)` — + // Codex P2). + let ddl_guard = matches!( + prev, + "TABLE" + | "VIEW" + | "INDEX" + | "SCHEMA" + | "DATABASE" + | "FUNCTION" + | "PROCEDURE" + | "TRIGGER" + | "SEQUENCE" + | "COLUMN" + | "CONSTRAINT" + | "EXTENSION" + | "TYPE" + | "ROLE" + | "USER" + | "EXISTS" + ); + if !ddl_guard && !t.is_function_name && !scalar_if_call(tokens, i) { + self.raw_count(ProceduralMetric::IfCount, t.span, reason::IF); + let nesting = self.nesting(); + self.cyclo(t.span, reason::IF); + self.cognitive(t.span, 1.0 + nesting as f64, reason::IF); + self.stack.push(Ctx::If { + with_then: false, + bound: false, + else_taken: false, + }); + } + } + "THEN" if kw => { + self.break_bool_run(); + if let Some(Ctx::If { with_then, .. }) = self.stack.last_mut() { + *with_then = true; + } + } + "ELSIF" | "ELSEIF" if kw => { + self.break_bool_run(); + self.raw_count(ProceduralMetric::IfCount, t.span, reason::ELSIF); + self.cyclo(t.span, reason::ELSIF); + self.cognitive(t.span, 1.0, reason::ELSIF); + } + "ELSE" if kw => { + self.break_bool_run(); + // ELSE of a CASE (expression or statement) is not a + // control-flow branch here. Any other ELSE in a + // procedural region is an IF-else — including the T-SQL + // `IF … BEGIN … END ELSE …` shape, where the block kept + // its IF open for this branch — and costs 1 cognitive + // (flat, per the cognitive model). + if !matches!(self.stack.last(), Some(Ctx::Case { .. })) { + self.cognitive(t.span, 1.0, reason::ELSE); + } + // A block-bound IF entering its ELSE branch becomes + // terminator-bound again: a single-statement else-body + // (`… END ELSE SELECT 2;`) closes it at the `;`, while a + // block else-body re-binds it at its `BEGIN` (Codex P2). + // Either way the IF has its else now — the next `; ELSE` + // belongs to an outer IF (Codex P2). + if let Some(Ctx::If { + with_then: false, + bound, + else_taken, + }) = self.stack.last_mut() + { + *bound = false; + *else_taken = true; + } + } + "CASE" if kw => { + self.break_bool_run(); + let nesting = self.nesting(); + self.stack.push(Ctx::Case { + whens: Vec::new(), + nesting, + open_span: t.span, + }); + } + "WHEN" if kw => { + self.break_bool_run(); + // MERGE clauses (`WHEN MATCHED` / `WHEN NOT MATCHED`) are + // declarative. Only that exact token shape is excluded — + // a searched CASE arm like `WHEN NOT done THEN …` is a + // real branch (Codex P2). + if word(i + 1) == "MATCHED" + || (word(i + 1) == "NOT" && word(i + 2) == "MATCHED") + { + i += 1; + continue; + } + match self.stack.last_mut() { + Some(Ctx::Case { whens, .. }) => whens.push(t.span), + Some(Ctx::Block { + exception_section: true, + .. + }) => { + self.raw_count( + ProceduralMetric::ExceptionHandlerCount, + t.span, + reason::EXCEPTION_HANDLER, + ); + let nesting = self.nesting(); + self.cyclo(t.span, reason::EXCEPTION_HANDLER); + self.cognitive(t.span, 1.0 + nesting as f64, reason::EXCEPTION_HANDLER); + self.stack.push(Ctx::Handler); + } + Some(Ctx::Handler) => { + // Next handler of the same exception section. + self.stack.pop(); + self.raw_count( + ProceduralMetric::ExceptionHandlerCount, + t.span, + reason::EXCEPTION_HANDLER, + ); + let nesting = self.nesting(); + self.cyclo(t.span, reason::EXCEPTION_HANDLER); + self.cognitive(t.span, 1.0 + nesting as f64, reason::EXCEPTION_HANDLER); + self.stack.push(Ctx::Handler); + } + _ => {} + } + } + "EXCEPTION" if kw && word(i + 1) == "WHEN" => { + self.break_bool_run(); + // PL/SQL & BigQuery: the block's handler section starts. + // Section position is proven by the following `WHEN` — + // a named exception *declaration* (`some_error + // EXCEPTION;`) is not a section and must not mark or + // seed any block (Codex P2). A Handler context from a + // previous section cannot be on top here, so marking + // the nearest block suffices. In an unparsable + // *fragment* the opening BEGIN may be lost — seed a + // block so the section's handlers still count. + match self + .stack + .iter_mut() + .rev() + .find(|c| matches!(c, Ctx::Block { .. })) + { + Some(Ctx::Block { + exception_section, .. + }) => *exception_section = true, + _ => self.stack.push(Ctx::Block { + exception_section: true, + routine_body: None, + }), + } + } + "EXIT" | "CONTINUE" if kw => { + self.break_bool_run(); + // `EXIT WHEN ` / `CONTINUE WHEN ` embed a + // condition: one extra path each. The WHEN is consumed so + // the handler-WHEN logic never sees it. Bare + // EXIT/CONTINUE/BREAK add no path. + if word(i + 1) == "WHEN" { + self.cyclo(t.span, reason::CONDITIONAL_EXIT); + self.cognitive(t.span, 1.0, reason::CONDITIONAL_EXIT); + i += 1; + } + } + "LOOP" if kw && !t.is_function_name => { + // A call-shaped `loop(…)` (`SELECT dbo.loop(1)`) is a + // UDF, not a loop keyword (Codex P2). + self.break_bool_run(); + if self.pending_loop_headers > 0 { + // Body opener of a WHILE/FOR header — its Loop + // context is already on the stack. + self.pending_loop_headers -= 1; + } else { + self.count_loop(t.span); + self.stack.push(Ctx::Loop { block_bound: false }); + } + } + "DO" if kw => { + self.break_bool_run(); + // MySQL/BigQuery `WHILE … DO` / `FOR … DO` body opener — + // the header's Loop context is already on the stack. + self.pending_loop_headers = self.pending_loop_headers.saturating_sub(1); + } + "WHILE" if kw => { + self.break_bool_run(); + self.count_loop(t.span); + // The Loop context opens at the *header*, so the body + // carries the loop's nesting in every shape: PL/SQL + // `WHILE … LOOP … END LOOP`, MySQL `WHILE … DO … END + // WHILE`, T-SQL `WHILE … BEGIN … END` (the block binds + // to it), and T-SQL single-statement `WHILE … IF …;` + // (closed by the terminator) (Codex P2). + self.stack.push(Ctx::Loop { block_bound: false }); + self.pending_loop_headers += 1; + } + "REPEAT" if kw => { + self.break_bool_run(); + // MySQL `REPEAT … UNTIL … END REPEAT`; `REPEAT(…)` is the + // string function. + if word(i + 1) != "(" { + self.count_loop(t.span); + self.stack.push(Ctx::Loop { block_bound: false }); + } + } + "FOR" if kw => { + self.break_bool_run(); + // A for-loop header (`FOR i IN 1..10 LOOP`, `FOR rec IN + // (…) DO`) is a loop variable followed by IN. The + // variable lexes as `NakedIdentifier` in parsed regions + // and as `Word` in unparsable runs, so the discriminator + // is the word shape, not the token kind. Everything else + // (`FOR EACH ROW`, `FOR UPDATE`, `CURSOR … FOR SELECT`, + // `FOR XML`) is not a loop. + if !matches!( + word(i + 1), + "EACH" + | "UPDATE" + | "SELECT" + | "XML" + | "JSON" + | "BROWSE" + | "SHARE" + | "KEY" + | "NO" + | "DELETE" + | "INSERT" + | "(" + ) && word(i + 2) == "IN" + { + self.count_loop(t.span); + self.stack.push(Ctx::Loop { block_bound: false }); + self.pending_loop_headers += 1; + } + } + "GOTO" if kw => { + self.break_bool_run(); + if self.in_body { + self.cognitive(t.span, 1.0, reason::GOTO); + } + } + "RETURN" if kw => { + self.break_bool_run(); + // Only inside a body, and never in a routine signature: + // `CREATE FUNCTION … RETURN number IS` (outer, before the + // body gate opens) and `function inner_f return number + // is` (nested, while the enclosing body gate is already + // open) both declare the return *type*. + if self.in_body && !self.pending_routine_header { + self.raw_count(ProceduralMetric::ReturnCount, t.span, reason::RETURN); + } + } + "RAISE" | "THROW" | "RAISERROR" | "SIGNAL" | "RESIGNAL" + if kw && !t.is_function_name => + { + // Real keyword shape required: a user function named + // `signal(…)` parses as a `FunctionNameIdentifier` and + // is a call, not a raise statement (Codex P2). Oracle's + // `RAISE_APPLICATION_ERROR` below is the deliberate + // exception — it always parses as a call. + self.break_bool_run(); + if self.in_body { + self.raw_count( + ProceduralMetric::RaiseThrowCount, + t.span, + reason::RAISE_THROW, + ); + self.cyclo(t.span, reason::RAISE_THROW); + } + } + "RAISE_APPLICATION_ERROR" if kw && self.oracle => { + // Oracle only: elsewhere a call-shaped + // `raise_application_error(…)` is an ordinary UDF + // (Codex P2). + self.break_bool_run(); + if self.in_body { + self.raw_count( + ProceduralMetric::RaiseThrowCount, + t.span, + reason::RAISE_THROW, + ); + self.cyclo(t.span, reason::RAISE_THROW); + } + } + "EXECUTE" | "EXEC" if kw => { + self.break_bool_run(); + // A qualified method call (`DBMS_SQL.EXECUTE(c)`) is not + // the T-SQL `EXEC(…)` string form — the package + // qualifier already counted it (Codex P2). + let qualified = i + .checked_sub(1) + .map(|j| tokens[j].word.as_str() == ".") + .unwrap_or(false); + if self.in_body && !qualified { + if word(i + 1) == "IMMEDIATE" { + self.count_dynamic_sql(t.span); + i += 1; + } else if word(i + 1) == "(" { + // T-SQL `EXEC('…')` executes a string. + self.count_dynamic_sql(t.span); + } else if word(i + 1) == "SP_EXECUTESQL" { + self.count_dynamic_sql(t.span); + i += 1; + } else if word(i + 1).starts_with('@') + && (word(i + 2) != "=" || word(i + 3).starts_with('@')) + { + // T-SQL `EXEC @sql` executes the *contents* of a + // variable — dynamic SQL (Codex P1). Return-value + // capture stays static only when the right-hand + // side is a literal procedure name: + // `EXEC @ret = dbo.proc` is a static call, but + // `EXEC @status = @proc_var` executes a variable + // (CodeRabbit). + self.count_dynamic_sql(t.span); + } + // Plain `EXEC procname` is a static call — no count. + } + } + "SP_EXECUTESQL" if kw && self.tsql => { + // Reached only without a preceding EXEC/EXECUTE (which + // consumes it above). T-SQL only: elsewhere an + // `sp_executesql(…)` is an ordinary user function call + // (Codex P2). + self.break_bool_run(); + if self.in_body { + self.count_dynamic_sql(t.span); + } + } + "PREPARE" if kw => { + // MySQL dynamic SQL: `PREPARE stmt FROM @sql`. The + // matching `EXECUTE stmt` deliberately does not count — + // the dynamic statement is counted once, at its + // definition site. + self.break_bool_run(); + if self.in_body && word(i + 2) == "FROM" { + self.count_dynamic_sql(t.span); + } + } + "DBMS_SQL" if self.oracle && word(i + 1) == "." && word(i + 3) == "(" => { + // The Oracle dynamic-SQL package, recognized only in the + // qualified *call* shape (`DBMS_SQL.PARSE(…)`): the + // parsed package qualifier lexes as a `NakedIdentifier` + // — not keyword-like — so there is no `kw` guard, while + // the `.method(` requirement keeps a column or relation + // that happens to be named `dbms_sql` from counting + // (`SELECT dbms_sql.foo INTO v FROM dbms_sql`, Codex P2). + self.break_bool_run(); + if self.in_body { + self.count_dynamic_sql(t.span); + } + } + "BETWEEN" if kw => { + self.break_bool_run(); + self.pending_between = true; + } + "AND" | "OR" => { + // Booleans count in bodies only (kw check is implicit: + // AND/OR lex as Keyword/BinaryOperator/Word — never as + // identifiers). `BETWEEN x AND y` is range syntax, and a + // routine *signature* being read is declaration, not a + // path: `PROCEDURE p(flag BOOLEAN := TRUE AND FALSE);` + // in a package spec creates no branch (Codex P2). + if t.word == "AND" && self.pending_between { + self.pending_between = false; + } else if self.in_body && !self.pending_routine_header { + let op: &'static str = if t.word == "AND" { "AND" } else { "OR" }; + self.cyclo(t.span, reason::BOOLEAN_OPERATOR); + // Cognitive: +1 per *sequence* of same-operator runs. + if self.last_bool != Some(op) { + self.cognitive(t.span, 1.0, reason::BOOLEAN_SEQUENCE); + } + self.last_bool = Some(op); + } + } + _ => { + // Ordinary operands (`a AND b AND c`) keep the boolean + // run alive — only expression boundaries end it, so a + // homogeneous chain costs one cognitive sequence, not + // one per operator (Codex P2). Every control keyword has + // an explicit arm above that breaks the run; here only + // clause starters and argument separators do. + if matches!( + t.word.as_str(), + "," | "WHERE" + | "HAVING" + | "ON" + | "SET" + | "SELECT" + | "FROM" + | "GROUP" + | "ORDER" + | "VALUES" + | "INTO" + | "UNION" + | "JOIN" + | "QUALIFY" + ) { + self.break_bool_run(); + } + } + } + i += 1; + } + // Region end: transient token-adjacent state never crosses regions. + // A loop header still pending a body opener closes like at a + // terminator. The context *stack* and body gate deliberately stay — + // the caller carries them into the routine's next continuation + // region (split bodies keep their open blocks/decisions, Codex P2) + // or drops them for standalone regions. + while self.pending_loop_headers > 0 { + self.pending_loop_headers -= 1; + self.pop_matching(|c| matches!(c, Ctx::Loop { block_bound: false })); + } + self.pending_between = false; + self.pending_routine_header = false; + self.break_bool_run(); + } + + fn count_loop(&mut self, span: SourceSpan) { + self.raw_count(ProceduralMetric::LoopCount, span, reason::LOOP); + let nesting = self.nesting(); + self.cyclo(span, reason::LOOP); + self.cognitive(span, 1.0 + nesting as f64, reason::LOOP); + } + + fn count_dynamic_sql(&mut self, span: SourceSpan) { + self.raw_count(ProceduralMetric::DynamicSqlCount, span, reason::DYNAMIC_SQL); + if self.emit { + self.change_risk.push(ChangeRiskEvidence { + span, + factor: ChangeRiskFactor::DynamicSql, + }); + } + } +} + +// ── entry point ──────────────────────────────────────────────────────── + +/// Extract procedural facts for the whole file. Requires +/// `facts.statements` (classification) and `facts.procedural_units` to be +/// populated. Fills `facts.procedural`, per-unit tallies on +/// `facts.procedural_units`, and appends dynamic-SQL change-risk evidence. +pub(crate) fn extract( + root: &ErasedSegment, + line_at: &impl Fn(u32) -> u32, + emit_contributions: bool, + dialect: DialectKind, + facts: &mut SqlFileFacts, +) { + let bigquery = dialect == DialectKind::Bigquery; + let tsql = dialect == DialectKind::Tsql; + let oracle = dialect == DialectKind::Oracle; + let mysql = dialect == DialectKind::Mysql; + // The dialects whose grammars split routine bodies into *root* + // unparsable spills — the only ones where a top-level run can be a + // routine continuation (T-SQL batch bodies, MySQL delimiter bodies). + let spills_routine_bodies = matches!(dialect, DialectKind::Tsql | DialectKind::Mysql); + let mut procedural = ProceduralFacts { + routine_count: facts.procedural_units.len() as u32, + ..ProceduralFacts::default() + }; + let unit_ranges: Vec<(u32, u32, usize)> = facts + .procedural_units + .iter() + .enumerate() + .map(|(idx, u)| (u.start_byte, u.end_byte, idx)) + .collect(); + let mut unit_tallies = vec![(0.0f64, 0.0f64); facts.procedural_units.len()]; + // Per-query facts of body continuations, accumulated per owning + // routine: when sqruff splits a routine body into sibling statements, + // the queries in those fragments belong to the routine's embedded + // score (Codex P2). Each maximal query root keeps its own facts — the + // routine's score is the *maximum* over its individual queries, so + // neither parser fragmentation nor many trivial statements can inflate + // the worst-query metric (Codex P2 ×2). Unparsable spills contribute + // nothing here — they contain no typed query nodes to extract. + let mut continuation_facts: Vec> = + facts.procedural_units.iter().map(|_| Vec::new()).collect(); + let mut change_risk: Vec = Vec::new(); + + let push_entry = |procedural: &mut ProceduralFacts, span: SourceSpan| { + procedural.cyclomatic_complexity += 1.0; + if emit_contributions { + procedural.evidence.push(ProceduralEvidence { + span, + metric: ProceduralMetric::Cyclomatic, + amount: 1.0, + reason: reason::ENTRY, + }); + } + }; + + // Statement regions, zipped with their classification (the same + // `top_level_statements` crawl `classify_statements` consumed, so the + // zip is aligned by construction). + // + // `carried` holds scanner state (open context stack + body gate) per + // routine whose body sqruff split across regions: the routine's next + // continuation resumes where the previous region stopped, so open + // blocks/decisions keep their depth and nesting across the split + // (Codex P2). Standalone regions never save state. + let statements = crate::facts::top_level_statements(root, bigquery); + // T-SQL `GO` batch separators are hard attribution boundaries: a region + // after a `GO` starts a new, independent batch and can never be the + // body of a routine defined before the separator (Codex P2). + let go_boundaries: Vec = statements + .iter() + .zip(facts.statements.iter()) + .filter(|(node, sf)| { + sf.kind == StatementKind::Unknown && crate::facts::is_go_separator(node) + }) + .map(|(_, sf)| sf.start_byte) + .collect(); + // BigQuery bare scripting brackets are *sibling* statements (`BEGIN + // BEGIN SELECT 1; END; END;`), so no single region scan sees their + // nesting — a source-ordered depth walk over the bracket statements + // computes the true block depth (Codex P2). Counts stay with the + // region scans (one `block_count` per opener); only the high-water + // mark and its evidence span come from here. Openers at depth ≥ 2 are + // *nested* lexical blocks: one connected scripting region has one + // control-flow entry, so their statements skip the entry below + // (Codex P2). + let mut nested_bracket_begins: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + if bigquery { + let mut depth = 0u32; + for stmt in root.recursive_crawl( + &SyntaxSet::single(SyntaxKind::Statement), + false, + &SyntaxSet::EMPTY, + true, + ) { + match crate::facts::bare_scripting_bracket(&stmt) { + Some(crate::facts::ScriptingBracket::Begin) => { + depth += 1; + let Some(pm) = stmt.get_position_marker() else { + continue; + }; + let (s, e) = (pm.source_slice.start as u32, pm.source_slice.end as u32); + if depth >= 2 { + nested_bracket_begins.insert(s); + } + if depth > procedural.max_block_depth { + procedural.max_block_depth = depth; + procedural.max_block_depth_span = Some(SourceSpan::new( + s, + e, + line_at(s), + line_at(e.saturating_sub(1)), + )); + } + } + Some(crate::facts::ScriptingBracket::End) => { + depth = depth.saturating_sub(1); + } + None => {} + } + } + } + let mut region_ranges: Vec<(u32, u32)> = Vec::new(); + let mut carried: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + // Leftover scanner state of anonymous regions, keyed by end byte — an + // immediately following `ELSE`-led unparsable run resumes it without + // attributing to any routine (Codex P2). + let mut anon_states: Vec<(u32, Option)> = Vec::new(); + for (node, stmt_facts) in statements.iter().zip(facts.statements.iter()) { + let is_procedural_region = matches!( + stmt_facts.kind, + StatementKind::Procedural | StatementKind::AnonymousBlock + ); + // An `unknown` statement can still hold procedural content the + // grammar parsed without classifying — a top-level MySQL `PREPARE + // stmt FROM @sql` is a plain statement, not an `Unparsable` run + // (Codex P1). Reuse the marker gate so ordinary unknowns stay + // unscanned. + let gated_unknown = stmt_facts.kind == StatementKind::Unknown && { + let tokens = tokens_of(node, line_at); + unparsable_is_procedural(&tokens) + }; + if !is_procedural_region && !gated_unknown { + continue; + } + // Statements *contained* in a unit are BigQuery-style routine + // bodies reclassified by `classify_statements`: their tokens are + // scanned by the routine's `MultiStatementSegment` region below, so + // the statement itself is not a region (double-scan guard). + if unit_ranges.iter().any(|&(s, e, _)| { + s <= stmt_facts.start_byte + && stmt_facts.end_byte <= e + && (s, e) != (stmt_facts.start_byte, stmt_facts.end_byte) + }) { + continue; + } + region_ranges.push((stmt_facts.start_byte, stmt_facts.end_byte)); + let tokens = tokens_of(node, line_at); + // A `procedural` statement that contains no routine-definition node + // is a *continuation* — a body fragment sqruff split off its routine + // (T-SQL/MySQL). Its increments attribute to the routine it follows + // (Codex P2). Routine-definition statements attribute by containment; + // anonymous blocks stay file-level. + let contained_units: Vec = unit_ranges + .iter() + .filter(|(s, e, _)| stmt_facts.start_byte <= *s && *e <= stmt_facts.end_byte) + .map(|&(_, _, idx)| idx) + .collect(); + let fallback_unit = + if stmt_facts.kind == StatementKind::Procedural && contained_units.is_empty() { + last_unit_before(&unit_ranges, stmt_facts.start_byte) + } else { + None + }; + // Continuations resume their routine's saved scanner state; the + // routine a definition region leaves open is its *last* unit. + let resumed = fallback_unit.and_then(|idx| carried.remove(&idx)); + let (stack, restored_body) = resumed.unwrap_or_default(); + let mut machine = Machine { + facts: &mut procedural, + unit_ranges: &unit_ranges, + unit_tallies: &mut unit_tallies, + change_risk: &mut change_risk, + emit: emit_contributions, + fallback_unit, + stack, + // Anonymous blocks, scripting statements, body continuations, + // and gated unknown statements *are* body; routine definitions + // enter their body at IS/AS/BEGIN — and so does a DECLARE-led + // anonymous block: its declaration section is not a path, so an + // initializer like `flag BOOLEAN := TRUE AND FALSE` counts + // nothing (Codex P2). IF/WHILE/BEGIN-led scripting stays + // immediate body. + in_body: restored_body + || (stmt_facts.kind == StatementKind::AnonymousBlock + && tokens.first().is_none_or(|t| t.word != "DECLARE")) + || fallback_unit.is_some() + || gated_unknown, + pending_loop_headers: 0, + pending_between: false, + pending_routine_header: false, + pending_routine_bodies: Vec::new(), + pending_spec_header: false, + tsql, + oracle, + mysql, + signature_paren_depth: 0, + reset_gate_at: Vec::new(), + reset_cursor: 0, + last_bool: None, + }; + machine.scan(&tokens); + let end_state = (std::mem::take(&mut machine.stack), machine.in_body); + drop(machine); + // Save the scanner state for the routine this region belongs to so + // its next continuation resumes it. An anonymous region's leftover + // state is kept too (keyed by its end byte): sqruff can split a + // standalone T-SQL decision between a parsed statement and an + // `ELSE`-led root run, and the else branch needs the open IF stack + // to keep its nesting (Codex P2). + if let Some(idx) = fallback_unit.or_else(|| contained_units.last().copied()) { + carried.insert(idx, end_state); + } else if stmt_facts.kind == StatementKind::AnonymousBlock && !end_state.0.is_empty() { + anon_states.push((stmt_facts.end_byte, Some(end_state))); + } + // A continuation's query constructs belong to its routine's embedded + // score (Codex P2) — facts are merged (not scores summed) so the + // max-shaped structural terms charge once per routine, not once per + // parser fragment. + if let Some(idx) = fallback_unit { + continuation_facts[idx].extend(query_root_facts(node)); + // The continuation is part of the routine's source extent: + // extend the unit's span so the emitted `Function` space covers + // the full body, not just the header fragment sqruff kept + // inside the definition node — per-function coverage enrichment + // and location-based consumers see the real scope (Codex P1). + // Attribution buckets (`unit_ranges`) deliberately keep the + // original ranges: continuation increments already reach this + // unit via `fallback_unit`, and re-bucketing mid-scan would + // change innermost-containment answers. + let unit = &mut facts.procedural_units[idx]; + if stmt_facts.end_byte > unit.end_byte { + unit.end_byte = stmt_facts.end_byte; + unit.end_line = unit.end_line.max(stmt_facts.end_line); + } + } + // Entry path: +1 for an anonymous block itself (a routine-definition + // statement's entries come from its units below). Nested BigQuery + // brackets are lexical blocks inside an already-entered scripting + // region — no second entry (Codex P2). + if stmt_facts.kind == StatementKind::AnonymousBlock + && !nested_bracket_begins.contains(&stmt_facts.start_byte) + { + push_entry(&mut procedural, crate::facts::statement_span(stmt_facts)); + } + } + + // BigQuery-style top-level scripting (`IF … THEN DROP TABLE …; END IF;` + // at file level) parses as a `MultiStatementSegment` directly under + // `File` — *outside* any `Statement` node. Its inner DDL/DML statements + // are the file's top-level statements (so object/risk scans see them + // normally), but the scripting control flow around them is only visible + // here: scan each such segment as an anonymous-block region. Segments + // inside already-collected regions (a routine body's scripting) are + // skipped by containment. + let multi_statements = root.recursive_crawl(&MULTI_STATEMENT, false, &SyntaxSet::EMPTY, true); + for seg in &multi_statements { + let Some(pm) = seg.get_position_marker() else { + continue; + }; + let (start, end) = (pm.source_slice.start as u32, pm.source_slice.end as u32); + if region_ranges.iter().any(|(s, e)| *s <= start && end <= *e) { + continue; + } + region_ranges.push((start, end)); + let tokens = tokens_of(seg, line_at); + let mut machine = Machine { + facts: &mut procedural, + unit_ranges: &unit_ranges, + unit_tallies: &mut unit_tallies, + change_risk: &mut change_risk, + emit: emit_contributions, + fallback_unit: None, + stack: Vec::new(), + in_body: true, + pending_loop_headers: 0, + pending_between: false, + pending_routine_header: false, + pending_routine_bodies: Vec::new(), + pending_spec_header: false, + tsql, + oracle, + mysql, + signature_paren_depth: 0, + reset_gate_at: Vec::new(), + reset_cursor: 0, + last_bool: None, + }; + machine.scan(&tokens); + // The segment is an *anonymous* scripting region only when no + // routine lives in it — BigQuery wraps `CREATE PROCEDURE` bodies in + // a `MultiStatementSegment` too, and those already earn their entry + // through the unit loop below (Codex P2). + let overlaps_unit = unit_ranges.iter().any(|(s, e, _)| *s < end && start < *e); + if !overlaps_unit { + push_entry( + &mut procedural, + SourceSpan::new(start, end, line_at(start), line_at(end.saturating_sub(1))), + ); + } + } + + // Entry paths per routine unit (independent of the region loop so units + // in a partially-parsed statement still count; each subprogram is its own + // path — Sonar's model). + for (idx, unit) in facts.procedural_units.iter().enumerate() { + let span = SourceSpan::new( + unit.start_byte, + unit.end_byte, + unit.start_line, + unit.end_line, + ); + push_entry(&mut procedural, span); + unit_tallies[idx].0 += 1.0; + } + + // Top-level unparsable runs outside procedural statements — T-SQL bodies + // spill here. Marker-gated so broken declarative SQL contributes nothing. + let unparsables = root.recursive_crawl(&UNPARSABLE, true, &SyntaxSet::EMPTY, true); + for run in &unparsables { + let Some(pm) = run.get_position_marker() else { + continue; + }; + let (start, end) = (pm.source_slice.start as u32, pm.source_slice.end as u32); + let contained = region_ranges.iter().any(|(s, e)| *s <= start && end <= *e); + if contained { + continue; // already scanned within its statement's region + } + let tokens = tokens_of(run, line_at); + // A run that *holds* recovered routine units (synthetic T-SQL/MySQL + // definitions, Codex P1) is proven procedural by their headers — + // the generic spill-marker gate would reject bodies without spill + // markers (`CREATE TRIGGER … AS IF EXISTS (…) RAISERROR (…)`, + // Codex P1). Typed units never live inside an unparsable run, so + // containment implies recovery. + let recovered_unit = unit_ranges.iter().any(|&(s, e, _)| start <= s && e <= end); + // Every recovered definition inside this run scans with a fresh + // gate at its header, and the gate *closes* again at each unit's + // end so intervening statements between definitions never count as + // body content (Codex P2 ×2). + let reset_gate_at: Vec = if recovered_unit { + let mut boundaries: Vec = unit_ranges + .iter() + .filter(|&&(s, e, _)| start <= s && e <= end) + .flat_map(|&(s, e, _)| [s, e]) + .collect(); + boundaries.sort_unstable(); + boundaries.dedup(); + boundaries + } else { + Vec::new() + }; + if !recovered_unit && !unparsable_is_procedural(&tokens) { + continue; + } + // A top-level spill is the body of the routine it follows — but + // only the spill dialects (T-SQL batches, MySQL delimiter bodies) + // actually put routine continuations in *root* runs, so the + // fallback is dialect-gated: an Oracle routine followed by an + // unparsable anonymous block is two independent things, and + // attaching the block would suppress its entry, extend the + // function space, and misattribute its paths (Codex P2). Under a + // spill dialect, attribute to the last unit ending before the run + // and resume that routine's scanner state (Codex P2) — unless a + // `GO` between them severs the tie: the run is a new batch + // (Codex P2). No unit → file-level only, fresh state. + let fallback_unit = if recovered_unit { + // A recovered definition is its own routine: it never continues + // the one before it — no stale scanner state, no span stretch + // over a sibling definition (Codex P2). Its tokens attribute by + // containment (the run *is* the unit range). + None + } else if spills_routine_bodies { + last_unit_before(&unit_ranges, start).filter(|&idx| { + let unit_end = facts.procedural_units[idx].end_byte; + !go_boundaries + .iter() + .any(|&go| unit_end <= go && go <= start) + }) + } else { + None + }; + // A *standalone* control-led run is an anonymous block the parser + // lost (`BEGIN TRY … END CATCH` at file level): one entry path, + // like its statement-backed equivalent (Codex P2). Fragment shapes + // (`ELSE …`-led continuations) and isolated dynamic-SQL runs + // (`EXEC @sql`) open no scope and stay entry-free. + if fallback_unit.is_none() { + let control_led = tokens.first().is_some_and(|t| { + matches!(t.word.as_str(), "IF" | "WHILE" | "FOR" | "LOOP" | "DECLARE") + || (t.word == "BEGIN" + && !matches!( + tokens.get(1).map(|t| t.word.as_str()).unwrap_or(""), + "TRANSACTION" + | "TRAN" + | "WORK" + | "DIALOG" + | "DISTRIBUTED" + | "CONVERSATION" + | ";" + )) + }); + if control_led { + push_entry( + &mut procedural, + SourceSpan::new(start, end, line_at(start), line_at(end.saturating_sub(1))), + ); + } + } + let resumed = fallback_unit.and_then(|idx| carried.remove(&idx)); + // A standalone `ELSE`-led run continues the anonymous region right + // before it: resume that region's open IF stack so the else branch + // keeps its nesting — without attributing to any routine (Codex P2). + let resumed_anon = if resumed.is_none() && fallback_unit.is_none() { + let else_led = tokens.first().is_some_and(|t| t.word == "ELSE"); + if else_led { + anon_states + .iter_mut() + .rev() + .find(|(anon_end, slot)| *anon_end <= start && slot.is_some()) + .filter(|(anon_end, _)| { + // Adjacency: no other scanned region between. + !region_ranges + .iter() + .any(|&(s2, _)| *anon_end < s2 && s2 < start) + }) + .and_then(|(_, slot)| slot.take()) + } else { + None + } + } else { + None + }; + // Standalone runs delay the body gate when DECLARE-led (their + // declaration section is not a path — the `BEGIN` opens the body, + // Codex P2); attributed or resumed runs are proven body content + // (a T-SQL body statement can itself start with `DECLARE @v …`). + let standalone = fallback_unit.is_none() && resumed.is_none() && resumed_anon.is_none(); + let declare_led = tokens.first().is_some_and(|t| t.word == "DECLARE"); + let (stack, restored_body) = resumed.or(resumed_anon).unwrap_or_default(); + let mut machine = Machine { + facts: &mut procedural, + unit_ranges: &unit_ranges, + unit_tallies: &mut unit_tallies, + change_risk: &mut change_risk, + emit: emit_contributions, + fallback_unit, + stack, + // The marker gate just proved this fragment is procedural body + // content (it may start mid-body — T-SQL spills lose the opening + // BEGIN to the parsed part), so the body gate is open from the + // start — except standalone DECLARE-led runs (Codex P2) and + // recovered definitions, whose *header* leads the run: their + // body opens at AS/BEGIN (T-SQL), the parameter list's close or + // `FOR EACH ROW` (MySQL), or IS→BEGIN (Oracle members), so + // header tokens like `CREATE OR REPLACE` never count as body + // booleans (Codex P2). + in_body: restored_body || (!recovered_unit && !(standalone && declare_led)), + pending_loop_headers: 0, + pending_between: false, + pending_routine_header: false, + pending_routine_bodies: Vec::new(), + pending_spec_header: false, + tsql, + oracle, + mysql, + signature_paren_depth: 0, + reset_gate_at, + reset_cursor: 0, + last_bool: None, + }; + machine.scan(&tokens); + let end_state = (std::mem::take(&mut machine.stack), machine.in_body); + drop(machine); + if let Some(idx) = fallback_unit { + carried.insert(idx, end_state); + // The spill is part of the routine's source extent too — same + // span extension as statement continuations above (Codex P1). + let unit = &mut facts.procedural_units[idx]; + if end > unit.end_byte { + unit.end_byte = end; + unit.end_line = unit.end_line.max(line_at(end.saturating_sub(1))); + } + } + } + + // Every routine unit is one evidence-backed `routine_count` increment, + // spanning its (continuation-extended) definition (Codex P1). + if emit_contributions { + for unit in &facts.procedural_units { + procedural.evidence.push(ProceduralEvidence { + span: SourceSpan::new( + unit.start_byte, + unit.end_byte, + unit.start_line, + unit.end_line, + ), + metric: ProceduralMetric::RoutineCount, + amount: 1.0, + reason: reason::ROUTINE, + }); + } + // The high-water mark keeps the evidence-sum invariant with a + // single contribution at the deepest opener whose amount is the + // observed depth (Codex P1). + if let Some(span) = procedural.max_block_depth_span { + procedural.evidence.push(ProceduralEvidence { + span, + metric: ProceduralMetric::MaxBlockDepth, + amount: procedural.max_block_depth as f64, + reason: reason::DEEPEST_BLOCK, + }); + } + } + + // Embedded query complexity per routine (§9.3 — "the worst embedded + // query inside any single routine"): each maximal query root in the + // unit's subtree (and its attributed continuations) scores separately, + // and the routine takes the *maximum* — two trivial SELECTs score like + // one, not like their sum (Codex P2). Typed nodes match their unit by + // start byte: synthetic T-SQL units recovered from unparsable runs + // (Codex P1) have no node — and no typed query constructs — so they + // keep score 0 unless continuations attribute queries to them. + let unit_nodes = crate::facts::procedural_unit_nodes(root); + debug_assert!(unit_nodes.len() <= facts.procedural_units.len()); + let mut max_unit: Option<(usize, f64)> = None; + for node in unit_nodes.iter() { + let Some(pm) = node.get_position_marker() else { + continue; + }; + let start = pm.source_slice.start as u32; + let Some(idx) = facts + .procedural_units + .iter() + .position(|u| u.start_byte == start) + else { + continue; + }; + let score = query_root_facts(node) + .iter() + .chain(continuation_facts[idx].iter()) + .map(crate::composite::structural) + .fold(0.0f64, f64::max); + facts.procedural_units[idx].embedded_query_structural = score; + if score > procedural.max_embedded_query_structural { + procedural.max_embedded_query_structural = score; + max_unit = Some((idx, score)); + } + } + // Synthetic units score their attributed continuations alone. + for (idx, roots) in continuation_facts.iter().enumerate() { + if facts.procedural_units[idx].embedded_query_structural == 0.0 && !roots.is_empty() { + let score = roots + .iter() + .map(crate::composite::structural) + .fold(0.0f64, f64::max); + facts.procedural_units[idx].embedded_query_structural = score; + if score > procedural.max_embedded_query_structural { + procedural.max_embedded_query_structural = score; + max_unit = Some((idx, score)); + } + } + } + // The published maximum is evidence-backed like every other composite: + // one entry naming the winning routine (§4.7, Codex P1). + if emit_contributions + && let Some((idx, score)) = max_unit + && let Some(unit) = facts.procedural_units.get(idx) + { + procedural.evidence.push(ProceduralEvidence { + span: SourceSpan::new( + unit.start_byte, + unit.end_byte, + unit.start_line, + unit.end_line, + ), + metric: ProceduralMetric::EmbeddedQueryMax, + amount: score, + reason: reason::EMBEDDED_QUERY, + }); + } + + for (idx, (cyclo, cognitive)) in unit_tallies.into_iter().enumerate() { + if let Some(unit) = facts.procedural_units.get_mut(idx) { + unit.cyclomatic_complexity = cyclo; + unit.cognitive_complexity = cognitive; + } + } + facts.change_risk_evidence.extend(change_risk); + facts.procedural = procedural; +} + +/// The index of the last routine unit whose range ends at or before `byte` — +/// the routine a body continuation (split statement or unparsable spill) +/// belongs to. +fn last_unit_before(unit_ranges: &[(u32, u32, usize)], byte: u32) -> Option { + unit_ranges + .iter() + .filter(|(_, e, _)| *e <= byte) + .max_by_key(|(_, e, _)| *e) + .map(|&(_, _, idx)| idx) +} + +/// The declarative query facts of each *maximal query root* in a region's +/// subtree — the per-query inputs to `sql.structural_complexity` scoring +/// (§8.1). One entry per root: the embedded metric is the *maximum* over +/// individual queries, never a sum across them (Codex P2). +/// +/// Nested routine definitions are *excluded*: a subprogram declared inside +/// the region scores its own queries when it is scored as its own unit, so +/// embedded complexity follows the same innermost-ownership contract as the +/// control-flow increments — an outer routine with no query of its own +/// cannot outrank its child on the child's query (Codex P2). The walk +/// descends past containers that hold nested definitions; within each +/// definition-free subtree, the maximal query roots (recurse_into=false +/// keeps a WITH's inner SELECTs from double-extracting) each yield their +/// own facts. +fn query_root_facts(region: &ErasedSegment) -> Vec { + let mut roots = Vec::new(); + let mut pending: Vec = region.segments().to_vec(); + while let Some(node) = pending.pop() { + if crate::facts::PROCEDURAL_UNITS.contains(node.get_type()) { + continue; // a nested unit: scored separately + } + let holds_nested = !node + .recursive_crawl( + &crate::facts::PROCEDURAL_UNITS, + true, + &SyntaxSet::EMPTY, + false, + ) + .is_empty(); + if holds_nested { + pending.extend(node.segments().iter().cloned()); + } else { + for query in node.recursive_crawl(&QUERY_ROOTS, false, &SyntaxSet::EMPTY, true) { + roots.push(subtree_query_facts(&query)); + } + } + } + roots +} + +/// The query facts of one query-root subtree, collected with the node as +/// the crawl root so subquery depths are query-relative. +fn subtree_query_facts(region: &ErasedSegment) -> SqlFileFacts { + let mut mini = SqlFileFacts::default(); + let selects = region.recursive_crawl(&SELECT_STATEMENT, true, &SyntaxSet::EMPTY, true); + mini.query_block_count = selects.len() as u32; + crate::facts::extract_joins(region, &mut mini.joins); + crate::facts::extract_set_ops(region, &mut mini.set_ops); + crate::facts::extract_cases(region, &mut mini.cases); + crate::facts::extract_windows(region, &mut mini.windows); + crate::facts::extract_aggregates(region, &mut mini.aggregates); + crate::facts::extract_predicates(region, &mut mini.predicates); + crate::facts::extract_subqueries(region, &selects, &mut mini.subqueries); + crate::facts::extract_expressions(region, &mut mini.expressions); + crate::facts::extract_cte_graph(region, &mut mini.ctes); + mini +} diff --git a/crates/mehen-sql/tests/contributions.rs b/crates/mehen-sql/tests/contributions.rs index 6a1041a7b..d74d8e20c 100644 --- a/crates/mehen-sql/tests/contributions.rs +++ b/crates/mehen-sql/tests/contributions.rs @@ -41,13 +41,24 @@ fn change_risk_contributions_are_weighted_spanned_and_complete() { let contributions = &analysis.contributions; assert!(!contributions.is_empty()); - assert!(contributions.iter().all(|item| { - item.metric.as_str() == "sql.change_risk_score" - && item.span.start_byte <= item.span.end_byte - && item.span.end_byte as usize <= sql.len() - && item.span.start_line >= 1 - && item.span.start_line <= item.span.end_line - })); + // Change-risk entries are well-formed; the raw object counters emit + // their own evidence family alongside (PR #257 round 17). + assert!( + contributions + .iter() + .filter(|item| item.metric.as_str() == "sql.change_risk_score") + .all(|item| { + item.span.start_byte <= item.span.end_byte + && item.span.end_byte as usize <= sql.len() + && item.span.start_line >= 1 + && item.span.start_line <= item.span.end_line + }) + ); + assert!( + contributions + .iter() + .any(|item| item.metric.as_str() == "sql.ddl.drop_count") + ); assert_risk_sum(&analysis); @@ -143,3 +154,249 @@ fn every_implemented_change_risk_term_has_a_stable_reason() { assert_risk_sum(&analysis); } } + +// ── procedural evidence (Phase 3) ─────────────────────────────────────── + +/// Procedural metrics are evidence-backed: the published value equals the +/// sum of contribution amounts by construction — the composites *and* every +/// raw `*_count` under its own key (Codex P1, PR #257 round 10) — for both +/// the typed-CST path (PL/SQL) and the token-fallback path (T-SQL with +/// unparsable spills). `max_block_depth`, a high-water mark, keeps the +/// invariant with a single contribution at the deepest opener (Codex P1, +/// round 11). +#[test] +fn procedural_complexity_evidence_sums_to_the_metric() { + for fixture in [ + include_str!("fixtures/plsql_procedure_control_flow.sql"), + include_str!("fixtures/tsql_procedure_control_flow.sql"), + include_str!("fixtures/mysql_procedure_control_flow.sql"), + include_str!("fixtures/bigquery_scripting.sql"), + ] { + let analysis = analyze(fixture, &AnalysisConfig::production()); + for key in [ + "sql.procedural.cyclomatic_complexity", + "sql.procedural.cognitive_complexity", + "sql.procedural.block_count", + "sql.procedural.routine_count", + "sql.procedural.loop_count", + "sql.procedural.if_count", + "sql.procedural.case_statement_count", + "sql.procedural.exception_handler_count", + "sql.procedural.return_count", + "sql.procedural.raise_throw_count", + "sql.procedural.dynamic_sql_count", + "sql.procedural.max_block_depth", + ] { + let sum: f64 = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == key) + .map(|item| item.amount) + .sum(); + assert_eq!(sum, metric(&analysis, key), "evidence sum for {key}"); + } + // Spans are well-formed and inside the file. + assert!( + analysis + .contributions + .iter() + .filter(|item| item.metric.as_str().starts_with("sql.procedural.")) + .all(|item| { + item.span.start_byte <= item.span.end_byte + && item.span.end_byte as usize <= fixture.len() + && item.span.start_line >= 1 + }), + ); + } +} + +/// Dynamic SQL (`EXECUTE IMMEDIATE`, `sp_executesql`) is a change-risk term +/// with its own stable reason code, and the risk-sum invariant holds with it. +#[test] +fn dynamic_sql_contributes_to_change_risk() { + let analysis = analyze( + include_str!("fixtures/plsql_procedure_control_flow.sql"), + &AnalysisConfig::production(), + ); + let dynamic: Vec<_> = analysis + .contributions + .iter() + .filter(|item| item.reason.as_str() == "sql.change_risk.dynamic_sql") + .collect(); + assert_eq!(dynamic.len(), 1); + assert_eq!(dynamic[0].amount, 5.0); + assert_risk_sum(&analysis); +} + +/// The benchmark profile skips procedural evidence without changing the +/// procedural metrics (counts are always exact; evidence is opt-in). +#[test] +fn benchmark_profile_skips_procedural_evidence_without_changing_metrics() { + let sql = include_str!("fixtures/plsql_procedure_control_flow.sql"); + let production = analyze(sql, &AnalysisConfig::production()); + let benchmark = analyze(sql, &AnalysisConfig::benchmark()); + + assert!( + production + .contributions + .iter() + .any(|item| item.metric.as_str().starts_with("sql.procedural.")) + ); + assert!(benchmark.contributions.is_empty()); + for key in [ + "sql.procedural.cyclomatic_complexity", + "sql.procedural.cognitive_complexity", + "sql.change_risk_score", + ] { + assert_eq!(metric(&production, key), metric(&benchmark, key), "{key}"); + } +} + +/// `sql.structural_complexity.max_embedded_query` is evidence-backed: one +/// entry naming the winning routine, whose amount equals the published value +/// (Codex P1, PR #257 round 2). +#[test] +fn embedded_query_max_has_evidence_for_the_winning_routine() { + let analysis = analyze( + include_str!("fixtures/plsql_procedure_control_flow.sql"), + &AnalysisConfig::production(), + ); + let entries: Vec<_> = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == "sql.structural_complexity.max_embedded_query") + .collect(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].reason.as_str(), "sql.procedural.embedded_query"); + assert_eq!( + entries[0].amount, + metric(&analysis, "sql.structural_complexity.max_embedded_query") + ); + assert!(entries[0].amount > 0.0); +} + +/// `sql.predicate.not_count` is evidence-backed: each counted negation +/// carries its token span and reason, and the sum equals the metric +/// (Codex P1, PR #257 round 13). +#[test] +fn predicate_not_evidence_sums_to_the_metric() { + let sql = "SELECT * FROM t WHERE NOT active AND flag IS NOT NULL;\n\ + CREATE TABLE u (id INT NOT NULL);\n"; + let analysis = analyze(sql, &AnalysisConfig::production()); + let nots: Vec<_> = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == "sql.predicate.not_count") + .collect(); + // `NOT active` + `IS NOT NULL` count; the column constraint does not. + assert_eq!(nots.len(), 2); + assert!(nots.iter().all(|item| { + item.reason.as_str() == "sql.predicate.not" + && item.amount == 1.0 + && (item.span.end_byte as usize) <= sql.len() + })); + let sum: f64 = nots.iter().map(|item| item.amount).sum(); + assert_eq!(sum, metric(&analysis, "sql.predicate.not_count")); +} + +/// Raw object-family counters are evidence-backed under their own keys — +/// both the per-statement classification path and the anonymous-block body +/// scan — and each key's evidence sums to its metric (Codex P1, PR #257 +/// round 17). +#[test] +fn object_counter_evidence_sums_to_the_metrics() { + let sql = "-- sqlfluff:dialect:oracle\n\ + update t set c = 1 where id = 1;\n\ + drop table old_stuff;\n\ + begin\n\ + update accounts set bal = 0;\n\ + insert into audit_log (id) values (1);\n\ + end;\n\ + /\n"; + let analysis = analyze(sql, &AnalysisConfig::production()); + for (key, expected) in [ + ("sql.dml.update_count", 2.0), + ("sql.dml.insert_count", 1.0), + ("sql.ddl.drop_count", 1.0), + ] { + let entries: Vec<_> = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == key) + .collect(); + let sum: f64 = entries.iter().map(|item| item.amount).sum(); + assert_eq!(sum, metric(&analysis, key), "evidence sum for {key}"); + assert_eq!(sum, expected, "expected count for {key}"); + assert!(entries.iter().all(|item| { + (item.span.end_byte as usize) <= sql.len() && item.span.start_line >= 1 + })); + } +} + +/// The no-WHERE counters are evidence-backed under their own keys, not +/// just as change risk (Codex P1, PR #257 round 18). +#[test] +fn no_where_counter_evidence_sums_to_the_metrics() { + let sql = "UPDATE t SET c = 1;\nDELETE FROM u;\n"; + let analysis = analyze(sql, &AnalysisConfig::production()); + for key in [ + "sql.dml.update_without_where_count", + "sql.dml.delete_without_where_count", + ] { + let sum: f64 = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == key) + .map(|item| item.amount) + .sum(); + assert_eq!(sum, metric(&analysis, key), "evidence sum for {key}"); + assert_eq!(sum, 1.0, "expected count for {key}"); + } +} + +/// The RETURNING/OUTPUT clause counter is evidence-backed under its own +/// key (Codex P1, PR #257 round 20). +#[test] +fn returning_counter_evidence_sums_to_the_metric() { + let sql = "-- sqlfluff:dialect:oracle\n\ + update t set c = 1 returning c into v;\n"; + let analysis = analyze(sql, &AnalysisConfig::production()); + let key = "sql.dml.returning_count"; + let sum: f64 = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == key) + .map(|item| item.amount) + .sum(); + assert_eq!(sum, metric(&analysis, key), "evidence sum for {key}"); + assert_eq!(sum, 1.0); +} + +/// Distinct-object counters and the anonymous-block kind count are +/// evidence-backed under their own keys (Codex P1 ×2, PR #257 round 21). +#[test] +fn object_and_kind_count_evidence_sums_to_the_metrics() { + let sql = "-- sqlfluff:dialect:oracle\n\ + update t set c = 1 where id = 1;\n\ + select * from u;\n\ + begin\n\ + null;\n\ + end;\n\ + /\n"; + let analysis = analyze(sql, &AnalysisConfig::production()); + for key in [ + "sql.object.write_count", + "sql.object.read_count", + "sql.object.touch_count", + "sql.statement.kind_count.anonymous_block", + ] { + let sum: f64 = analysis + .contributions + .iter() + .filter(|item| item.metric.as_str() == key) + .map(|item| item.amount) + .sum(); + assert_eq!(sum, metric(&analysis, key), "evidence sum for {key}"); + assert!(sum >= 1.0, "expected a positive count for {key}"); + } +} diff --git a/crates/mehen-sql/tests/fixtures/bigquery_scripting.sql b/crates/mehen-sql/tests/fixtures/bigquery_scripting.sql new file mode 100644 index 000000000..4725f2054 --- /dev/null +++ b/crates/mehen-sql/tests/fixtures/bigquery_scripting.sql @@ -0,0 +1,27 @@ +-- sqlfluff:dialect:bigquery +declare x int64 default 0; + +if x > 0 then + update t set c = 1 where id = x; +elseif x < -5 then + set x = 1; +else + set x = 2; +end if; + +while x < 10 do + set x = x + 1; + if x = 5 then + break; + end if; +end while; + +for rec in (select 1 as n) do + set x = rec.n; +end for; + +begin + execute immediate 'drop table scratch'; +exception when error then + raise using message = 'boom'; +end; diff --git a/crates/mehen-sql/tests/fixtures/mysql_procedure_control_flow.sql b/crates/mehen-sql/tests/fixtures/mysql_procedure_control_flow.sql new file mode 100644 index 000000000..b35e0d1b7 --- /dev/null +++ b/crates/mehen-sql/tests/fixtures/mysql_procedure_control_flow.sql @@ -0,0 +1,32 @@ +-- sqlfluff:dialect:mysql +create procedure process_orders(in p_batch int) +begin + declare v_count int default 0; + + if p_batch > 0 then + update orders set status = 'WORKING' where batch_id = p_batch; + elseif p_batch < -5 then + set v_count = 1; + else + set v_count = 2; + end if; + + while v_count > 0 do + set v_count = v_count - 1; + end while; + + repeat + set v_count = v_count + 1; + until v_count > 3 + end repeat; + + case v_count + when 1 then set v_count = 10; + else set v_count = 20; + end case; + + prepare stmt from @sql; + execute stmt; + + signal sqlstate '45000'; +end diff --git a/crates/mehen-sql/tests/fixtures/plsql_procedure_control_flow.sql b/crates/mehen-sql/tests/fixtures/plsql_procedure_control_flow.sql new file mode 100644 index 000000000..77468adbe --- /dev/null +++ b/crates/mehen-sql/tests/fixtures/plsql_procedure_control_flow.sql @@ -0,0 +1,31 @@ +-- sqlfluff:dialect:oracle +create or replace procedure process_orders(p_batch number) is + v_count pls_integer := 0; +begin + if p_batch > 0 then + update orders set status = 'WORKING' where batch_id = p_batch; + v_count := v_count + 1; + elsif p_batch < -10 and v_count = 0 then + raise_application_error(-20001, 'bad batch'); + else + null; + end if; + + while v_count > 0 loop + v_count := v_count - 1; + exit when v_count = 5; + end loop; + + for i in 1..3 loop + v_count := v_count + i; + end loop; + + execute immediate 'drop table scratch'; + return; +exception + when no_data_found then + raise; + when others then + raise; +end process_orders; +/ diff --git a/crates/mehen-sql/tests/fixtures/tsql_procedure_control_flow.sql b/crates/mehen-sql/tests/fixtures/tsql_procedure_control_flow.sql new file mode 100644 index 000000000..307c74da2 --- /dev/null +++ b/crates/mehen-sql/tests/fixtures/tsql_procedure_control_flow.sql @@ -0,0 +1,31 @@ +-- sqlfluff:dialect:tsql +create procedure dbo.process_orders @batch int as +begin + declare @count int = 0; + + if @batch > 0 + begin + update orders set status = 'WORKING' where batch_id = @batch; + set @count = @@rowcount; + end + else + begin + set @count = 0; + end + + while @count > 0 + begin + set @count = @count - 1; + if @count = 5 break; + end + + begin try + exec sp_executesql N'drop table scratch'; + end try + begin catch + if error_number() = 208 throw; + return; + end catch + + return; +end diff --git a/crates/mehen-sql/tests/fixtures_snapshot.rs b/crates/mehen-sql/tests/fixtures_snapshot.rs index 1eefe54fe..8fd331faf 100644 --- a/crates/mehen-sql/tests/fixtures_snapshot.rs +++ b/crates/mehen-sql/tests/fixtures_snapshot.rs @@ -69,3 +69,7 @@ fixture_snapshot!(migration_destructive, "migration_destructive"); fixture_snapshot!(correlated_subquery, "correlated_subquery"); fixture_snapshot!(set_ops_unions, "set_ops_unions"); fixture_snapshot!(dialect_directive, "dialect_directive"); +fixture_snapshot!(plsql_procedure_control_flow, "plsql_procedure_control_flow"); +fixture_snapshot!(tsql_procedure_control_flow, "tsql_procedure_control_flow"); +fixture_snapshot!(mysql_procedure_control_flow, "mysql_procedure_control_flow"); +fixture_snapshot!(bigquery_scripting, "bigquery_scripting"); diff --git a/crates/mehen-sql/tests/metrics.rs b/crates/mehen-sql/tests/metrics.rs index d5d50e0bd..5689db2b4 100644 --- a/crates/mehen-sql/tests/metrics.rs +++ b/crates/mehen-sql/tests/metrics.rs @@ -1097,3 +1097,2032 @@ fn directive_surfaces_on_comment_only_file() { assert!(diag_codes(&a).contains(&"sql.dialect.unknown".to_string())); assert_eq!(get(&a.root.metrics, "sql.dialect.directive_present"), 1.0); } + +// ── procedural SQL (research foundation §6.17, Phase 3) ─────────────── + +/// PL/SQL routine with the full §6.17 construct set — every count below is +/// hand-traced against the fixture (see the cyclomatic/cognitive breakdowns +/// inline). The fixture parses fully under the Oracle dialect, so this +/// exercises the typed-CST token path. +#[test] +fn plsql_procedural_family_counts() { + let m = metrics(include_str!("fixtures/plsql_procedure_control_flow.sql")); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.block_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.max_block_depth"), 1.0); + // IF + one ELSIF. + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // WHILE … LOOP + numeric FOR … LOOP (each counted once, not twice for + // their body-opening LOOP keyword). + assert_eq!(get(&m, "sql.procedural.loop_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.case_statement_count"), 0.0); + // EXCEPTION WHEN no_data_found / WHEN others. + assert_eq!(get(&m, "sql.procedural.exception_handler_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.return_count"), 1.0); + // raise_application_error + two bare RAISE. + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 3.0); + // EXECUTE IMMEDIATE. + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + // Cyclomatic (Sonar PL/SQL model): entry 1 + IF 1 + ELSIF 1 + AND 1 + // + RAISE×3 + loops×2 + EXIT WHEN 1 + handlers×2 = 12. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 12.0); + // Cognitive: IF 1 + ELSIF 1 + ELSE 1 + boolean sequence 1 + WHILE 1 + // + EXIT WHEN 1 + FOR 1 + handlers×2 = 9 (flat: nothing is nested). + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 9.0); + // Change risk: CREATE OR REPLACE (4) + dynamic SQL (5). The routine + // body's UPDATE is *not* file-level risk (it runs when called, not when + // the file is applied). + assert_eq!(get(&m, "sql.change_risk_score"), 9.0); + assert_eq!(get(&m, "sql.object.write_count"), 0.0); + // The embedded UPDATE…WHERE gives the routine a small query-structural + // score, surfaced file-level as the max over routines. + assert!(get(&m, "sql.structural_complexity.max_embedded_query") > 0.0); +} + +/// T-SQL routine exercising the token fallback path: sqruff parses the +/// header and keyword-led IF statements but spills the WHILE/TRY-CATCH tail +/// into top-level `Unparsable` runs (parser comparison §9). The procedural +/// counts must survive that degradation — and the split body must NOT be +/// reported as independently-executing batch DML (Codex P1): T-SQL batch +/// semantics say the body extends to the next GO/EOF, so the keyword-led IF +/// that sqruff splits off is a routine *continuation*, not migration risk. +#[test] +fn tsql_procedural_family_counts_through_unparsable_spill() { + let m = metrics(include_str!("fixtures/tsql_procedure_control_flow.sql")); + // The parse loses statement structure but not the token stream. + assert!(get(&m, "sql.parser.unparsable_segment_count") > 0.0); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // IF @batch, IF @count = 5, IF error_number() = 208. + assert_eq!(get(&m, "sql.procedural.if_count"), 3.0); + assert_eq!(get(&m, "sql.procedural.loop_count"), 1.0); + // BEGIN CATCH. + assert_eq!(get(&m, "sql.procedural.exception_handler_count"), 1.0); + // THROW. + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); + // EXEC sp_executesql. + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.return_count"), 2.0); + // Proc body BEGIN + IF/ELSE blocks + WHILE block + TRY + CATCH. + assert_eq!(get(&m, "sql.procedural.block_count"), 6.0); + // Entry: the routine only — the keyword-led IF that sqruff splits into a + // sibling statement is reclassified as the routine's continuation, so it + // earns no separate anonymous-block entry. 1 entry + 3 IF + 1 WHILE + // + 1 CATCH + 1 THROW = 7. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 7.0); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 0.0); + assert_eq!(get(&m, "sql.statement.kind_count.procedural"), 2.0); + // The body's UPDATE runs when the procedure is *called*, not when the + // file is applied — no file-level DML or object-touch risk (Codex P1). + assert_eq!(get(&m, "sql.dml.update_count"), 0.0); + assert_eq!(get(&m, "sql.object.write_count"), 0.0); + // Change risk: dynamic SQL only. + assert_eq!(get(&m, "sql.change_risk_score"), 5.0); +} + +/// An Oracle anonymous block *executes when the file is applied*, so its +/// body DML/TCL feeds the DML counters, object touches, and change risk — +/// unlike a routine definition's body (probed: `Statement > +/// OracleBeginEndBlock`). +#[test] +fn anonymous_block_body_dml_counts_as_migration_risk() { + let sql = "-- sqlfluff:dialect:oracle\n\ + begin\n\ + update accounts set bal = 0;\n\ + commit;\n\ + end;\n\ + /\n"; + let m = metrics(sql); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 1.0); + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + assert_eq!(get(&m, "sql.dml.update_without_where_count"), 1.0); + assert_eq!(get(&m, "sql.transaction.control_count"), 1.0); + assert_eq!(get(&m, "sql.object.write_count"), 1.0); + // Procedural entry for the block itself. + assert_eq!(get(&m, "sql.procedural.block_count"), 1.0); + assert!(get(&m, "sql.procedural.cyclomatic_complexity") >= 1.0); +} + +/// Regression (dialect folding): sqruff's Oracle dialect emits its own +/// `OracleUpdateStatement`/`OracleTableReference`/… kinds. Before the folding +/// sets, top-level Oracle DML classified as `unknown` and appeared in no +/// `sql.dml.*` / object-touch / change-risk metric. +#[test] +fn oracle_dml_classifies_and_feeds_object_touch() { + let sql = "-- sqlfluff:dialect:oracle\n\ + update orders set status = 'X' where id = 1;\n\ + insert into audit_log (id) values (1);\n\ + delete from stale_rows;\n\ + commit;\n"; + let m = metrics(sql); + assert_eq!(get(&m, "sql.statement.kind_count.update"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.insert"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.delete"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.transaction_control"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.unknown"), 0.0); + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + assert_eq!(get(&m, "sql.dml.delete_without_where_count"), 1.0); + // orders + audit_log + stale_rows are written objects. + assert_eq!(get(&m, "sql.object.write_count"), 3.0); +} + +// ── predicate keyword fixes ───────────────────────────────────────────── + +/// `NOT NULL` column constraints and `IF NOT EXISTS` guards are DDL, not +/// predicate logic; `IS NOT NULL`, `NOT IN`, and `NOT EXISTS` predicates +/// still count. +#[test] +fn not_count_excludes_ddl_contexts() { + let ddl = metrics("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)"); + assert_eq!(get(&ddl, "sql.predicate.not_count"), 0.0); + + let guard = metrics("-- sqlfluff:dialect:postgres\nCREATE TABLE IF NOT EXISTS t (id INT)"); + assert_eq!(get(&guard, "sql.predicate.not_count"), 0.0); + + let is_not_null = metrics("SELECT a FROM t WHERE a IS NOT NULL"); + assert_eq!(get(&is_not_null, "sql.predicate.not_count"), 1.0); + + let not_in = metrics("SELECT a FROM t WHERE a NOT IN (1, 2)"); + assert_eq!(get(¬_in, "sql.predicate.not_count"), 1.0); + + let not_exists = metrics("SELECT a FROM t WHERE NOT EXISTS (SELECT 1 FROM u)"); + assert_eq!(get(¬_exists, "sql.predicate.not_count"), 1.0); +} + +/// `sql.subquery.in_count` counts IN-subqueries, not raw `IN` keywords — +/// a `FOR i IN 1..10 LOOP` header or a parameter direction never counts +/// (only `IN` followed by a bracketed SELECT does). +#[test] +fn in_subquery_count_ignores_procedural_in_keywords() { + let m = metrics(include_str!("fixtures/plsql_procedure_control_flow.sql")); + // The fixture has a FOR … IN loop and no IN-subqueries. + assert_eq!(get(&m, "sql.subquery.in_count"), 0.0); + + let predicate = metrics("SELECT a FROM t WHERE a IN (SELECT id FROM u)"); + assert_eq!(get(&predicate, "sql.subquery.in_count"), 1.0); +} + +// ── PR #257 review regressions (procedural state machine) ────────────── + +/// Homogeneous boolean chains cost one cognitive *sequence*, not one per +/// operator — operands must not break the run (Codex P2, PR #257). +#[test] +fn boolean_sequences_charge_per_run_not_per_operator() { + let homogeneous = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + if a = 1 and b = 2 and c = 3 then\n\ + null;\n\ + end if;\n\ + end;\n\ + /\n", + ); + // Cyclomatic: entry 1 + if 1 + two ANDs = 4. + assert_eq!( + get(&homogeneous, "sql.procedural.cyclomatic_complexity"), + 4.0 + ); + // Cognitive: if 1 + ONE sequence for the AND-run = 2. + assert_eq!( + get(&homogeneous, "sql.procedural.cognitive_complexity"), + 2.0 + ); + + let mixed = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + if a = 1 and b = 2 or c = 3 then\n\ + null;\n\ + end if;\n\ + end;\n\ + /\n", + ); + // Cognitive: if 1 + AND-run 1 + OR-run 1 = 3 (operator change re-charges). + assert_eq!(get(&mixed, "sql.procedural.cognitive_complexity"), 3.0); +} + +/// `WHEN NOT ` in a procedural CASE is a real branch; only the MERGE +/// `WHEN [NOT] MATCHED` token shape is declaratively excluded (Codex P2, +/// PR #257). +#[test] +fn case_when_not_condition_still_counts() { + // The Oracle grammar sends procedural CASE to Unparsable inside the + // block region — the token machine must still see both WHEN arms. + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + case when not done then null; when ready then null; end case;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.case_statement_count"), 1.0); + // Cyclomatic: entry + two WHEN arms. The Oracle grammar cannot parse + // procedural CASE, so the whole block degrades to a root `Unparsable` + // run — the `begin`-led run is a block-shaped standalone region and + // earns one anonymous entry, like its statement-backed equivalent + // (Codex P2, round 10). + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 3.0); + // MERGE keeps its clauses out of the procedural family. + let merge = metrics( + "MERGE INTO t USING s ON t.id = s.id \ + WHEN MATCHED THEN UPDATE SET c = 2 \ + WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id)", + ); + assert_eq!(get(&merge, "sql.procedural.case_statement_count"), 0.0); + assert_eq!(get(&merge, "sql.procedural.cyclomatic_complexity"), 0.0); +} + +/// Parenthesized conditions are ordinary statements (`IF (@x > 0)`), not the +/// scalar `IF(…)` function — the discriminator is the parsed function-name +/// shape, not the following `(` (Codex P2, PR #257). +#[test] +fn parenthesized_if_condition_counts_as_control_flow() { + let tsql = metrics( + "-- sqlfluff:dialect:tsql\n\ + if (@batch > 0)\n\ + begin\n\ + select 1;\n\ + end\n", + ); + assert_eq!(get(&tsql, "sql.procedural.if_count"), 1.0); + + // The MySQL scalar IF() *function* in parsed SQL stays declarative. + let scalar = metrics("-- sqlfluff:dialect:mysql\nSELECT IF(x > 0, 1, 2) FROM t;\n"); + assert_eq!(get(&scalar, "sql.procedural.if_count"), 0.0); +} + +/// A T-SQL `WHILE … BEGIN … END` body carries the loop's nesting: the IF +/// inside costs 1 + 1, exactly like its PL/SQL `WHILE … LOOP` equivalent +/// (Codex P2, PR #257). +#[test] +fn tsql_while_begin_body_nests_its_contents() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + while @x > 0\n\ + begin\n\ + if @x = 5 break;\n\ + set @x = @x - 1;\n\ + end\n", + ); + assert_eq!(get(&m, "sql.procedural.loop_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); + // Cognitive: while 1 + if (1 + 1 nesting) = 3. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 3.0); +} + +/// `DBMS_SQL.PARSE(…)` is dynamic SQL even though the parsed package +/// qualifier lexes as a `NakedIdentifier` (Codex P2, PR #257). +#[test] +fn dbms_sql_package_reference_counts_as_dynamic_sql() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + dbms_sql.parse(c, 'drop table scratch', 1);\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + assert!(get(&m, "sql.change_risk_score") >= 5.0); +} + +/// A genuinely top-level scripting block (no preceding routine definition) +/// executes on apply — DDL inside it is migration risk (Codex P1, PR #257): +/// `IF … THEN DROP TABLE t; END IF` must report the drop and its +8 risk +/// term. BigQuery scripting parses block DDL into typed nodes; the T-SQL +/// grammar loses `IF … BEGIN DROP …` bodies to `Unparsable` entirely, so +/// there this remains parser-bound (flagged by `sql.parser.*`, never +/// mis-counted). +#[test] +fn top_level_batch_block_ddl_counts_as_migration_risk() { + let m = metrics( + "-- sqlfluff:dialect:bigquery\n\ + if cleanup then\n\ + drop table stale_data;\n\ + truncate table audit_log;\n\ + end if;\n", + ); + // BigQuery top-level scripting parses as a `MultiStatementSegment` whose + // *inner* statements are the file's top-level statements — the DDL + // classifies and risk-scores through the normal per-statement path… + assert_eq!(get(&m, "sql.ddl.drop_count"), 1.0); + assert_eq!(get(&m, "sql.ddl.truncate_count"), 1.0); + // Drop 8 + truncate 8, plus write objects. + assert!(get(&m, "sql.change_risk_score") >= 16.0); + // …while the scripting control flow around them is measured as a + // procedural region (entry + IF). + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); +} + +/// Oracle `INSERT ALL` lists several statement-level `INTO` targets — every +/// one is written, none is a read (Codex P2, PR #257). A plain +/// `INSERT INTO … SELECT` keeps its source inside the SELECT, so widening +/// inserts to all-targets cannot misclassify sources as writes. +#[test] +fn insert_all_destinations_are_all_write_targets() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + insert all\n\ + into orders_archive (id) values (id)\n\ + into orders_audit (id) values (id)\n\ + select id from orders;\n", + ); + assert_eq!(get(&m, "sql.object.write_count"), 2.0); + assert_eq!(get(&m, "sql.object.read_count"), 1.0); + + let plain = metrics("INSERT INTO dst SELECT id FROM src"); + assert_eq!(get(&plain, "sql.object.write_count"), 1.0); + assert_eq!(get(&plain, "sql.object.read_count"), 1.0); +} + +/// DCL inside a typed anonymous block (Oracle `BEGIN … END`) counts — the +/// grant parses as an `AccessStatement` node there, unlike inside T-SQL +/// keyword-led blocks where the tsql grammar loses it to `Unparsable` +/// (Codex P1, PR #257). +#[test] +fn anonymous_block_dcl_counts_as_migration_risk() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + grant select on t to reporting;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 1.0); + assert_eq!(get(&m, "sql.dcl.grant_revoke_count"), 1.0); + assert!(get(&m, "sql.change_risk_score") >= 5.0); +} + +/// MySQL routine exercising the fragment path: the mysql grammar splits the +/// body into per-branch typed statements (`IfThenStatement` ×4, +/// `WhileStatement` ×2, `RepeatStatement` ×2) plus an `Unparsable` CASE run. +/// All fragments reclassify as routine continuations — body DML is not +/// migration-time DML — while the token machine counts control flow across +/// them (CodeRabbit, PR #257). +#[test] +fn mysql_procedural_family_counts_across_fragments() { + let m = metrics(include_str!("fixtures/mysql_procedure_control_flow.sql")); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // IF + ELSEIF. + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // WHILE … DO + REPEAT … END REPEAT. + assert_eq!(get(&m, "sql.procedural.loop_count"), 2.0); + // CASE … END CASE (from the Unparsable run). + assert_eq!(get(&m, "sql.procedural.case_statement_count"), 1.0); + // SIGNAL. + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); + // PREPARE … FROM (the paired EXECUTE stmt does not double-count). + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + // Entry 1 + IF 1 + ELSEIF 1 + WHILE 1 + REPEAT 1 + CASE WHEN 1 + // + SIGNAL 1 = 7. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 7.0); + // IF 1 + ELSEIF 1 + ELSE 1 + WHILE 1 + REPEAT 1 + CASE statement 1 = 6. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 6.0); + // Body fragments are routine continuations, not executing batches. + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 0.0); + assert_eq!(get(&m, "sql.dml.update_count"), 0.0); + // Change risk: dynamic SQL only. + assert_eq!(get(&m, "sql.change_risk_score"), 5.0); +} + +/// BigQuery top-level scripting: `MultiStatementSegment`s directly under +/// `File` are procedural regions (each with an entry path), while their +/// inner statements stay the file's top-level statements — the UPDATE +/// executes on apply and *does* count as migration DML, unlike a routine +/// body's (CodeRabbit, PR #257). +#[test] +fn bigquery_scripting_family_counts() { + let m = metrics(include_str!("fixtures/bigquery_scripting.sql")); + assert_eq!(get(&m, "sql.procedural.routine_count"), 0.0); + // IF + ELSEIF at top level, plus the IF nested in the WHILE. + assert_eq!(get(&m, "sql.procedural.if_count"), 3.0); + // WHILE … END WHILE + FOR … IN … DO … END FOR. + assert_eq!(get(&m, "sql.procedural.loop_count"), 2.0); + // EXCEPTION WHEN ERROR THEN (inside the begin/exception block, which the + // bigquery grammar partially loses to Unparsable — the handler tokens + // survive). + assert_eq!(get(&m, "sql.procedural.exception_handler_count"), 1.0); + // RAISE USING MESSAGE. + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); + // EXECUTE IMMEDIATE. + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + // Entries: if/while/for scripting regions = 3, + the bare BEGIN…END + // scripting block (round 10) = 4; + IF 3 + loops 2 + handler 1 + // + raise 1 = 11. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 11.0); + // IF 1 + ELSEIF 1 + ELSE 1 + WHILE 1 + nested IF 2 + FOR 1 + // + handler 1 = 8. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 8.0); + // Top-level scripting DML executes on apply. + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + // The bare `BEGIN;`/`END;` scripting brackets are a block, not + // transaction control (Codex P1, round 10). + assert_eq!(get(&m, "sql.procedural.block_count"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 1.0); + assert_eq!(get(&m, "sql.transaction.control_count"), 0.0); +} + +// ── PR #257 round-2 review regressions ────────────────────────────────── + +/// A T-SQL `GO` batch separator ends the routine body by definition — +/// whatever follows is a new, independently executing batch whose DDL is +/// migration risk (Codex P1, PR #257 round 2). +#[test] +fn go_separator_resets_routine_continuation() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p @x int as\n\ + begin\n\ + select 1;\n\ + end\n\ + go\n\ + if @cleanup = 1\n\ + begin\n\ + update stale set c = 1 where id = 1;\n\ + end\n", + ); + // The IF after GO is an anonymous batch, not a routine continuation. + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 1.0); + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + assert_eq!(get(&m, "sql.object.write_count"), 1.0); +} + +/// `DBMS_SQL.EXECUTE(c)` is one dynamic-SQL occurrence (counted at the +/// package qualifier), not two — the qualified method must not also match +/// the T-SQL `EXEC(…)` form (Codex P2, PR #257 round 2). +#[test] +fn qualified_dbms_sql_execute_counts_once() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + dbms_sql.parse(c, 'drop table t', 1);\n\ + dbms_sql.execute(c);\n\ + end;\n\ + /\n", + ); + // One per DBMS_SQL package usage: parse + execute. + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 2.0); +} + +/// A control structure in a T-SQL ELSE body keeps the IF's nesting: the +/// outer decision is still open there (Codex P2, PR #257 round 2). +#[test] +fn tsql_else_body_keeps_if_nesting() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0\n\ + begin\n\ + select 1;\n\ + end\n\ + else\n\ + begin\n\ + if @b > 0 select 2;\n\ + end\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // Outer IF 1 + ELSE 1 + inner IF (1 + 1 nesting) = 4. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 4.0); +} + +/// A single-statement T-SQL loop body (no BEGIN/END) still nests its +/// controlled statement (Codex P2, PR #257 round 2). +#[test] +fn tsql_single_statement_while_nests_its_body() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + while @x > 0 if @y > 0 set @x = @x - 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.loop_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); + // WHILE 1 + IF (1 + 1 nesting) = 3. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 3.0); +} + +/// `BEGIN DISTRIBUTED TRANSACTION` is transaction control, not a procedural +/// block (Codex P2, PR #257 round 2). +#[test] +fn begin_distributed_transaction_is_not_a_block() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + begin distributed transaction;\n\ + commit;\n", + ); + assert_eq!(get(&m, "sql.procedural.block_count"), 0.0); +} + +/// A top-level `EXEC('…')` batch is dynamic SQL even when sqruff leaves it +/// wholly unparsable (Codex P1, PR #257 round 2). +#[test] +fn top_level_exec_string_batch_counts_as_dynamic_sql() { + let m = metrics("-- sqlfluff:dialect:tsql\nexec('drop table t');\n"); + assert!(get(&m, "sql.parser.unparsable_segment_count") > 0.0); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + assert!(get(&m, "sql.change_risk_score") >= 5.0); +} + +/// Oracle `CREATE TABLE … REFERENCES parent` / `ALTER TABLE … REFERENCES` +/// mutate only their subject; the referenced table is read, not written +/// (Codex P2, PR #257 round 2). +#[test] +fn referenced_tables_in_ddl_are_reads_not_writes() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create table child (id number, parent_id number references parent(id));\n", + ); + assert_eq!(get(&m, "sql.object.write_count"), 1.0); + assert_eq!(get(&m, "sql.object.read_count"), 1.0); +} + +/// Oracle drop statements name their targets through `OracleFunctionName` / +/// `ObjectReference` — those targets are written objects (Codex P2, PR #257 +/// round 2). +#[test] +fn oracle_drop_targets_are_written_objects() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + drop procedure my_proc;\n\ + drop package my_pkg;\n\ + drop synonym my_syn;\n", + ); + assert_eq!(get(&m, "sql.ddl.drop_count"), 3.0); + assert_eq!(get(&m, "sql.object.write_count"), 3.0); +} + +// ── PR #257 round-3 review regressions ────────────────────────────────── + +/// Query constructs in body continuations (routine bodies sqruff splits +/// into sibling statements) feed the routine's embedded-query score — the +/// T-SQL fixture's UPDATE…WHERE lives in a continuation, so the file +/// maximum must be nonzero (Codex P2, PR #257 round 3). +#[test] +fn continuation_queries_feed_embedded_score() { + let m = metrics(include_str!("fixtures/tsql_procedure_control_flow.sql")); + assert!(get(&m, "sql.structural_complexity.max_embedded_query") > 0.0); +} + +/// Non-table/view CREATE forms inside an executing block count as creates +/// (Codex P2, PR #257 round 3): `IF … THEN CREATE INDEX …` is migration +/// DDL. BigQuery scripting exercises the typed path. +#[test] +fn anonymous_block_create_index_counts() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + execute immediate 'noop';\n\ + end;\n\ + /\n\ + create index ix_orders on orders(batch_id);\n", + ); + // The top-level CREATE INDEX classifies per-statement (create_other) … + assert_eq!(get(&m, "sql.ddl.create_count"), 1.0); + + let block = metrics( + "-- sqlfluff:dialect:bigquery\n\ + if cleanup then\n\ + create index ix on t(c);\n\ + end if;\n", + ); + // … and inside an executing scripting block the typed node counts too. + assert_eq!(get(&block, "sql.ddl.create_count"), 1.0); +} + +// ── PR #257 round-4 review regressions ────────────────────────────────── + +/// A nested subprogram's signature `RETURN ` is not a return +/// statement, even though the enclosing routine's body gate is already open +/// (Codex P2, PR #257 round 4). +#[test] +fn nested_routine_header_return_type_is_not_a_return_statement() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure outer_p is\n\ + function inner_f return number is\n\ + begin\n\ + return 1;\n\ + end inner_f;\n\ + begin\n\ + null;\n\ + end outer_p;\n\ + /\n", + ); + // Only inner_f's actual `return 1;` counts. + assert_eq!(get(&m, "sql.procedural.return_count"), 1.0); +} + +/// Scanner state carries across a routine's split regions: the T-SQL +/// fixture's outer `BEGIN` (first region) and its `IF … BEGIN` body +/// (continuation) are nested, so the depth is 2 (Codex P2, PR #257 +/// round 4). +#[test] +fn carried_state_preserves_block_depth_across_split_regions() { + let m = metrics(include_str!("fixtures/tsql_procedure_control_flow.sql")); + assert_eq!(get(&m, "sql.procedural.max_block_depth"), 2.0); +} + +/// Fragment facts merge before scoring: max-shaped structural terms +/// (expression depth, boolean depth, …) charge once per routine, not once +/// per parser fragment. The MySQL fixture's four IF fragments and two loop +/// fragments each carry expression depth 1 — merged, the routine scores +/// 0.5 (one depth), not 2.5 (five) (Codex P2, PR #257 round 4). +#[test] +fn fragment_facts_merge_before_structural_scoring() { + let m = metrics(include_str!("fixtures/mysql_procedure_control_flow.sql")); + assert_eq!(get(&m, "sql.structural_complexity.max_embedded_query"), 0.5); +} + +// ── PR #257 round-5 review regressions ────────────────────────────────── + +/// A top-level MySQL `PREPARE stmt FROM @sql` parses as an ordinary unknown +/// statement (no `Unparsable` run) — the marker gate must still scan it as +/// dynamic SQL (Codex P1, PR #257 round 5). +#[test] +fn top_level_prepare_counts_as_dynamic_sql() { + let m = metrics("-- sqlfluff:dialect:mysql\nprepare stmt from @sql;\n"); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); + assert!(get(&m, "sql.change_risk_score") >= 5.0); +} + +/// A block-bound IF entering a single-statement ELSE closes at the else +/// statement's terminator — a sibling IF afterwards is not nested under the +/// completed decision (Codex P2, PR #257 round 5). +#[test] +fn single_statement_else_closes_its_if() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0\n\ + begin\n\ + select 1;\n\ + end\n\ + else select 2;\n\ + if @b > 0 select 3;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // IF 1 + ELSE 1 + sibling IF 1 (flat, not nested) = 3. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 3.0); +} + +/// Nested single-statement loops all complete at one terminator — a sibling +/// IF afterwards carries no phantom loop nesting (Codex P2, PR #257 +/// round 5). +#[test] +fn nested_single_statement_loops_close_at_one_terminator() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + while @a > 0 while @b > 0 set @b = @b - 1;\n\ + if @c > 0 select 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.loop_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); + // Outer WHILE 1 + inner WHILE (1+1) + sibling IF 1 (flat) = 4. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 4.0); +} + +/// A bare identifier named `dbms_sql` is not the package — only a qualified +/// call counts (Codex P2, PR #257 round 5). +#[test] +fn bare_dbms_sql_identifier_is_not_dynamic_sql() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + select dbms_sql into v from t;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 0.0); + // The qualified form still counts. + let qualified = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + dbms_sql.parse(c, 'x', 1);\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&qualified, "sql.procedural.dynamic_sql_count"), 1.0); +} + +// ── PR #257 round-6 review regressions ────────────────────────────────── + +/// Under the T-SQL batch model, *every* statement after a routine +/// definition belongs to the routine until `GO` — including ordinary DML +/// that sqruff splits off the body (Codex P1, PR #257 round 6). +#[test] +fn tsql_dml_siblings_stay_procedural_until_go() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p @x int as\n\ + select 1;\n\ + update t set c = 1;\n\ + go\n\ + update standalone set c = 2 where id = 1;\n", + ); + // The in-body UPDATE is not migration DML; the post-GO one is. + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + assert_eq!(get(&m, "sql.dml.update_without_where_count"), 0.0); + assert_eq!(get(&m, "sql.statement.kind_count.update"), 1.0); +} + +/// An Oracle routine followed by plain DML keeps normal semantics — the +/// until-GO continuation is a T-SQL batch rule only. +#[test] +fn oracle_dml_after_routine_stays_independent() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + begin\n\ + null;\n\ + end p;\n\ + /\n\ + update t set c = 1;\n", + ); + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + assert_eq!(get(&m, "sql.dml.update_without_where_count"), 1.0); +} + +/// A single-statement then-body followed by ELSE keeps its IF open at a +/// *contiguous* terminator (the `;`-before-ELSE lookahead). At top level the +/// tsql grammar splits `IF …; ELSE IF …;` into an anonymous statement and an +/// orphan `Unparsable` run, so the regions cannot share nesting state — both +/// IFs and the ELSE still count, flat (Codex P2, PR #257 round 6; the +/// nested-through-ELSE case within one region is covered by +/// `tsql_else_body_keeps_if_nesting`). +#[test] +fn single_statement_then_body_keeps_if_open_for_else() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0 select 1;\n\ + else if @b > 0 select 2;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // IF 1 + ELSE 1 + else-branch IF 2 (nested under @a — T-SQL `ELSE IF` + // is `else { if }`, and the split run resumes the parsed region's open + // IF stack, so the split shape now matches the parsed + // `… END ELSE IF …` shape instead of losing the nesting to the parser + // boundary; PR #257 round 12) = 4. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 4.0); +} + +/// An IF controlling a single-statement loop closes at the shared +/// terminator — the sibling IF afterwards carries no phantom nesting +/// (Codex P2, PR #257 round 6). +#[test] +fn if_over_single_statement_loop_closes_at_terminator() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0 while @b > 0 set @b = 0;\n\ + if @c > 0 select 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // IF 1 + WHILE (1+1) + sibling IF 1 (flat) = 4. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 4.0); +} + +/// A dotted `dbms_sql.` reference without a call is not dynamic SQL — +/// only the qualified *call* shape counts (Codex P2, PR #257 round 6). +#[test] +fn dotted_dbms_sql_reference_without_call_is_not_dynamic_sql() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + select dbms_sql.foo into v from dbms_sql;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 0.0); +} + +/// T-SQL `IF NOT EXISTS (SELECT …)` is a genuine boolean negation; only +/// `IF NOT EXISTS` inside CREATE/DROP statements is a DDL guard (Codex P2, +/// PR #257 round 6). +#[test] +fn procedural_if_not_exists_counts_as_predicate_not() { + let procedural = metrics( + "-- sqlfluff:dialect:tsql\n\ + if not exists (select 1 from t)\n\ + begin\n\ + select 1;\n\ + end\n", + ); + assert_eq!(get(&procedural, "sql.predicate.not_count"), 1.0); + + let guard = metrics("-- sqlfluff:dialect:postgres\nCREATE TABLE IF NOT EXISTS t (id INT)"); + assert_eq!(get(&guard, "sql.predicate.not_count"), 0.0); +} + +/// Oracle package-specification prototypes (`PROCEDURE p;` without a body) +/// are declarations, not routines: no unit, no entry path, no coverage +/// space (Codex P2, PR #257 round 6). +#[test] +fn oracle_spec_prototypes_are_not_routine_units() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace package pkg_spec is\n\ + procedure p(x number);\n\ + function f return number;\n\ + end pkg_spec;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 0.0); + // A package *body* with implementations still yields units. + let body = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace package body pkg_impl is\n\ + function f return number is\n\ + begin\n\ + return 1;\n\ + end f;\n\ + end pkg_impl;\n\ + /\n", + ); + assert_eq!(get(&body, "sql.procedural.routine_count"), 1.0); +} + +// ── PR #257 round-7 review regressions ────────────────────────────────── + +/// An anonymous block that *declares* a nested procedure is still an +/// executing block: its own DML is migration risk, and the nested +/// definition's body stays excluded (Codex P1, PR #257 round 7). +#[test] +fn anonymous_block_with_nested_procedure_still_executes() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + declare\n\ + procedure log_it is\n\ + begin\n\ + insert into audit_log (id) values (1);\n\ + end log_it;\n\ + begin\n\ + update t set c = 0;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.procedural"), 0.0); + // The block's own UPDATE executes on apply… + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + assert_eq!(get(&m, "sql.dml.update_without_where_count"), 1.0); + // …while the nested procedure's INSERT does not. + assert_eq!(get(&m, "sql.dml.insert_count"), 0.0); + // The nested procedure is still a routine unit. + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); +} + +/// A routine's own body block never makes the definition look like an +/// anonymous block (the shape walk skips routine-definition subtrees). +#[test] +fn routine_definition_is_not_an_anonymous_block() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + begin\n\ + null;\n\ + end p;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.statement.kind_count.procedural"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 0.0); +} + +/// A BigQuery routine (`MultiStatementSegment` wrapping `CREATE PROCEDURE` +/// without a top-level `Statement` node) earns exactly one entry — from the +/// unit loop, not an extra anonymous-region entry (Codex P2, PR #257 +/// round 7). +#[test] +fn bigquery_routine_earns_a_single_entry() { + let m = metrics( + "-- sqlfluff:dialect:bigquery\n\ + create procedure ds.p()\n\ + begin\n\ + if x > 0 then\n\ + select 1;\n\ + end if;\n\ + end;\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // Entry 1 + IF 1 = 2 (not 3). + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); +} + +/// PostgreSQL idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS` is a DDL +/// guard, not a boolean negation (Codex P2, PR #257 round 7). +#[test] +fn alter_table_if_not_exists_guard_is_not_a_predicate_not() { + let m = metrics( + "-- sqlfluff:dialect:postgres\n\ + ALTER TABLE t ADD COLUMN IF NOT EXISTS c INT;\n", + ); + assert_eq!(get(&m, "sql.predicate.not_count"), 0.0); +} + +/// T-SQL variable-form `EXEC @sql` executes dynamic SQL; a literal procedure +/// call and return-value capture stay static (Codex P1, PR #257 round 7). +#[test] +fn variable_form_exec_counts_as_dynamic_sql() { + let dynamic = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p as\n\ + begin\n\ + exec @sql;\n\ + end\n", + ); + assert_eq!(get(&dynamic, "sql.procedural.dynamic_sql_count"), 1.0); + + let static_call = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p as\n\ + begin\n\ + exec dbo.other_proc;\n\ + exec @ret = dbo.third_proc;\n\ + end\n", + ); + assert_eq!(get(&static_call, "sql.procedural.dynamic_sql_count"), 0.0); +} + +/// A bare `END` directly followed by a sibling `IF` is not the PL/SQL +/// `END IF` closer — the block closes and the sibling IF counts (Codex P2, +/// PR #257 round 7). +#[test] +fn bare_end_followed_by_sibling_if_keeps_the_if() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0\n\ + begin\n\ + select 1;\n\ + end\n\ + if @b > 0 select 2;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // Both IFs flat: 1 + 1 = 2 cognitive. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 2.0); +} + +// ── PR #257 round-8 review regressions ────────────────────────────────── + +/// BigQuery routine bodies surface as top-level statements (the grammar +/// wraps `CREATE PROCEDURE` in a segment without a `Statement` node), but +/// they run when the routine is *called*: no executing DML, touched +/// objects, or missing-WHERE risk (Codex P1, PR #257 round 8). +#[test] +fn bigquery_routine_body_dml_is_not_migration_risk() { + let m = metrics( + "-- sqlfluff:dialect:bigquery\n\ + create procedure ds.upd()\n\ + begin\n\ + update t set c = 1 where true;\n\ + delete from u;\n\ + end;\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.dml.update_count"), 0.0); + assert_eq!(get(&m, "sql.dml.delete_count"), 0.0); + assert_eq!(get(&m, "sql.dml.delete_without_where_count"), 0.0); + assert_eq!(get(&m, "sql.object.write_count"), 0.0); +} + +/// Variable-form `EXEC @sql` in a standalone T-SQL batch reaches through +/// the unparsable-region marker gate (Codex P1, PR #257 round 8). +#[test] +fn variable_exec_reaches_through_the_region_gate() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + declare @sql nvarchar(max) = N'select 1';\n\ + exec @sql;\n", + ); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 1.0); +} + +/// `EXEC @status = @proc_var` executes a *variable* even though it captures +/// a return value — only a literal procedure name on the right-hand side is +/// a static call (CodeRabbit Major, PR #257 round 8). Covers both the +/// parsed routine-body path and the marker-gated standalone path. +#[test] +fn exec_capture_of_a_variable_is_dynamic() { + let dynamic = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p as\n\ + begin\n\ + exec @status = @proc_var;\n\ + end\n", + ); + assert_eq!(get(&dynamic, "sql.procedural.dynamic_sql_count"), 1.0); + + let standalone = metrics( + "-- sqlfluff:dialect:tsql\n\ + exec @status = @proc_var;\n", + ); + assert_eq!(get(&standalone, "sql.procedural.dynamic_sql_count"), 1.0); + + let static_call = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p as\n\ + begin\n\ + exec @ret = dbo.other_proc;\n\ + end\n", + ); + assert_eq!(get(&static_call, "sql.procedural.dynamic_sql_count"), 0.0); +} + +/// An IF whose single statement is a block-bodied loop closes when the +/// block does: `IF @a > 0 WHILE @b > 0 BEGIN … END` leaves the IF unbound +/// (the BEGIN bound the pending loop), and a sibling IF after the END must +/// not nest under it (Codex P2, PR #257 round 8). +#[test] +fn if_over_block_bodied_loop_closes_at_the_block_end() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0\n\ + while @b > 0\n\ + begin\n\ + set @b = @b - 1;\n\ + end\n\ + if @c > 0 select 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.loop_count"), 1.0); + // IF@a +1, WHILE nested +2, sibling IF@c +1 (flat) = 4, not 5. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 4.0); +} + +/// The scalar conditional *function* `IF(expr, a, b)` inside an unparsable +/// MySQL routine body is not control flow: its argument commas at paren +/// depth 1 distinguish it from a parenthesized control condition +/// (Codex P2, PR #257 round 8). +#[test] +fn scalar_if_call_in_unparsable_run_is_not_a_branch() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + delimiter //\n\ + create procedure set_flag(in flag int, out result int)\n\ + begin\n\ + set result = if(flag > 0, 1, 0);\n\ + if result = 1 then\n\ + update t set c = 1 where id = 1;\n\ + end if;\n\ + end //\n", + ); + // Only the procedural IF…THEN counts — the scalar IF() does not. + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); +} + +/// Oracle call-spec routines (`AS LANGUAGE JAVA …`) have no +/// `BEGIN…END` block but are executable definitions — they stay routine +/// units; only bodyless declarations inside a package/type *specification* +/// are prototypes (Codex P2, PR #257 round 8). +#[test] +fn oracle_call_spec_routine_is_still_a_unit() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace function get_balance (acct_id in number) return number\n\ + as language java\n\ + name 'Bank.getBalance(int) return float';\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); +} + +// ── PR #257 round-9 review regressions ────────────────────────────────── + +/// A bare `END` closing a T-SQL block-bound loop directly followed by a +/// sibling `WHILE` is not the compound `END WHILE` closer — the sibling +/// loop counts (Codex P2, PR #257 round 9). +#[test] +fn bare_end_followed_by_sibling_while_keeps_the_loop() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + while @a > 0\n\ + begin\n\ + set @a = @a - 1;\n\ + end\n\ + while @b > 0 set @b = @b - 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.loop_count"), 2.0); + // Two sibling loops, both flat: 1 + 1. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 2.0); +} + +/// MySQL row-constructor conditions carry depth-1 commas — +/// `IF (a, b) = (1, 2) THEN` is control flow, not the scalar `IF()` +/// function: the statement shape (its own depth-0 `THEN`) decides +/// (Codex P2, PR #257 round 9). A scalar call in condition position +/// (`IF IF(x, 1, 0) = 1 THEN`) still stays an operand. +#[test] +fn row_comparison_if_condition_is_control_flow() { + let row = metrics( + "-- sqlfluff:dialect:mysql\n\ + delimiter //\n\ + create procedure check_pair(in a int, in b int)\n\ + begin\n\ + if (a, b) = (1, 2) then\n\ + update t set c = 1 where id = a;\n\ + end if;\n\ + end //\n", + ); + assert_eq!(get(&row, "sql.procedural.if_count"), 1.0); + + let operand = metrics( + "-- sqlfluff:dialect:mysql\n\ + delimiter //\n\ + create procedure check_flag(in x int)\n\ + begin\n\ + if if(x > 0, 1, 0) = 1 then\n\ + update t set c = 1 where id = x;\n\ + end if;\n\ + end //\n", + ); + // The outer control IF counts once; the scalar IF() operand does not. + assert_eq!(get(&operand, "sql.procedural.if_count"), 1.0); +} + +/// A nested subprogram declared under an outer routine's control flow gets +/// a fresh cognitive-nesting baseline: its decisions don't inherit the +/// caller's lexical depth (Codex P2, PR #257 round 9). File cognitive here +/// is 2 (outer IF 1 + inner IF 1), not 3. +#[test] +fn nested_routine_resets_cognitive_nesting() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure outer_p is\n\ + begin\n\ + if 1 = 1 then\n\ + declare\n\ + procedure inner_p is\n\ + begin\n\ + if 2 = 2 then\n\ + null;\n\ + end if;\n\ + end inner_p;\n\ + begin\n\ + inner_p;\n\ + end;\n\ + end if;\n\ + end outer_p;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 2.0); +} + +// ── PR #257 round-10 review regressions ───────────────────────────────── + +/// A forward declaration (`PROCEDURE helper;`) never opens a body: its +/// pending routine-body marker retires at the terminator, so a later +/// ordinary nested `BEGIN` is not mislabeled a routine body and the outer +/// decision's nesting still applies (Codex P2, PR #257 round 10). +#[test] +fn forward_declaration_does_not_leak_a_body_marker() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + declare\n\ + procedure helper;\n\ + procedure helper is begin null; end;\n\ + begin\n\ + if 1 = 1 then\n\ + begin\n\ + if 2 = 2 then\n\ + null;\n\ + end if;\n\ + end;\n\ + end if;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // Outer IF 1 + inner IF (nested under the outer decision) 2 = 3 — the + // plain block under the IF is not a routine-body nesting baseline. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 3.0); +} + +/// BigQuery's bare scripting `BEGIN;`/`END;` statements are a block, not +/// transaction control: no TCL risk, one anonymous entry, one block, and +/// the DML between them executes on apply (Codex P1, PR #257 round 10). +#[test] +fn bigquery_bare_begin_is_a_scripting_block() { + let m = metrics( + "-- sqlfluff:dialect:bigquery\n\ + begin\n\ + update t set c = 1 where true;\n\ + end;\n", + ); + assert_eq!(get(&m, "sql.statement.kind_count.anonymous_block"), 1.0); + assert_eq!(get(&m, "sql.statement.kind_count.transaction_control"), 0.0); + assert_eq!(get(&m, "sql.transaction.control_count"), 0.0); + assert_eq!(get(&m, "sql.procedural.block_count"), 1.0); + // Entry only: 1. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 1.0); + // Top-level scripting DML executes on apply. + assert_eq!(get(&m, "sql.dml.update_count"), 1.0); + // The `END;` closer is a syntactic bracket, not a statement: it joins + // no statement count, no `unknown` kind, and no entropy (Codex P2, + // round 11). Two statements: the block opener and the UPDATE. + assert_eq!(get(&m, "sql.statement.count"), 2.0); + assert_eq!(get(&m, "sql.statement.kind_count.unknown"), 0.0); + assert_eq!(get(&m, "sql.statement.unparsed_count"), 0.0); +} + +/// Boolean operators inside a routine *signature* (a package-spec prototype +/// default such as `flag BOOLEAN := TRUE AND FALSE`) are declaration, not +/// control flow (Codex P2, PR #257 round 10). +#[test] +fn signature_boolean_defaults_are_not_paths() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace package pkg_spec is\n\ + procedure p(flag boolean := true and false);\n\ + end pkg_spec;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 0.0); + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 0.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// Procedural assignment expressions embed no query: a routine whose body +/// is only `x := ((1 + 2) * 3);` publishes zero embedded-query structural +/// complexity (Codex P2, PR #257 round 10). +#[test] +fn expression_only_routine_embeds_no_query() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure calc is\n\ + x number;\n\ + begin\n\ + x := ((1 + 2) * 3);\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.structural_complexity.max_embedded_query"), 0.0); +} + +/// A standalone block-shaped `Unparsable` run (T-SQL `BEGIN TRY … END +/// CATCH` at file level) earns one anonymous entry, like its +/// statement-backed equivalent (Codex P2, PR #257 round 10). +#[test] +fn standalone_try_catch_run_earns_an_entry() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + begin try\n\ + select 1;\n\ + end try\n\ + begin catch\n\ + throw;\n\ + end catch\n", + ); + // Entry 1 + catch handler 1 + throw 1 = 3. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 3.0); + assert_eq!(get(&m, "sql.procedural.exception_handler_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); +} + +// ── PR #257 round-11 review regressions ───────────────────────────────── + +/// A T-SQL control bound at `BEGIN TRY` closes at `END CATCH`, not at the +/// first terminator inside the try body — the catch handler keeps the +/// decision's nesting penalty and a sibling decision stays flat (Codex P2, +/// PR #257 round 11). +#[test] +fn try_catch_binds_its_controlling_if() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0\n\ + begin try\n\ + select 1;\n\ + end try\n\ + begin catch\n\ + select 2;\n\ + end catch\n\ + if @b > 0 select 3;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.exception_handler_count"), 1.0); + // IF@a 1 + catch under it 2 + sibling IF@b 1 = 4 (not 3: the `;` inside + // the try body must not close the bound IF). + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 4.0); +} + +/// A `GO` separator severs fallback attribution: a standalone control block +/// in the next batch keeps its own anonymous entry and does not attach to +/// the routine defined before the separator (Codex P2, PR #257 round 11). +#[test] +fn go_boundary_severs_fallback_attribution() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.p as\n\ + begin\n\ + select 1;\n\ + end\n\ + go\n\ + begin try\n\ + select 2;\n\ + end try\n\ + begin catch\n\ + throw;\n\ + end catch\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // Routine entry 1 + standalone run: entry 1 + catch 1 + throw 1 = 4. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 4.0); +} + +// ── PR #257 round-12 review regressions ───────────────────────────────── + +/// A whole T-SQL decision lost to a root `Unparsable` run still counts: +/// leading control-position `IF` passes the marker gate (Codex P2, PR #257 +/// round 12), while `end if` debris (`if;`) and scalar `IF(…)` stay out. +#[test] +fn if_led_unparsable_run_is_admitted() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a = 1 throw 51000, 'oops', 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); + // Entry 1 + IF 1 + THROW 1 = 3. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 3.0); +} + +/// An `ELSE`-led spill resumes the anonymous region's open IF stack: the +/// outer else's decision keeps one nesting level, and a deeper IF whose +/// else already completed closes at the terminator so the second ELSE +/// binds the outer IF (Codex P2, PR #257 round 12). +#[test] +fn else_spill_resumes_anonymous_state() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + if @a > 0 if @b > 0 select 1; else select 2; else if @c > 0 select 3;\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 3.0); + // IF@a 1 + IF@b 2 + ELSE 1 + ELSE 1 + IF@c 2 (under @a's else) = 7. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 7.0); +} + +/// An Oracle routine followed by an independent anonymous block that +/// degrades to a root `Unparsable` run: the block keeps its own entry and +/// paths — fallback attribution is reserved for the dialects whose +/// grammars actually spill routine bodies into root runs (Codex P2, +/// PR #257 round 12). +#[test] +fn oracle_block_after_routine_stays_independent() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + begin\n\ + null;\n\ + end;\n\ + /\n\ + begin\n\ + case when 1 = 1 then null; end case;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // p's entry 1 + the block's entry 1 + CASE WHEN 1 = 3, with the block's + // paths staying file-level instead of attaching to p. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 3.0); +} + +/// A DECLARE-led anonymous block starts outside the body gate: declaration +/// initializers (`flag BOOLEAN := TRUE AND FALSE`) are not paths — the +/// block's `BEGIN` opens the body (Codex P2, PR #257 round 12). +#[test] +fn anonymous_declare_section_is_not_a_path() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + declare\n\ + flag boolean := true and false;\n\ + begin\n\ + null;\n\ + end;\n\ + /\n", + ); + // Entry only — the initializer's AND counts nothing. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 1.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +// ── PR #257 round-13 review regressions ───────────────────────────────── + +/// A package specification's IS introduces declarations, not an executable +/// body: package-level initializers (`flag CONSTANT BOOLEAN := TRUE AND +/// FALSE`) create no paths (Codex P2, PR #257 round 13). +#[test] +fn package_level_declarations_are_not_paths() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace package pkg_consts is\n\ + flag constant boolean := true and false;\n\ + end pkg_consts;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 0.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// `WHERE NOT NULL` is a genuine unary negation — the NOT-NULL suppression +/// requires column-definition/constraint ancestry (Codex P2, PR #257 +/// round 13). +#[test] +fn where_not_null_is_a_predicate_negation() { + let predicate = metrics("SELECT * FROM t WHERE NOT NULL;\n"); + assert_eq!(get(&predicate, "sql.predicate.not_count"), 1.0); + + let constraint = metrics("CREATE TABLE t (id INT NOT NULL);\n"); + assert_eq!(get(&constraint, "sql.predicate.not_count"), 0.0); +} + +/// Nested bare BigQuery scripting blocks nest even though sqruff emits the +/// brackets as sibling statements: the depth walk over the bracket stream +/// reports the true maximum (Codex P2, PR #257 round 13). +#[test] +fn bigquery_nested_bare_blocks_report_their_depth() { + let m = metrics( + "-- sqlfluff:dialect:bigquery\n\ + begin\n\ + begin\n\ + select 1;\n\ + end;\n\ + end;\n", + ); + assert_eq!(get(&m, "sql.procedural.block_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.max_block_depth"), 2.0); +} + +// ── PR #257 round-14 review regressions ───────────────────────────────── + +/// A nested routine's END restores the body gate saved at its header: +/// declarations *after* a local routine stay declarations, so their +/// initializers create no paths (Codex P2, PR #257 round 14). +#[test] +fn declarations_after_a_nested_routine_stay_declarations() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + declare\n\ + procedure p is\n\ + begin\n\ + null;\n\ + end;\n\ + flag boolean := true and false;\n\ + begin\n\ + null;\n\ + end;\n\ + /\n", + ); + // Anonymous entry 1 + p's entry 1 — the initializer's AND is no path. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// Oracle editioning modifiers before a package header still arm the +/// spec gate: `CREATE OR REPLACE EDITIONABLE PACKAGE … AS` introduces +/// declarations, not a body (Codex P2, PR #257 round 14). +#[test] +fn editionable_package_spec_declarations_are_not_paths() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace editionable package pkg_ed as\n\ + flag constant boolean := true and false;\n\ + end pkg_ed;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 0.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// T-SQL Service Broker `BEGIN CONVERSATION …` opens no block scope: no +/// block, no anonymous entry (Codex P2, PR #257 round 14). +#[test] +fn begin_conversation_is_not_a_block() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + begin conversation timer (@handle) timeout = 60;\n", + ); + assert_eq!(get(&m, "sql.procedural.block_count"), 0.0); + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 0.0); +} + +/// A named exception *declaration* (`some_error EXCEPTION;`) is not a +/// handler section: no synthetic block seeds, so the real BEGIN reports +/// depth 1 (Codex P2, PR #257 round 14). +#[test] +fn exception_declaration_is_not_a_handler_section() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + declare\n\ + some_error exception;\n\ + begin\n\ + raise some_error;\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.max_block_depth"), 1.0); + assert_eq!(get(&m, "sql.procedural.exception_handler_count"), 0.0); + // Entry 1 + RAISE 1. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); +} + +// ── PR #257 round-15 review regressions ───────────────────────────────── + +/// A routine's IS/AS introduces its declaration section — initializers and +/// cursor-query predicates there are not executable control flow; the body +/// opens at the routine's BEGIN (Codex P2, PR #257 round 15). +#[test] +fn routine_declaration_sections_are_not_paths() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + flag boolean := true and false;\n\ + cursor c is select * from t where a = 1 and b = 2;\n\ + begin\n\ + null;\n\ + end;\n\ + /\n", + ); + // Entry only — no declaration boolean counts. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 1.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// A standalone T-SQL raise batch lost to a root `Unparsable` run is +/// measured like the same tokens inside a parsed block (Codex P2, PR #257 +/// round 15). +#[test] +fn standalone_raise_batches_are_measured() { + let throw = metrics( + "-- sqlfluff:dialect:tsql\n\ + throw 51000, 'oops', 1;\n", + ); + assert_eq!(get(&throw, "sql.procedural.raise_throw_count"), 1.0); + assert_eq!(get(&throw, "sql.procedural.cyclomatic_complexity"), 1.0); +} + +/// The embedded-query metric is the worst *individual* query: two trivial +/// SELECTs score like one, not like their sum (Codex P2, PR #257 +/// round 15). +#[test] +fn embedded_query_score_is_a_maximum_not_a_sum() { + let one = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p_one is\n\ + v number;\n\ + begin\n\ + select 1 into v from dual;\n\ + end;\n\ + /\n", + ); + let two = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p_two is\n\ + v number;\n\ + begin\n\ + select 1 into v from dual;\n\ + select 2 into v from dual;\n\ + end;\n\ + /\n", + ); + let single = get(&one, "sql.structural_complexity.max_embedded_query"); + assert!(single > 0.0); + assert_eq!( + get(&two, "sql.structural_complexity.max_embedded_query"), + single + ); +} + +// ── PR #257 round-16 review regressions ───────────────────────────────── + +/// T-SQL definitions that sqruff 0.40 leaves wholly unparsable (`CREATE +/// FUNCTION … RETURNS int AS BEGIN … END`, `CREATE TRIGGER … AS SELECT`) +/// still become routine units — recovered from the leading header token +/// shape of the root run (Codex P1, PR #257 round 16). +#[test] +fn tsql_function_and_trigger_units_recover_from_parse_gaps() { + let function = metrics( + "-- sqlfluff:dialect:tsql\n\ + create function dbo.f(@x int) returns int\n\ + as\n\ + begin\n\ + return @x;\n\ + end\n", + ); + assert_eq!(get(&function, "sql.procedural.routine_count"), 1.0); + // Entry + nothing else (the RETURN is a count, not a path). + assert_eq!(get(&function, "sql.procedural.cyclomatic_complexity"), 1.0); + assert_eq!(get(&function, "sql.procedural.return_count"), 1.0); + + let trigger = metrics( + "-- sqlfluff:dialect:tsql\n\ + create trigger trg on t for insert\n\ + as select 1;\n", + ); + assert_eq!(get(&trigger, "sql.procedural.routine_count"), 1.0); +} + +/// T-SQL `AS` opening the body retires the pending routine-body marker: a +/// later `BEGIN` inside the body belongs to its control flow, not to the +/// routine baseline — nested decisions keep their nesting (Codex P2, +/// PR #257 round 16). +#[test] +fn tsql_as_body_does_not_leak_a_marker_to_inner_blocks() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure p as\n\ + if @a = 1\n\ + begin\n\ + if @b = 1 select 1;\n\ + end\n", + ); + assert_eq!(get(&m, "sql.procedural.if_count"), 2.0); + // Entry 1 is cyclomatic; cognitive: IF@a 1 + IF@b 2 (nested) = 3. + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 3.0); +} + +/// Nested bare BigQuery brackets are lexical blocks, not new entries: one +/// connected scripting region earns one entry (Codex P2, PR #257 +/// round 16). +#[test] +fn nested_bigquery_bracket_is_not_a_second_entry() { + let m = metrics( + "-- sqlfluff:dialect:bigquery\n\ + begin\n\ + begin\n\ + select 1;\n\ + end;\n\ + end;\n", + ); + assert_eq!(get(&m, "sql.procedural.block_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.max_block_depth"), 2.0); + // One entry for the connected region. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 1.0); +} + +/// An Oracle nested DECLARE section closes the body gate until its BEGIN: +/// mid-body declaration initializers create no paths (Codex P2, PR #257 +/// round 16). +#[test] +fn nested_declare_section_closes_the_body_gate() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + begin\n\ + declare\n\ + flag boolean := true and false;\n\ + begin\n\ + null;\n\ + end;\n\ + end;\n\ + /\n", + ); + // Entry only — the nested declaration's AND is no path. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 1.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// A user function named `signal` is a call, not a raise statement — the +/// raise family requires real keyword shape; Oracle's +/// `RAISE_APPLICATION_ERROR` stays the deliberate call-shaped exception +/// (Codex P2, PR #257 round 16). +#[test] +fn signal_named_function_call_is_not_a_raise() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + begin\n\ + signal(1);\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 0.0); + + let real = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + begin\n\ + raise_application_error(-20001, 'boom');\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&real, "sql.procedural.raise_throw_count"), 1.0); +} + +// ── PR #257 round-17 review regressions ───────────────────────────────── + +/// Standalone `ALTER FUNCTION|TRIGGER … AS …` definitions recover as +/// routine units like their CREATE counterparts (Codex P1, PR #257 +/// round 17). +#[test] +fn tsql_alter_definitions_recover_as_units() { + let function = metrics( + "-- sqlfluff:dialect:tsql\n\ + alter function dbo.f(@x int) returns int\n\ + as\n\ + begin\n\ + return @x;\n\ + end\n", + ); + assert_eq!(get(&function, "sql.procedural.routine_count"), 1.0); + + let trigger = metrics( + "-- sqlfluff:dialect:tsql\n\ + alter trigger trg on t after insert\n\ + as select 1;\n", + ); + assert_eq!(get(&trigger, "sql.procedural.routine_count"), 1.0); +} + +/// A recovered definition's body is scanned without needing a spill +/// marker: a no-BEGIN trigger body's IF and RAISERROR count (Codex P1, +/// PR #257 round 17). +#[test] +fn recovered_definition_body_scans_without_spill_markers() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create trigger trg on t after insert\n\ + as if exists (select 1 from inserted where qty < 0)\n\ + raiserror ('negative qty', 16, 1);\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.if_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); + // Entry 1 + IF 1 + RAISERROR 1. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 3.0); +} + +/// A standalone `GOTO label;` batch is admitted to the scanner and charges +/// its cognitive penalty (Codex P2, PR #257 round 17). +#[test] +fn standalone_goto_batch_is_measured() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + goto done;\n", + ); + assert!(get(&m, "sql.procedural.cognitive_complexity") >= 1.0); +} + +/// MySQL single-statement routine bodies (`CREATE PROCEDURE p() SIGNAL …`) +/// recover as units with their body measured (Codex P1, PR #257 +/// round 17). +#[test] +fn mysql_single_statement_routine_recovers_as_a_unit() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create procedure p() signal sqlstate '45000';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); + // Entry 1 + SIGNAL 1. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); +} + +// ── PR #257 round-18 review regressions ───────────────────────────────── + +/// Two separately recovered MySQL definitions stay independent: the second +/// run never attaches to the first routine, so their spaces don't overlap +/// and each keeps its own entry (Codex P2, PR #257 round 18). A parsed +/// statement between them keeps the parse gaps distinct — adjacent +/// unparsable definitions merge into one run, which recovers only the +/// leading header (a documented limit of run-granularity recovery). +#[test] +fn recovered_definitions_stay_independent() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create procedure p1() signal sqlstate '45000';\n\ + select 1;\n\ + create procedure p2() signal sqlstate '45001';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 2.0); + // Two entries + two SIGNALs. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 4.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 2.0); +} + +/// A recovered MySQL single-statement function counts its body-level +/// RETURN: the signature ends at the parameter list's close (Codex P2, +/// PR #257 round 18). +#[test] +fn mysql_single_statement_function_counts_its_return() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create function f(x int) returns int return x + 1;\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.return_count"), 1.0); +} + +/// Backtick-quoted MySQL routine names recover like plain ones (Codex P2, +/// PR #257 round 18). +#[test] +fn backtick_quoted_recovered_name_is_accepted() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create procedure `p`() signal sqlstate '45000';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); +} + +// ── PR #257 round-19 review regressions ───────────────────────────────── + +/// A standard MySQL `DELIMITER //` script recovers its definitions: the +/// custom delimiter is a statement boundary before each header, including +/// the very first one (Codex P1, PR #257 round 19). +#[test] +fn mysql_delimiter_script_recovers_definitions() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + delimiter //\n\ + create procedure p1()\n\ + begin\n\ + signal sqlstate '45000';\n\ + end//\n\ + create procedure p2()\n\ + begin\n\ + signal sqlstate '45001';\n\ + end//\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 2.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 2.0); +} + +/// A MySQL `DEFINER = user@host` account expression spans several tokens — +/// the header recognizer consumes it whole (Codex P1, PR #257 round 19). +#[test] +fn mysql_definer_account_expression_is_consumed() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create definer = `root`@`localhost` procedure p() signal sqlstate '45000';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); +} + +/// A recovered definition's header tokens stay outside the body gate: +/// `CREATE OR REPLACE …` counts no boolean for its `OR` (Codex P2, PR #257 +/// round 19). +#[test] +fn recovered_header_or_is_not_a_boolean_path() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create or replace procedure p() signal sqlstate '45000';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // Entry 1 + SIGNAL 1 — no boolean increment from the header's OR. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// MySQL `ALTER PROCEDURE p COMMENT …` alters metadata, not a body: no +/// routine recovers from it (Codex P2, PR #257 round 19). +#[test] +fn mysql_alter_procedure_metadata_is_not_a_definition() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + alter procedure p comment 'new comment';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 0.0); +} + +/// An Oracle package body lost to a parse gap recovers its member +/// routines; the initialization section stays file-level (Codex P1, +/// PR #257 round 19). +#[test] +fn oracle_package_body_parse_gap_recovers_members() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create package body pkg as\n\ + procedure p is\n\ + begin\n\ + null;\n\ + end p;\n\ + begin\n\ + null;\n\ + end pkg;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // The member's entry only — NULL bodies add no paths. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 1.0); +} + +// ── PR #257 round-20 review regressions ───────────────────────────────── + +/// MySQL parameter types with their own parens (`DECIMAL(10,2)`) don't end +/// the signature early — the body gate opens at the *outer* parameter +/// list's close, so a default expression's AND stays declaration syntax +/// (Codex P2, PR #257 round 20). +#[test] +fn mysql_nested_parameter_parens_stay_in_the_signature() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create procedure p(x decimal(10,2), flag boolean default true and false)\n\ + signal sqlstate '45000';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + // Entry 1 + SIGNAL 1 — the default's AND is no path. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 2.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// `dbms_sql` outside Oracle is an ordinary schema name, not the dynamic +/// SQL package (Codex P2, PR #257 round 20). +#[test] +fn dbms_sql_qualifier_requires_the_oracle_dialect() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure p as\n\ + begin\n\ + select dbms_sql.foo();\n\ + end\n", + ); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 0.0); +} + +// ── PR #257 round-21 review regressions ───────────────────────────────── + +/// A declared `DELIMITER %%` (arbitrary token, no punctuation whitelist) +/// is a recovery boundary (Codex P1, PR #257 round 21). +#[test] +fn declared_delimiter_is_a_recovery_boundary() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + delimiter %%\n\ + create procedure p()\n\ + begin\n\ + signal sqlstate '45000';\n\ + end%%\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 1.0); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 1.0); +} + +/// The body gate resets at every recovered definition in a shared run: the +/// second header's `OR` counts nothing (Codex P2, PR #257 round 21). +#[test] +fn body_gate_resets_between_recovered_definitions() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create procedure p() signal sqlstate '45000';\n\ + select 1;\n\ + create or replace procedure q() signal sqlstate '45001';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 2.0); + // Two entries + two SIGNALs — no boolean from q's header. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 4.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// `sp_executesql` outside T-SQL is an ordinary function name (Codex P2, +/// PR #257 round 21). +#[test] +fn sp_executesql_requires_the_tsql_dialect() { + let m = metrics( + "-- sqlfluff:dialect:oracle\n\ + create or replace procedure p is\n\ + begin\n\ + sp_executesql('value');\n\ + end;\n\ + /\n", + ); + assert_eq!(get(&m, "sql.procedural.dynamic_sql_count"), 0.0); +} + +// ── PR #257 round-22 review regressions ───────────────────────────────── + +/// A call-shaped `raise_application_error(…)` outside Oracle is an +/// ordinary UDF (Codex P2, PR #257 round 22). +#[test] +fn raise_application_error_requires_the_oracle_dialect() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure p as\n\ + begin\n\ + select dbo.raise_application_error(1);\n\ + end\n", + ); + assert_eq!(get(&m, "sql.procedural.raise_throw_count"), 0.0); +} + +/// A call-shaped `loop(…)` is a UDF, not a loop keyword (Codex P2, PR #257 +/// round 22). +#[test] +fn loop_named_function_call_is_not_a_loop() { + let m = metrics( + "-- sqlfluff:dialect:tsql\n\ + create procedure p as\n\ + begin\n\ + select dbo.loop(1);\n\ + end\n", + ); + assert_eq!(get(&m, "sql.procedural.loop_count"), 0.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} + +/// A recovered MySQL unit ends at its own terminator: statements between +/// definitions in a shared run stay outside every unit and outside the +/// body gate (Codex P2, PR #257 round 22). +#[test] +fn intervening_statements_stay_outside_recovered_units() { + let m = metrics( + "-- sqlfluff:dialect:mysql\n\ + create procedure p() signal sqlstate '45000';\n\ + select true and false;\n\ + create procedure q() signal sqlstate '45001';\n", + ); + assert_eq!(get(&m, "sql.procedural.routine_count"), 2.0); + // Two entries + two SIGNALs; the SELECT's AND is not procedural body. + assert_eq!(get(&m, "sql.procedural.cyclomatic_complexity"), 4.0); + assert_eq!(get(&m, "sql.procedural.cognitive_complexity"), 0.0); +} diff --git a/crates/mehen-sql/tests/procedural_units.rs b/crates/mehen-sql/tests/procedural_units.rs index a532615f0..88e077726 100644 --- a/crates/mehen-sql/tests/procedural_units.rs +++ b/crates/mehen-sql/tests/procedural_units.rs @@ -167,3 +167,351 @@ fn space_ids_are_unique_across_statements_and_units() { sorted.dedup(); assert_eq!(sorted.len(), ids.len(), "duplicate SpaceId: {ids:?}"); } + +/// Per-routine procedural composites land on the unit's Function space +/// (Phase 3): the file-level aggregates attribute to the innermost unit +/// containing each increment, plus the unit's own entry path. +#[test] +fn function_spaces_carry_per_unit_procedural_metrics() { + let analysis = analyze(include_str!("fixtures/plsql_procedure_control_flow.sql")); + let statement = &analysis.root.spaces[0]; + let unit = &statement.spaces[0]; + assert_eq!(unit.kind, SpaceKind::Function); + let get = |key: &str| { + unit.metrics + .get(&MetricKey::new(key)) + .map(|v| v.as_f64()) + .unwrap_or_else(|| panic!("missing unit metric {key}")) + }; + // The single routine owns every increment (hand trace in + // tests/metrics.rs::plsql_procedural_family_counts). + assert_eq!(get("sql.procedural.cyclomatic_complexity"), 12.0); + assert_eq!(get("sql.procedural.cognitive_complexity"), 9.0); + // The embedded UPDATE…WHERE gives a small query-structural score, and the + // file-level max is exactly this unit's score. + let embedded = get("sql.structural_complexity"); + assert!(embedded > 0.0); + assert_eq!( + analysis + .root + .metrics + .get(&MetricKey::new( + "sql.structural_complexity.max_embedded_query" + )) + .map(|v| v.as_f64()), + Some(embedded) + ); +} + +/// Subprograms nested in a container split the attribution: each unit gets +/// its own entry, and increments land in the *innermost* enclosing unit. +#[test] +fn nested_subprogram_attribution_is_innermost() { + let sql = "-- sqlfluff:dialect:oracle\n\ + create or replace function outer_fn return number is\n\ + v number;\n\ + function inner_fn return number is\n\ + begin\n\ + if v > 0 then\n\ + return 2;\n\ + end if;\n\ + return 3;\n\ + end inner_fn;\n\ + begin\n\ + v := inner_fn();\n\ + return v;\n\ + end outer_fn;\n\ + /\n"; + let analysis = analyze(sql); + let statement = &analysis.root.spaces[0]; + let outer = &statement.spaces[0]; + let inner = &outer.spaces[0]; + let get = |space: &mehen_core::MetricSpace, key: &str| { + space + .metrics + .get(&MetricKey::new(key)) + .map(|v| v.as_f64()) + .unwrap_or_else(|| panic!("missing {key}")) + }; + // Inner: entry 1 + IF 1 = 2. Its RETURN statements and the IF belong to + // it, not to outer_fn. + assert_eq!(get(inner, "sql.procedural.cyclomatic_complexity"), 2.0); + // Outer: its own entry only (the machine increments inside inner_fn's + // byte range attribute to inner_fn). + assert_eq!(get(outer, "sql.procedural.cyclomatic_complexity"), 1.0); + // File-level = 3 = both entries + the IF. + assert_eq!( + analysis + .root + .metrics + .get(&MetricKey::new("sql.procedural.cyclomatic_complexity")) + .map(|v| v.as_f64()), + Some(3.0) + ); +} + +/// Body increments that sqruff spills outside the routine's parsed range +/// (split sibling statements, top-level `Unparsable` runs) attribute to the +/// routine they continue — the T-SQL fixture's function space carries the +/// file's whole cyclomatic score, not just its entry (Codex P2, PR #257 +/// round 2). +#[test] +fn tsql_spilled_body_attributes_to_its_routine() { + let analysis = analyze(include_str!("fixtures/tsql_procedure_control_flow.sql")); + // The routine's span extends through its spilled body regions (PR #257 + // round 8), outgrowing the header fragment's statement space — the + // Function space sits at root level with the full-body span. + let unit = analysis + .root + .spaces + .iter() + .find(|s| s.kind == SpaceKind::Function) + .expect("routine Function space"); + assert_eq!(unit.name.as_deref(), Some("dbo.process_orders")); + assert!(unit.span.end_line >= 31, "span covers the spilled body"); + let unit_cyclo = unit + .metrics + .get(&MetricKey::new("sql.procedural.cyclomatic_complexity")) + .map(|v| v.as_f64()) + .expect("unit cyclomatic"); + let file_cyclo = analysis + .root + .metrics + .get(&MetricKey::new("sql.procedural.cyclomatic_complexity")) + .map(|v| v.as_f64()) + .expect("file cyclomatic"); + // Everything in the file belongs to the single routine. + assert_eq!(unit_cyclo, file_cyclo); + assert_eq!(unit_cyclo, 7.0); +} + +/// A T-SQL routine whose body sqruff splits into sibling statements keeps a +/// `Function` space covering the *whole* body: continuation regions +/// attributed to the routine extend its span past the header fragment the +/// parser kept inside the definition node (Codex P1, PR #257 round 8). +/// When the extended span outgrows its host statement space the Function +/// space surfaces at the root instead — a full-scope space beats a nested +/// but truncated one for location-based consumers. +#[test] +fn tsql_split_body_extends_the_function_space() { + let sql = "-- sqlfluff:dialect:tsql\n\ + create procedure dbo.split_me @x int as\n\ + begin\n\ + declare @c int = 0;\n\ + \n\ + if @x > 0\n\ + begin\n\ + set @c = 1;\n\ + end\n\ + end\n"; + let spaces = tree(sql); + let function = spaces + .iter() + .find(|(_, kind, _, _, _)| kind == "function") + .expect("routine yields a Function space"); + assert_eq!(function.2.as_deref(), Some("dbo.split_me")); + // Header fragment ends at line 4; the body continuation runs to the + // trailing END. The space must cover the continuation. + assert!( + function.4 >= 9, + "Function space ends at line {}, expected the full body", + function.4 + ); +} + +/// Embedded-query scores follow innermost ownership: a nested subprogram's +/// query belongs to the nested unit alone, so an outer routine with no +/// query of its own scores 0 and cannot outrank its child (Codex P2, +/// PR #257 round 9). +#[test] +fn nested_routine_owns_its_embedded_queries() { + let sql = "-- sqlfluff:dialect:oracle\n\ + create or replace procedure outer_p is\n\ + \x20 function inner_f return number is\n\ + \x20 v number;\n\ + \x20 begin\n\ + \x20 select count(*) into v from orders o join lines l on l.oid = o.id;\n\ + \x20 return v;\n\ + \x20 end inner_f;\n\ + begin\n\ + \x20 null;\n\ + end outer_p;\n\ + /\n"; + let analysis = analyze(sql); + let get = |space: &mehen_core::MetricSpace, key: &str| { + space + .metrics + .get(&MetricKey::new(key)) + .map(|v| v.as_f64()) + .unwrap_or_else(|| panic!("missing {key}")) + }; + let statement = &analysis.root.spaces[0]; + let outer = &statement.spaces[0]; + assert_eq!(outer.name.as_deref(), Some("outer_p")); + let inner = &outer.spaces[0]; + assert_eq!(inner.name.as_deref(), Some("inner_f")); + // The join-bearing SELECT scores on the inner unit… + assert!(get(inner, "sql.structural_complexity") > 0.0); + // …and not on the outer routine, which has no query of its own. + assert_eq!(get(outer, "sql.structural_complexity"), 0.0); +} + +/// A recovered package-body member ending with the common unnamed `END;` +/// still gets a bounded span: the initialization section's control flow +/// stays file-level instead of attributing to the last member (Codex P2, +/// PR #257 round 20). +#[test] +fn unnamed_end_bounds_a_recovered_member() { + let sql = "-- sqlfluff:dialect:oracle\n\ + create package body pkg as\n\ + \x20 procedure p is\n\ + \x20 begin\n\ + \x20 null;\n\ + \x20 end;\n\ + begin\n\ + \x20 if 1 = 1 then\n\ + \x20 null;\n\ + \x20 end if;\n\ + end pkg;\n\ + /\n"; + let analysis = analyze(sql); + let member = analysis + .root + .spaces + .iter() + .flat_map(|s| std::iter::once(s).chain(s.spaces.iter())) + .find(|s| s.kind == SpaceKind::Function) + .expect("member Function space"); + assert_eq!(member.name.as_deref(), Some("p")); + // The member ends at its own END; — before the init section's BEGIN. + assert!(member.span.end_line <= 6, "member ends at line 6"); + // Its entry only: the init section's IF is file-level. + let cyclo = member + .metrics + .get(&MetricKey::new("sql.procedural.cyclomatic_complexity")) + .map(|v| v.as_f64()) + .expect("member cyclomatic"); + assert_eq!(cyclo, 1.0); +} + +/// A declaration-level `CASE … END` initializer doesn't terminate a +/// recovered member early: only its executable `BEGIN` arms termination +/// (Codex P2, PR #257 round 21). +#[test] +fn declaration_case_does_not_truncate_a_recovered_member() { + let sql = "-- sqlfluff:dialect:oracle\n\ + create package body pkg as\n\ + \x20 procedure p is\n\ + \x20 x number := case when 1 = 1 then 1 else 0 end;\n\ + \x20 begin\n\ + \x20 null;\n\ + \x20 end;\n\ + end pkg;\n\ + /\n"; + let analysis = analyze(sql); + let member = analysis + .root + .spaces + .iter() + .flat_map(|s| std::iter::once(s).chain(s.spaces.iter())) + .find(|s| s.kind == SpaceKind::Function) + .expect("member Function space"); + assert_eq!(member.name.as_deref(), Some("p")); + // The member runs through its real body END (line 7), not the + // declaration initializer's END (line 3). + assert!(member.span.end_line >= 7, "member covers its body"); +} + +/// A nested subprogram inside a recovered member doesn't truncate the +/// outer span: both become units, the outer containing the inner +/// (Codex P2, PR #257 round 21). +#[test] +fn nested_recovered_member_keeps_the_outer_span() { + let sql = "-- sqlfluff:dialect:oracle\n\ + create package body pkg as\n\ + \x20 procedure outer_p is\n\ + \x20 procedure inner_p is\n\ + \x20 begin\n\ + \x20 null;\n\ + \x20 end;\n\ + \x20 begin\n\ + \x20 null;\n\ + \x20 end;\n\ + end pkg;\n\ + /\n"; + let analysis = analyze(sql); + let mut functions: Vec<(String, u32, u32)> = Vec::new(); + fn walk(space: &mehen_core::MetricSpace, out: &mut Vec<(String, u32, u32)>) { + if space.kind == SpaceKind::Function { + out.push(( + space.name.clone().unwrap_or_default(), + space.span.start_line, + space.span.end_line, + )); + } + for child in &space.spaces { + walk(child, out); + } + } + for space in &analysis.root.spaces { + walk(space, &mut functions); + } + assert_eq!(functions.len(), 2, "outer and inner members: {functions:?}"); + let outer = functions.iter().find(|(n, _, _)| n == "outer_p").unwrap(); + let inner = functions.iter().find(|(n, _, _)| n == "inner_p").unwrap(); + // The outer member covers its own body END (line 10), past the inner. + assert!(outer.2 >= 10, "outer spans through its body: {outer:?}"); + assert!(inner.2 <= 7, "inner ends at its own END: {inner:?}"); +} + +/// A nested subprogram declared before the outer member's BEGIN (inside a +/// parse-gap package body with an initialization section) doesn't +/// truncate the outer span: prototype-vs-body is decided per member by +/// its own first `;` vs `IS`/`AS` (Codex P2, PR #257 round 22). +#[test] +fn nested_declaration_before_outer_begin_keeps_the_outer_span() { + let sql = "-- sqlfluff:dialect:oracle\n\ + create package body pkg as\n\ + \x20 procedure outer_p is\n\ + \x20 procedure inner_p is\n\ + \x20 begin\n\ + \x20 null;\n\ + \x20 end;\n\ + \x20 begin\n\ + \x20 if 1 = 1 then\n\ + \x20 null;\n\ + \x20 end if;\n\ + \x20 end;\n\ + begin\n\ + \x20 null;\n\ + end pkg;\n\ + /\n"; + let analysis = analyze(sql); + let mut functions: Vec<(String, u32, u32, f64)> = Vec::new(); + fn walk(space: &mehen_core::MetricSpace, out: &mut Vec<(String, u32, u32, f64)>) { + if space.kind == SpaceKind::Function { + out.push(( + space.name.clone().unwrap_or_default(), + space.span.start_line, + space.span.end_line, + space + .metrics + .get(&MetricKey::new("sql.procedural.cyclomatic_complexity")) + .map(|v| v.as_f64()) + .unwrap_or(0.0), + )); + } + for child in &space.spaces { + walk(child, out); + } + } + for space in &analysis.root.spaces { + walk(space, &mut functions); + } + assert_eq!(functions.len(), 2, "outer and inner members: {functions:?}"); + let outer = functions.iter().find(|(n, ..)| n == "outer_p").unwrap(); + // The outer member covers its executable body (through line 12) — the + // nested declaration didn't end it — and owns its IF (entry 1 + IF 1). + assert!(outer.2 >= 12, "outer spans through its body: {outer:?}"); + assert_eq!(outer.3, 2.0, "outer owns its IF: {outer:?}"); +} diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__analytics_cte_chain.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__analytics_cte_chain.snap index 101693c89..762f35ec6 100644 --- a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__analytics_cte_chain.snap +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__analytics_cte_chain.snap @@ -98,6 +98,18 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.predicate.max_boolean_depth": 2, "sql.predicate.not_count": 0, "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 0, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 0.0, + "sql.procedural.cyclomatic_complexity": 0.0, + "sql.procedural.dynamic_sql_count": 0, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 0, + "sql.procedural.loop_count": 0, + "sql.procedural.max_block_depth": 0, + "sql.procedural.raise_throw_count": 0, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, "sql.query_block.avg_select_items": 4.0, "sql.query_block.count": 4, "sql.query_block.max_depth": 1, @@ -116,6 +128,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.set_op.union_all_ratio": 0.0, "sql.statement.count": 1, "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, "sql.statement.kind_count.create_other": 0, "sql.statement.kind_count.create_table": 0, "sql.statement.kind_count.create_table_as": 0, @@ -139,6 +152,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.statement.kind_entropy": 0.0, "sql.statement.unparsed_count": 0, "sql.structural_complexity": 18.900000000000002, + "sql.structural_complexity.max_embedded_query": 0.0, "sql.subquery.correlated_count": 0, "sql.subquery.count": 0, "sql.subquery.exists_count": 0, diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__bigquery_scripting.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__bigquery_scripting.snap new file mode 100644 index 000000000..bf76eaeac --- /dev/null +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__bigquery_scripting.snap @@ -0,0 +1,214 @@ +--- +source: crates/mehen-sql/tests/fixtures_snapshot.rs +expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label(), \"diagnostics\" :\n analysis.diagnostics.iter().map(| d | d.code.clone()).collect :: < Vec < _\n >> (), \"metrics\" : & analysis.root.metrics, \"spaces\" :\n analysis.root.spaces.iter().map(| s | serde_json :: json!\n ({\n \"kind\" : s.kind.as_str(), \"name\" : s.name, \"start_line\" :\n s.span.start_line, \"end_line\" : s.span.end_line,\n })).collect :: < Vec < _ >> (),\n})" +--- +{ + "backend": "sqruff", + "diagnostics": [ + "sql.unparsable" + ], + "metrics": { + "sql.aggregate.distinct_count": 0, + "sql.aggregate.function_count": 0, + "sql.alias.table_alias_count": 0, + "sql.case.count": 0, + "sql.case.max_depth": 0, + "sql.case.max_when_count": 0, + "sql.case.missing_else_count": 0, + "sql.case.when_count": 0, + "sql.cast.count": 0, + "sql.change_risk_score": 7.0, + "sql.cognitive_complexity": 1.0, + "sql.cte.count": 0, + "sql.cte.dependency_edges": 0, + "sql.cte.max_dependency_depth": 0, + "sql.cte.max_fan_out": 0, + "sql.cte.recursive_count": 0, + "sql.cte.trivial_count": 0, + "sql.cte.unused_count": 0, + "sql.dcl.grant_revoke_count": 0, + "sql.ddl.alter_count": 0, + "sql.ddl.create_count": 0, + "sql.ddl.create_or_replace_count": 0, + "sql.ddl.drop_count": 0, + "sql.ddl.truncate_count": 0, + "sql.derived_table.count": 0, + "sql.dialect.confidence": 1.0, + "sql.dialect.conflict_count": 0, + "sql.dialect.directive_present": 1, + "sql.dialect.is_bigquery": 1, + "sql.dialect.requested": 0, + "sql.dml.delete_count": 0, + "sql.dml.delete_without_where_count": 0, + "sql.dml.insert_count": 0, + "sql.dml.merge_count": 0, + "sql.dml.returning_count": 0, + "sql.dml.update_count": 1, + "sql.dml.update_without_where_count": 0, + "sql.expression.max_depth": 1, + "sql.function.call_count": 0, + "sql.function.distinct_count": 0, + "sql.function.nested_call_depth": 0, + "sql.group_by.count": 0, + "sql.group_by.cube_count": 0, + "sql.group_by.grouping_sets_count": 0, + "sql.group_by.rollup_count": 0, + "sql.halstead.difficulty": 37.916666666666664, + "sql.halstead.distinct_operands": 12, + "sql.halstead.distinct_operators": 35, + "sql.halstead.effort": 18112.588480512164, + "sql.halstead.length": 86.0, + "sql.halstead.total_operands": 26, + "sql.halstead.total_operators": 60, + "sql.halstead.vocabulary": 47.0, + "sql.halstead.volume": 477.69464124427685, + "sql.having.count": 0, + "sql.identifier.quoted_count": 0, + "sql.identifier.unqualified_column_ratio": 0.0, + "sql.join.count": 0, + "sql.join.cross_count": 0, + "sql.join.kind_count.cross": 0, + "sql.join.kind_count.full": 0, + "sql.join.kind_count.inner": 0, + "sql.join.kind_count.lateral": 0, + "sql.join.kind_count.left": 0, + "sql.join.kind_count.natural": 0, + "sql.join.kind_count.right": 0, + "sql.join.missing_condition_count": 0, + "sql.join.natural_count": 0, + "sql.join.non_equi_count": 0, + "sql.join.outer_count": 0, + "sql.loc.avg_statement_lines": 1.0, + "sql.loc.blank": 4, + "sql.loc.code": 22, + "sql.loc.comment": 1, + "sql.loc.comment_density": 0.043478260869565216, + "sql.loc.logical": 7, + "sql.loc.max_statement_lines": 1, + "sql.loc.physical": 27, + "sql.maintainability_index": 92.6023889699709, + "sql.modularity_health": 0.0, + "sql.object.read_count": 0, + "sql.object.touch_count": 1, + "sql.object.write_count": 1, + "sql.parser.diagnostic_count": 2, + "sql.parser.unparsable_line_count": 7, + "sql.parser.unparsable_ratio": 0.3181818181818182, + "sql.parser.unparsable_segment_count": 2, + "sql.predicate.boolean_operator_count": 0, + "sql.predicate.comparison_count": 9, + "sql.predicate.max_boolean_depth": 0, + "sql.predicate.not_count": 0, + "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 1, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 8.0, + "sql.procedural.cyclomatic_complexity": 11.0, + "sql.procedural.dynamic_sql_count": 1, + "sql.procedural.exception_handler_count": 1, + "sql.procedural.if_count": 3, + "sql.procedural.loop_count": 2, + "sql.procedural.max_block_depth": 1, + "sql.procedural.raise_throw_count": 1, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, + "sql.query_block.avg_select_items": 1.0, + "sql.query_block.count": 1, + "sql.query_block.max_depth": 1, + "sql.query_block.max_select_items": 1, + "sql.relation.ref_count": 1, + "sql.review_burden_index": 8.722716389194124, + "sql.select.expression_without_alias_count": 0, + "sql.select.outer_star_count": 0, + "sql.select.output_alias_coverage": 1.0, + "sql.select.star_count": 0, + "sql.set_op.count": 0, + "sql.set_op.kind_count.except": 0, + "sql.set_op.kind_count.intersect": 0, + "sql.set_op.kind_count.union": 0, + "sql.set_op.kind_count.union_all": 0, + "sql.set_op.union_all_ratio": 0.0, + "sql.statement.count": 7, + "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 1, + "sql.statement.kind_count.create_other": 0, + "sql.statement.kind_count.create_table": 0, + "sql.statement.kind_count.create_table_as": 0, + "sql.statement.kind_count.create_view": 0, + "sql.statement.kind_count.delete": 0, + "sql.statement.kind_count.drop": 0, + "sql.statement.kind_count.explain": 0, + "sql.statement.kind_count.grant": 0, + "sql.statement.kind_count.insert": 0, + "sql.statement.kind_count.merge": 0, + "sql.statement.kind_count.procedural": 0, + "sql.statement.kind_count.revoke": 0, + "sql.statement.kind_count.select": 0, + "sql.statement.kind_count.set_operation": 0, + "sql.statement.kind_count.transaction_control": 0, + "sql.statement.kind_count.truncate": 0, + "sql.statement.kind_count.unknown": 5, + "sql.statement.kind_count.update": 1, + "sql.statement.kind_count.with_select": 0, + "sql.statement.kind_distinct": 3, + "sql.statement.kind_entropy": 0.72483409150576, + "sql.statement.unparsed_count": 5, + "sql.structural_complexity": 1.5, + "sql.structural_complexity.max_embedded_query": 0.0, + "sql.subquery.correlated_count": 0, + "sql.subquery.count": 0, + "sql.subquery.exists_count": 0, + "sql.subquery.in_count": 1, + "sql.subquery.max_depth": 0, + "sql.subquery.scalar_count": 0, + "sql.transaction.control_count": 0, + "sql.window.frame_count": 0, + "sql.window.function_count": 0, + "sql.window.order_expression_count": 0, + "sql.window.partition_expression_count": 0 + }, + "spaces": [ + { + "end_line": 2, + "kind": "sql.statement", + "name": "unknown", + "start_line": 2 + }, + { + "end_line": 5, + "kind": "sql.statement", + "name": "update", + "start_line": 5 + }, + { + "end_line": 7, + "kind": "sql.statement", + "name": "unknown", + "start_line": 7 + }, + { + "end_line": 9, + "kind": "sql.statement", + "name": "unknown", + "start_line": 9 + }, + { + "end_line": 13, + "kind": "sql.statement", + "name": "unknown", + "start_line": 13 + }, + { + "end_line": 20, + "kind": "sql.statement", + "name": "unknown", + "start_line": 20 + }, + { + "end_line": 23, + "kind": "sql.statement", + "name": "anonymous_block", + "start_line": 23 + } + ] +} diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__correlated_subquery.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__correlated_subquery.snap index 6c5f665f5..bc221682d 100644 --- a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__correlated_subquery.snap +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__correlated_subquery.snap @@ -98,6 +98,18 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.predicate.max_boolean_depth": 1, "sql.predicate.not_count": 0, "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 0, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 0.0, + "sql.procedural.cyclomatic_complexity": 0.0, + "sql.procedural.dynamic_sql_count": 0, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 0, + "sql.procedural.loop_count": 0, + "sql.procedural.max_block_depth": 0, + "sql.procedural.raise_throw_count": 0, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, "sql.query_block.avg_select_items": 1.6, "sql.query_block.count": 5, "sql.query_block.max_depth": 2, @@ -116,6 +128,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.set_op.union_all_ratio": 0.0, "sql.statement.count": 1, "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, "sql.statement.kind_count.create_other": 0, "sql.statement.kind_count.create_table": 0, "sql.statement.kind_count.create_table_as": 0, @@ -139,6 +152,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.statement.kind_entropy": 0.0, "sql.statement.unparsed_count": 0, "sql.structural_complexity": 22.65, + "sql.structural_complexity.max_embedded_query": 0.0, "sql.subquery.correlated_count": 3, "sql.subquery.count": 4, "sql.subquery.exists_count": 1, diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__dialect_directive.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__dialect_directive.snap index b862a0add..66fc02b90 100644 --- a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__dialect_directive.snap +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__dialect_directive.snap @@ -100,6 +100,18 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.predicate.max_boolean_depth": 0, "sql.predicate.not_count": 0, "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 0, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 0.0, + "sql.procedural.cyclomatic_complexity": 0.0, + "sql.procedural.dynamic_sql_count": 0, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 0, + "sql.procedural.loop_count": 0, + "sql.procedural.max_block_depth": 0, + "sql.procedural.raise_throw_count": 0, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, "sql.query_block.avg_select_items": 2.0, "sql.query_block.count": 1, "sql.query_block.max_depth": 1, @@ -118,6 +130,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.set_op.union_all_ratio": 0.0, "sql.statement.count": 1, "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, "sql.statement.kind_count.create_other": 0, "sql.statement.kind_count.create_table": 0, "sql.statement.kind_count.create_table_as": 0, @@ -141,6 +154,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.statement.kind_entropy": 0.0, "sql.statement.unparsed_count": 0, "sql.structural_complexity": 2.0, + "sql.structural_complexity.max_embedded_query": 0.0, "sql.subquery.correlated_count": 0, "sql.subquery.count": 0, "sql.subquery.exists_count": 0, diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__migration_destructive.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__migration_destructive.snap index e34d24271..fe101baf2 100644 --- a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__migration_destructive.snap +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__migration_destructive.snap @@ -98,6 +98,18 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.predicate.max_boolean_depth": 0, "sql.predicate.not_count": 0, "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 0, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 0.0, + "sql.procedural.cyclomatic_complexity": 0.0, + "sql.procedural.dynamic_sql_count": 0, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 0, + "sql.procedural.loop_count": 0, + "sql.procedural.max_block_depth": 0, + "sql.procedural.raise_throw_count": 0, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, "sql.query_block.avg_select_items": 0.0, "sql.query_block.count": 0, "sql.query_block.max_depth": 0, @@ -116,6 +128,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.set_op.union_all_ratio": 0.0, "sql.statement.count": 8, "sql.statement.kind_count.alter_table": 1, + "sql.statement.kind_count.anonymous_block": 0, "sql.statement.kind_count.create_other": 0, "sql.statement.kind_count.create_table": 0, "sql.statement.kind_count.create_table_as": 0, @@ -139,6 +152,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.statement.kind_entropy": 0.979569764547061, "sql.statement.unparsed_count": 0, "sql.structural_complexity": 0.0, + "sql.structural_complexity.max_embedded_query": 0.0, "sql.subquery.correlated_count": 0, "sql.subquery.count": 0, "sql.subquery.exists_count": 0, diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__mysql_procedure_control_flow.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__mysql_procedure_control_flow.snap new file mode 100644 index 000000000..038beec93 --- /dev/null +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__mysql_procedure_control_flow.snap @@ -0,0 +1,232 @@ +--- +source: crates/mehen-sql/tests/fixtures_snapshot.rs +expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label(), \"diagnostics\" :\n analysis.diagnostics.iter().map(| d | d.code.clone()).collect :: < Vec < _\n >> (), \"metrics\" : & analysis.root.metrics, \"spaces\" :\n analysis.root.spaces.iter().map(| s | serde_json :: json!\n ({\n \"kind\" : s.kind.as_str(), \"name\" : s.name, \"start_line\" :\n s.span.start_line, \"end_line\" : s.span.end_line,\n })).collect :: < Vec < _ >> (),\n})" +--- +{ + "backend": "sqruff", + "diagnostics": [ + "sql.unparsable" + ], + "metrics": { + "sql.aggregate.distinct_count": 0, + "sql.aggregate.function_count": 0, + "sql.alias.table_alias_count": 0, + "sql.case.count": 0, + "sql.case.max_depth": 0, + "sql.case.max_when_count": 0, + "sql.case.missing_else_count": 0, + "sql.case.when_count": 0, + "sql.cast.count": 0, + "sql.change_risk_score": 5.0, + "sql.cognitive_complexity": 0.0, + "sql.cte.count": 0, + "sql.cte.dependency_edges": 0, + "sql.cte.max_dependency_depth": 0, + "sql.cte.max_fan_out": 0, + "sql.cte.recursive_count": 0, + "sql.cte.trivial_count": 0, + "sql.cte.unused_count": 0, + "sql.dcl.grant_revoke_count": 0, + "sql.ddl.alter_count": 0, + "sql.ddl.create_count": 0, + "sql.ddl.create_or_replace_count": 0, + "sql.ddl.drop_count": 0, + "sql.ddl.truncate_count": 0, + "sql.derived_table.count": 0, + "sql.dialect.confidence": 1.0, + "sql.dialect.conflict_count": 0, + "sql.dialect.directive_present": 1, + "sql.dialect.is_mysql": 1, + "sql.dialect.requested": 0, + "sql.dml.delete_count": 0, + "sql.dml.delete_without_where_count": 0, + "sql.dml.insert_count": 0, + "sql.dml.merge_count": 0, + "sql.dml.returning_count": 0, + "sql.dml.update_count": 0, + "sql.dml.update_without_where_count": 0, + "sql.expression.max_depth": 1, + "sql.function.call_count": 0, + "sql.function.distinct_count": 0, + "sql.function.nested_call_depth": 0, + "sql.group_by.count": 0, + "sql.group_by.cube_count": 0, + "sql.group_by.grouping_sets_count": 0, + "sql.group_by.rollup_count": 0, + "sql.halstead.difficulty": 34.83333333333333, + "sql.halstead.distinct_operands": 12, + "sql.halstead.distinct_operators": 38, + "sql.halstead.effort": 18873.055098606677, + "sql.halstead.length": 96.0, + "sql.halstead.total_operands": 22, + "sql.halstead.total_operators": 74, + "sql.halstead.vocabulary": 50.0, + "sql.halstead.volume": 541.8101942183736, + "sql.having.count": 0, + "sql.identifier.quoted_count": 0, + "sql.identifier.unqualified_column_ratio": 0.0, + "sql.join.count": 0, + "sql.join.cross_count": 0, + "sql.join.kind_count.cross": 0, + "sql.join.kind_count.full": 0, + "sql.join.kind_count.inner": 0, + "sql.join.kind_count.lateral": 0, + "sql.join.kind_count.left": 0, + "sql.join.kind_count.natural": 0, + "sql.join.kind_count.right": 0, + "sql.join.missing_condition_count": 0, + "sql.join.natural_count": 0, + "sql.join.non_equi_count": 0, + "sql.join.outer_count": 0, + "sql.loc.avg_statement_lines": 1.8888888888888888, + "sql.loc.blank": 6, + "sql.loc.code": 25, + "sql.loc.comment": 1, + "sql.loc.comment_density": 0.038461538461538464, + "sql.loc.logical": 9, + "sql.loc.max_statement_lines": 3, + "sql.loc.physical": 32, + "sql.maintainability_index": 93.22941690590856, + "sql.modularity_health": 0.0, + "sql.object.read_count": 0, + "sql.object.touch_count": 0, + "sql.object.write_count": 0, + "sql.parser.diagnostic_count": 1, + "sql.parser.unparsable_line_count": 10, + "sql.parser.unparsable_ratio": 0.4, + "sql.parser.unparsable_segment_count": 1, + "sql.predicate.boolean_operator_count": 0, + "sql.predicate.comparison_count": 10, + "sql.predicate.max_boolean_depth": 0, + "sql.predicate.not_count": 0, + "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 1, + "sql.procedural.case_statement_count": 1, + "sql.procedural.cognitive_complexity": 6.0, + "sql.procedural.cyclomatic_complexity": 7.0, + "sql.procedural.dynamic_sql_count": 1, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 2, + "sql.procedural.loop_count": 2, + "sql.procedural.max_block_depth": 1, + "sql.procedural.raise_throw_count": 1, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 1, + "sql.query_block.avg_select_items": 0.0, + "sql.query_block.count": 0, + "sql.query_block.max_depth": 0, + "sql.query_block.max_select_items": 0, + "sql.relation.ref_count": 1, + "sql.review_burden_index": 6.098712247737775, + "sql.select.expression_without_alias_count": 0, + "sql.select.outer_star_count": 0, + "sql.select.output_alias_coverage": 1.0, + "sql.select.star_count": 0, + "sql.set_op.count": 0, + "sql.set_op.kind_count.except": 0, + "sql.set_op.kind_count.intersect": 0, + "sql.set_op.kind_count.union": 0, + "sql.set_op.kind_count.union_all": 0, + "sql.set_op.union_all_ratio": 0.0, + "sql.statement.count": 9, + "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, + "sql.statement.kind_count.create_other": 0, + "sql.statement.kind_count.create_table": 0, + "sql.statement.kind_count.create_table_as": 0, + "sql.statement.kind_count.create_view": 0, + "sql.statement.kind_count.delete": 0, + "sql.statement.kind_count.drop": 0, + "sql.statement.kind_count.explain": 0, + "sql.statement.kind_count.grant": 0, + "sql.statement.kind_count.insert": 0, + "sql.statement.kind_count.merge": 0, + "sql.statement.kind_count.procedural": 9, + "sql.statement.kind_count.revoke": 0, + "sql.statement.kind_count.select": 0, + "sql.statement.kind_count.set_operation": 0, + "sql.statement.kind_count.transaction_control": 0, + "sql.statement.kind_count.truncate": 0, + "sql.statement.kind_count.unknown": 0, + "sql.statement.kind_count.update": 0, + "sql.statement.kind_count.with_select": 0, + "sql.statement.kind_distinct": 1, + "sql.statement.kind_entropy": 0.0, + "sql.statement.unparsed_count": 0, + "sql.structural_complexity": 0.5, + "sql.structural_complexity.max_embedded_query": 0.5, + "sql.subquery.correlated_count": 0, + "sql.subquery.count": 0, + "sql.subquery.exists_count": 0, + "sql.subquery.in_count": 0, + "sql.subquery.max_depth": 0, + "sql.subquery.scalar_count": 0, + "sql.transaction.control_count": 0, + "sql.window.frame_count": 0, + "sql.window.function_count": 0, + "sql.window.order_expression_count": 0, + "sql.window.partition_expression_count": 0 + }, + "spaces": [ + { + "end_line": 4, + "kind": "sql.statement", + "name": "procedural", + "start_line": 2 + }, + { + "end_line": 7, + "kind": "sql.statement", + "name": "procedural", + "start_line": 6 + }, + { + "end_line": 9, + "kind": "sql.statement", + "name": "procedural", + "start_line": 8 + }, + { + "end_line": 11, + "kind": "sql.statement", + "name": "procedural", + "start_line": 10 + }, + { + "end_line": 12, + "kind": "sql.statement", + "name": "procedural", + "start_line": 12 + }, + { + "end_line": 15, + "kind": "sql.statement", + "name": "procedural", + "start_line": 14 + }, + { + "end_line": 16, + "kind": "sql.statement", + "name": "procedural", + "start_line": 16 + }, + { + "end_line": 19, + "kind": "sql.statement", + "name": "procedural", + "start_line": 18 + }, + { + "end_line": 21, + "kind": "sql.statement", + "name": "procedural", + "start_line": 20 + }, + { + "end_line": 32, + "kind": "function", + "name": "process_orders", + "start_line": 2 + } + ] +} diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__plsql_procedure_control_flow.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__plsql_procedure_control_flow.snap new file mode 100644 index 000000000..c2e14258f --- /dev/null +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__plsql_procedure_control_flow.snap @@ -0,0 +1,176 @@ +--- +source: crates/mehen-sql/tests/fixtures_snapshot.rs +expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label(), \"diagnostics\" :\n analysis.diagnostics.iter().map(| d | d.code.clone()).collect :: < Vec < _\n >> (), \"metrics\" : & analysis.root.metrics, \"spaces\" :\n analysis.root.spaces.iter().map(| s | serde_json :: json!\n ({\n \"kind\" : s.kind.as_str(), \"name\" : s.name, \"start_line\" :\n s.span.start_line, \"end_line\" : s.span.end_line,\n })).collect :: < Vec < _ >> (),\n})" +--- +{ + "backend": "sqruff", + "diagnostics": [], + "metrics": { + "sql.aggregate.distinct_count": 0, + "sql.aggregate.function_count": 0, + "sql.alias.table_alias_count": 0, + "sql.case.count": 0, + "sql.case.max_depth": 0, + "sql.case.max_when_count": 0, + "sql.case.missing_else_count": 0, + "sql.case.when_count": 0, + "sql.cast.count": 0, + "sql.change_risk_score": 9.0, + "sql.cognitive_complexity": 0.25, + "sql.cte.count": 0, + "sql.cte.dependency_edges": 0, + "sql.cte.max_dependency_depth": 0, + "sql.cte.max_fan_out": 0, + "sql.cte.recursive_count": 0, + "sql.cte.trivial_count": 0, + "sql.cte.unused_count": 0, + "sql.dcl.grant_revoke_count": 0, + "sql.ddl.alter_count": 0, + "sql.ddl.create_count": 0, + "sql.ddl.create_or_replace_count": 1, + "sql.ddl.drop_count": 0, + "sql.ddl.truncate_count": 0, + "sql.derived_table.count": 0, + "sql.dialect.confidence": 1.0, + "sql.dialect.conflict_count": 0, + "sql.dialect.directive_present": 1, + "sql.dialect.is_oracle": 1, + "sql.dialect.requested": 0, + "sql.dml.delete_count": 0, + "sql.dml.delete_without_where_count": 0, + "sql.dml.insert_count": 0, + "sql.dml.merge_count": 0, + "sql.dml.returning_count": 0, + "sql.dml.update_count": 0, + "sql.dml.update_without_where_count": 0, + "sql.expression.max_depth": 1, + "sql.function.call_count": 1, + "sql.function.distinct_count": 1, + "sql.function.nested_call_depth": 1, + "sql.group_by.count": 0, + "sql.group_by.cube_count": 0, + "sql.group_by.grouping_sets_count": 0, + "sql.group_by.rollup_count": 0, + "sql.halstead.difficulty": 40.147058823529406, + "sql.halstead.distinct_operands": 17, + "sql.halstead.distinct_operators": 39, + "sql.halstead.effort": 22615.377307501087, + "sql.halstead.length": 97.0, + "sql.halstead.total_operands": 35, + "sql.halstead.total_operators": 62, + "sql.halstead.vocabulary": 56.0, + "sql.halstead.volume": 563.3134274395876, + "sql.having.count": 0, + "sql.identifier.quoted_count": 0, + "sql.identifier.unqualified_column_ratio": 0.0, + "sql.join.count": 0, + "sql.join.cross_count": 0, + "sql.join.kind_count.cross": 0, + "sql.join.kind_count.full": 0, + "sql.join.kind_count.inner": 0, + "sql.join.kind_count.lateral": 0, + "sql.join.kind_count.left": 0, + "sql.join.kind_count.natural": 0, + "sql.join.kind_count.right": 0, + "sql.join.missing_condition_count": 0, + "sql.join.natural_count": 0, + "sql.join.non_equi_count": 0, + "sql.join.outer_count": 0, + "sql.loc.avg_statement_lines": 29.0, + "sql.loc.blank": 3, + "sql.loc.code": 27, + "sql.loc.comment": 1, + "sql.loc.comment_density": 0.03571428571428571, + "sql.loc.logical": 1, + "sql.loc.max_statement_lines": 29, + "sql.loc.physical": 31, + "sql.maintainability_index": 93.34709644182789, + "sql.modularity_health": 0.0, + "sql.object.read_count": 0, + "sql.object.touch_count": 0, + "sql.object.write_count": 0, + "sql.parser.diagnostic_count": 0, + "sql.parser.unparsable_line_count": 0, + "sql.parser.unparsable_ratio": 0.0, + "sql.parser.unparsable_segment_count": 0, + "sql.predicate.boolean_operator_count": 1, + "sql.predicate.comparison_count": 7, + "sql.predicate.max_boolean_depth": 0, + "sql.predicate.not_count": 0, + "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 1, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 9.0, + "sql.procedural.cyclomatic_complexity": 12.0, + "sql.procedural.dynamic_sql_count": 1, + "sql.procedural.exception_handler_count": 2, + "sql.procedural.if_count": 2, + "sql.procedural.loop_count": 2, + "sql.procedural.max_block_depth": 1, + "sql.procedural.raise_throw_count": 3, + "sql.procedural.return_count": 1, + "sql.procedural.routine_count": 1, + "sql.query_block.avg_select_items": 0.0, + "sql.query_block.count": 0, + "sql.query_block.max_depth": 0, + "sql.query_block.max_select_items": 0, + "sql.relation.ref_count": 1, + "sql.review_burden_index": 6.276032308270309, + "sql.select.expression_without_alias_count": 0, + "sql.select.outer_star_count": 0, + "sql.select.output_alias_coverage": 1.0, + "sql.select.star_count": 0, + "sql.set_op.count": 0, + "sql.set_op.kind_count.except": 0, + "sql.set_op.kind_count.intersect": 0, + "sql.set_op.kind_count.union": 0, + "sql.set_op.kind_count.union_all": 0, + "sql.set_op.union_all_ratio": 0.0, + "sql.statement.count": 1, + "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, + "sql.statement.kind_count.create_other": 0, + "sql.statement.kind_count.create_table": 0, + "sql.statement.kind_count.create_table_as": 0, + "sql.statement.kind_count.create_view": 0, + "sql.statement.kind_count.delete": 0, + "sql.statement.kind_count.drop": 0, + "sql.statement.kind_count.explain": 0, + "sql.statement.kind_count.grant": 0, + "sql.statement.kind_count.insert": 0, + "sql.statement.kind_count.merge": 0, + "sql.statement.kind_count.procedural": 1, + "sql.statement.kind_count.revoke": 0, + "sql.statement.kind_count.select": 0, + "sql.statement.kind_count.set_operation": 0, + "sql.statement.kind_count.transaction_control": 0, + "sql.statement.kind_count.truncate": 0, + "sql.statement.kind_count.unknown": 0, + "sql.statement.kind_count.update": 0, + "sql.statement.kind_count.with_select": 0, + "sql.statement.kind_distinct": 1, + "sql.statement.kind_entropy": 0.0, + "sql.statement.unparsed_count": 0, + "sql.structural_complexity": 0.85, + "sql.structural_complexity.max_embedded_query": 0.5, + "sql.subquery.correlated_count": 0, + "sql.subquery.count": 0, + "sql.subquery.exists_count": 0, + "sql.subquery.in_count": 0, + "sql.subquery.max_depth": 0, + "sql.subquery.scalar_count": 0, + "sql.transaction.control_count": 0, + "sql.window.frame_count": 0, + "sql.window.function_count": 0, + "sql.window.order_expression_count": 0, + "sql.window.partition_expression_count": 0 + }, + "spaces": [ + { + "end_line": 30, + "kind": "sql.statement", + "name": "procedural", + "start_line": 2 + } + ] +} diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__set_ops_unions.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__set_ops_unions.snap index 4ec178276..299f0070b 100644 --- a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__set_ops_unions.snap +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__set_ops_unions.snap @@ -98,6 +98,18 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.predicate.max_boolean_depth": 0, "sql.predicate.not_count": 0, "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 0, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 0.0, + "sql.procedural.cyclomatic_complexity": 0.0, + "sql.procedural.dynamic_sql_count": 0, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 0, + "sql.procedural.loop_count": 0, + "sql.procedural.max_block_depth": 0, + "sql.procedural.raise_throw_count": 0, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, "sql.query_block.avg_select_items": 1.0, "sql.query_block.count": 4, "sql.query_block.max_depth": 1, @@ -116,6 +128,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.set_op.union_all_ratio": 0.5, "sql.statement.count": 1, "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, "sql.statement.kind_count.create_other": 0, "sql.statement.kind_count.create_table": 0, "sql.statement.kind_count.create_table_as": 0, @@ -139,6 +152,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.statement.kind_entropy": 0.0, "sql.statement.unparsed_count": 0, "sql.structural_complexity": 7.0, + "sql.structural_complexity.max_embedded_query": 0.0, "sql.subquery.correlated_count": 0, "sql.subquery.count": 0, "sql.subquery.exists_count": 0, diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__simple_select.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__simple_select.snap index 16d4b3cf4..f33d7d4b0 100644 --- a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__simple_select.snap +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__simple_select.snap @@ -98,6 +98,18 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.predicate.max_boolean_depth": 0, "sql.predicate.not_count": 0, "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 0, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 0.0, + "sql.procedural.cyclomatic_complexity": 0.0, + "sql.procedural.dynamic_sql_count": 0, + "sql.procedural.exception_handler_count": 0, + "sql.procedural.if_count": 0, + "sql.procedural.loop_count": 0, + "sql.procedural.max_block_depth": 0, + "sql.procedural.raise_throw_count": 0, + "sql.procedural.return_count": 0, + "sql.procedural.routine_count": 0, "sql.query_block.avg_select_items": 3.0, "sql.query_block.count": 1, "sql.query_block.max_depth": 1, @@ -116,6 +128,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.set_op.union_all_ratio": 0.0, "sql.statement.count": 1, "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, "sql.statement.kind_count.create_other": 0, "sql.statement.kind_count.create_table": 0, "sql.statement.kind_count.create_table_as": 0, @@ -139,6 +152,7 @@ expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label() "sql.statement.kind_entropy": 0.0, "sql.statement.unparsed_count": 0, "sql.structural_complexity": 1.5, + "sql.structural_complexity.max_embedded_query": 0.0, "sql.subquery.correlated_count": 0, "sql.subquery.count": 0, "sql.subquery.exists_count": 0, diff --git a/crates/mehen-sql/tests/snapshots/fixtures_snapshot__tsql_procedure_control_flow.snap b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__tsql_procedure_control_flow.snap new file mode 100644 index 000000000..d2b4b6cfe --- /dev/null +++ b/crates/mehen-sql/tests/snapshots/fixtures_snapshot__tsql_procedure_control_flow.snap @@ -0,0 +1,190 @@ +--- +source: crates/mehen-sql/tests/fixtures_snapshot.rs +expression: "serde_json :: json!\n({\n \"backend\" : analysis.backend.label(), \"diagnostics\" :\n analysis.diagnostics.iter().map(| d | d.code.clone()).collect :: < Vec < _\n >> (), \"metrics\" : & analysis.root.metrics, \"spaces\" :\n analysis.root.spaces.iter().map(| s | serde_json :: json!\n ({\n \"kind\" : s.kind.as_str(), \"name\" : s.name, \"start_line\" :\n s.span.start_line, \"end_line\" : s.span.end_line,\n })).collect :: < Vec < _ >> (),\n})" +--- +{ + "backend": "sqruff", + "diagnostics": [ + "sql.unparsable" + ], + "metrics": { + "sql.aggregate.distinct_count": 0, + "sql.aggregate.function_count": 0, + "sql.alias.table_alias_count": 0, + "sql.case.count": 0, + "sql.case.max_depth": 0, + "sql.case.max_when_count": 0, + "sql.case.missing_else_count": 0, + "sql.case.when_count": 0, + "sql.cast.count": 0, + "sql.change_risk_score": 5.0, + "sql.cognitive_complexity": 0.0, + "sql.cte.count": 0, + "sql.cte.dependency_edges": 0, + "sql.cte.max_dependency_depth": 0, + "sql.cte.max_fan_out": 0, + "sql.cte.recursive_count": 0, + "sql.cte.trivial_count": 0, + "sql.cte.unused_count": 0, + "sql.dcl.grant_revoke_count": 0, + "sql.ddl.alter_count": 0, + "sql.ddl.create_count": 0, + "sql.ddl.create_or_replace_count": 0, + "sql.ddl.drop_count": 0, + "sql.ddl.truncate_count": 0, + "sql.derived_table.count": 0, + "sql.dialect.confidence": 1.0, + "sql.dialect.conflict_count": 0, + "sql.dialect.directive_present": 1, + "sql.dialect.is_tsql": 1, + "sql.dialect.requested": 0, + "sql.dml.delete_count": 0, + "sql.dml.delete_without_where_count": 0, + "sql.dml.insert_count": 0, + "sql.dml.merge_count": 0, + "sql.dml.returning_count": 0, + "sql.dml.update_count": 0, + "sql.dml.update_without_where_count": 0, + "sql.expression.max_depth": 1, + "sql.function.call_count": 0, + "sql.function.distinct_count": 0, + "sql.function.nested_call_depth": 0, + "sql.group_by.count": 0, + "sql.group_by.cube_count": 0, + "sql.group_by.grouping_sets_count": 0, + "sql.group_by.rollup_count": 0, + "sql.halstead.difficulty": 19.333333333333332, + "sql.halstead.distinct_operands": 9, + "sql.halstead.distinct_operators": 29, + "sql.halstead.effort": 7710.954826419774, + "sql.halstead.length": 76.0, + "sql.halstead.total_operands": 12, + "sql.halstead.total_operators": 64, + "sql.halstead.vocabulary": 38.0, + "sql.halstead.volume": 398.8424910217125, + "sql.having.count": 0, + "sql.identifier.quoted_count": 0, + "sql.identifier.unqualified_column_ratio": 0.0, + "sql.join.count": 0, + "sql.join.cross_count": 0, + "sql.join.kind_count.cross": 0, + "sql.join.kind_count.full": 0, + "sql.join.kind_count.inner": 0, + "sql.join.kind_count.lateral": 0, + "sql.join.kind_count.left": 0, + "sql.join.kind_count.natural": 0, + "sql.join.kind_count.right": 0, + "sql.join.missing_condition_count": 0, + "sql.join.natural_count": 0, + "sql.join.non_equi_count": 0, + "sql.join.outer_count": 0, + "sql.loc.avg_statement_lines": 6.0, + "sql.loc.blank": 4, + "sql.loc.code": 26, + "sql.loc.comment": 1, + "sql.loc.comment_density": 0.037037037037037035, + "sql.loc.logical": 2, + "sql.loc.max_statement_lines": 9, + "sql.loc.physical": 31, + "sql.maintainability_index": 93.85105829917758, + "sql.modularity_health": 0.0, + "sql.object.read_count": 0, + "sql.object.touch_count": 0, + "sql.object.write_count": 0, + "sql.parser.diagnostic_count": 2, + "sql.parser.unparsable_line_count": 18, + "sql.parser.unparsable_ratio": 0.6923076923076923, + "sql.parser.unparsable_segment_count": 2, + "sql.predicate.boolean_operator_count": 0, + "sql.predicate.comparison_count": 3, + "sql.predicate.max_boolean_depth": 0, + "sql.predicate.not_count": 0, + "sql.predicate.null_semantics_risk_count": 0, + "sql.procedural.block_count": 6, + "sql.procedural.case_statement_count": 0, + "sql.procedural.cognitive_complexity": 8.0, + "sql.procedural.cyclomatic_complexity": 7.0, + "sql.procedural.dynamic_sql_count": 1, + "sql.procedural.exception_handler_count": 1, + "sql.procedural.if_count": 3, + "sql.procedural.loop_count": 1, + "sql.procedural.max_block_depth": 2, + "sql.procedural.raise_throw_count": 1, + "sql.procedural.return_count": 2, + "sql.procedural.routine_count": 1, + "sql.query_block.avg_select_items": 0.0, + "sql.query_block.count": 0, + "sql.query_block.max_depth": 0, + "sql.query_block.max_select_items": 0, + "sql.relation.ref_count": 1, + "sql.review_burden_index": 6.526368795652826, + "sql.select.expression_without_alias_count": 0, + "sql.select.outer_star_count": 0, + "sql.select.output_alias_coverage": 1.0, + "sql.select.star_count": 0, + "sql.set_op.count": 0, + "sql.set_op.kind_count.except": 0, + "sql.set_op.kind_count.intersect": 0, + "sql.set_op.kind_count.union": 0, + "sql.set_op.kind_count.union_all": 0, + "sql.set_op.union_all_ratio": 0.0, + "sql.statement.count": 2, + "sql.statement.kind_count.alter_table": 0, + "sql.statement.kind_count.anonymous_block": 0, + "sql.statement.kind_count.create_other": 0, + "sql.statement.kind_count.create_table": 0, + "sql.statement.kind_count.create_table_as": 0, + "sql.statement.kind_count.create_view": 0, + "sql.statement.kind_count.delete": 0, + "sql.statement.kind_count.drop": 0, + "sql.statement.kind_count.explain": 0, + "sql.statement.kind_count.grant": 0, + "sql.statement.kind_count.insert": 0, + "sql.statement.kind_count.merge": 0, + "sql.statement.kind_count.procedural": 2, + "sql.statement.kind_count.revoke": 0, + "sql.statement.kind_count.select": 0, + "sql.statement.kind_count.set_operation": 0, + "sql.statement.kind_count.transaction_control": 0, + "sql.statement.kind_count.truncate": 0, + "sql.statement.kind_count.unknown": 0, + "sql.statement.kind_count.update": 0, + "sql.statement.kind_count.with_select": 0, + "sql.statement.kind_distinct": 1, + "sql.statement.kind_entropy": 0.0, + "sql.statement.unparsed_count": 0, + "sql.structural_complexity": 0.5, + "sql.structural_complexity.max_embedded_query": 0.5, + "sql.subquery.correlated_count": 0, + "sql.subquery.count": 0, + "sql.subquery.exists_count": 0, + "sql.subquery.in_count": 0, + "sql.subquery.max_depth": 0, + "sql.subquery.scalar_count": 0, + "sql.transaction.control_count": 0, + "sql.window.frame_count": 0, + "sql.window.function_count": 0, + "sql.window.order_expression_count": 0, + "sql.window.partition_expression_count": 0 + }, + "spaces": [ + { + "end_line": 4, + "kind": "sql.statement", + "name": "procedural", + "start_line": 2 + }, + { + "end_line": 14, + "kind": "sql.statement", + "name": "procedural", + "start_line": 6 + }, + { + "end_line": 31, + "kind": "function", + "name": "dbo.process_orders", + "start_line": 2 + } + ] +} diff --git a/design-docs/sql_parser_comparison.md b/design-docs/sql_parser_comparison.md index c48d216f9..097e255e3 100644 --- a/design-docs/sql_parser_comparison.md +++ b/design-docs/sql_parser_comparison.md @@ -387,3 +387,53 @@ no rewrite required. - Dialect inventory: `sqlparser::dialect` exposes 16 dialect structs plus `dialect_from_str`; our sqruff build compiles 12 feature-gated dialects alongside the always-present `ansi`. + + +--- + +## 9. Addendum — Phase-3 procedural re-probe (2026-08-20) + +**Status:** pre-implementation check before building `sql.procedural.*` · +**Verdict: §8.5 stands — keep sqruff for Phase 3 and Phase 4.** + +Before implementing Phase 3 (procedural metrics), `sqlparser` was re-probed +specifically on the constructs Phase 3 must parse — *definitions with bodies*, +not isolated control-flow statements. `sqlparser` is still at **v0.62.0** +(May 2026, no newer release), so §8's structural findings stand; this pass +adds the procedural detail §8.2 lacked. sqruff side probed at **v0.40.0** +(current pin) via a throwaway CST dumper, not via `SqlAnalyzer`. + +| Probe (realistic bodies) | `sqlparser` 0.62 | sqruff 0.40 | +|---|---|---| +| T-SQL `CREATE PROCEDURE dbo.p @x INT AS BEGIN … END` | ❌ `Err` at the header (`Expected: AS, found: @batch`) — zero statements | ✅ statement + header typed; `IF`/`WHILE` parse as keyword+`Expression`+nested `Statement`; tail of long bodies can degrade to `Unparsable` | +| T-SQL `BEGIN TRY … END CATCH` (isolated) | ❌ `Err` | ✅ parses (keyword run + typed nested statements) | +| T-SQL `EXEC sp_executesql` / `PRINT` / `THROW` / `GOTO` / cursor DDL at top level | ❌ `Err` (except bare `WHILE`) | ⚠️ `Unparsable`, but tokens stay classified (`Word`/`SingleQuote`/`InlineComment`), so token-level counting stays trivia-safe | +| PL/SQL `CREATE OR REPLACE PROCEDURE … IS … BEGIN … EXCEPTION … END` | ❌ `Err` — grammar has no Oracle `CREATE PROCEDURE` at all | ✅ rich typed nodes: `OracleCreateProcedureStatement`, `OracleBeginEndBlock`, `OracleIfThenStatement`/`OracleIfClause`, `WhileLoopStatement`, `OracleLoopStatement`, `OracleExitStatement`, `OracleExecuteImmediateStatement`, `RaiseStatement`, `OracleReturnStatement`, `OracleNullStatement`, `DeclareCursorVariable` | +| PL/SQL anonymous block `BEGIN IF … END IF; END;` | ❌ `Err` | ✅ parses | +| PL/SQL cursor `FOR rec IN c LOOP` / procedural `CASE` statement | n/a (whole file already `Err`) | ⚠️ `Unparsable` (graceful; surfaces in `sql.parser.*`) | + +Key insight, sharper than §8.2 put it: `sqlparser`'s typed procedural AST +(`IfStatement`, `WhileStatement`, `RaiseStatement`, …) exists primarily for +BigQuery-style *scripting at top level*. The procedure/function *definitions* +that contain 95 % of real procedural SQL hard-fail to parse for both MsSql and +Oracle dialects — and with no error recovery, one such definition zeroes out +every metric for the file. That is the exact opposite of what Phase 3 needs, +and Phase 4 (`sql.lineage.*`) would additionally lose sqruff's `lineage` crate +(in-repo `crates/lineage`; note it is **not published to crates.io**, so +Phase 4 will need either a git pin exception or an upstream publish request). + +Phase-3 implementation consequences adopted from this probe: + +1. **Oracle/PL-SQL metrics ride the typed CST nodes** listed above. +2. **T-SQL metrics fall back to lexed-token counting** (`Keyword`/`Word` + tokens inside procedural statements and `Unparsable` runs). This is still + lexer-derived, never regex-on-text: comments lex as `InlineComment`/ + `BlockComment` and string literals as `SingleQuote` even inside + `Unparsable`, so `-- exec this` or `'goto'` cannot false-match. +3. **ANTLR `tsql` stays the escalation path** (§7.4) if linter-grade T-SQL + depth ever stops being enough — the runtime/codegen infrastructure now + exists in-repo (`mehen-antlr`, `cargo xtask antlr generate`), which §4 + predates. `sqlparser` is not that path. + +Evidence: probe crates `/tmp/sqlparser-probe3` (sqlparser v0.62.0) and +`/tmp/mehen-sql-dump` (sqruff v0.40.0 CST dumper), 2026-08-20. diff --git a/docs/docs.json b/docs/docs.json index f50c99e95..cad14e679 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -232,6 +232,7 @@ "group": "SQL", "pages": [ "metrics/sql/overview", + "metrics/sql/procedural", "metrics/sql/coverage", "metrics/sql/roadmap" ] diff --git a/docs/metrics/sql/overview.mdx b/docs/metrics/sql/overview.mdx index 8eea42e4f..6eed90fc2 100644 --- a/docs/metrics/sql/overview.mdx +++ b/docs/metrics/sql/overview.mdx @@ -86,12 +86,15 @@ These `sql.*` keys are published today (raw metrics — research foundation §15 | `sql.select.*` | `star_count`, `outer_star_count`, `expression_without_alias_count`, `output_alias_coverage`. | | `sql.identifier.*`, `sql.alias.*`, `sql.relation.*` | `unqualified_column_ratio`, `quoted_count`, `table_alias_count`, `ref_count`. | | `sql.object.*`, `sql.dml.*`, `sql.ddl.*`, `sql.dcl.*`, `sql.transaction.*` | Object-touch and migration-risk counts (`read_count`, `write_count`, `drop_count`, `truncate_count`, `update_without_where_count`, …). | +| `sql.procedural.*` | Control-flow metrics for PL/SQL, T-SQL, MySQL, and BigQuery-scripting routines — see [Procedural SQL metrics](/metrics/sql/procedural). | | `sql.dialect.*` | `confidence`, `conflict_count`, `requested`, `directive_present`, `is_`. | | `sql.parser.*` | `diagnostic_count`, `unparsable_segment_count`, `unparsable_line_count`, `unparsable_ratio`. | | `sql.halstead.*` | `distinct_operators`, `distinct_operands`, `total_operators`, `total_operands`, `vocabulary`, `length`, `volume`, `difficulty`, `effort`. | -Procedural-SQL metrics (`sql.procedural.*` — cyclomatic/cognitive complexity for PL/SQL and T-SQL -routines) remain on the [roadmap](/metrics/sql/roadmap). +Statements are classified into normalized kinds (`select`, `insert`, `create_table_as`, +`anonymous_block`, `procedural`, …) with per-kind counts. Routine definitions become +function-shaped spaces carrying per-routine complexity — see +[Procedural SQL metrics](/metrics/sql/procedural). ## Composite scores diff --git a/docs/metrics/sql/procedural.mdx b/docs/metrics/sql/procedural.mdx new file mode 100644 index 000000000..b9b644427 --- /dev/null +++ b/docs/metrics/sql/procedural.mdx @@ -0,0 +1,139 @@ +--- +title: "Procedural SQL metrics" +description: "Control-flow complexity for PL/SQL, T-SQL, MySQL, and BigQuery-scripting routines: cyclomatic and cognitive complexity, blocks, loops, exception handlers, and dynamic SQL." +keywords: ["plsql complexity", "tsql complexity", "stored procedure metrics", "cyclomatic sql", "dynamic sql"] +--- + +Ordinary declarative SQL has no imperative control flow — that is why the +[`sql.*` family](/metrics/sql/overview) is built around relational structure. Stored procedures, +functions, triggers, and anonymous blocks are the exception: they *do* branch, loop, raise, and +retry, so for exactly these regions mehen publishes a classic control-flow metric family, +`sql.procedural.*`. + +Think of it as the boundary between two textbooks: a `SELECT` with ten joins is measured like a +query (dataflow), while the `IF`/`LOOP`/`EXCEPTION` scaffolding around it is measured like a +program (control flow). One file can contain both; the two families never double-count a +construct — `CASE` *expressions* stay in `sql.case.*`, and only `CASE` **statements** (the ones +closed by `END CASE`) count here. + +## What mehen emits + +| Key | Type | Description | +|---|---|---| +| `sql.procedural.routine_count` | int | Routine definitions: procedures, functions, triggers, incl. package/type-body members. | +| `sql.procedural.block_count` | int | `BEGIN … END` block openers (plain, `TRY`, `CATCH`); transaction-control `BEGIN` excluded. | +| `sql.procedural.max_block_depth` | int | Deepest `BEGIN … END` nesting. | +| `sql.procedural.if_count` | int | `IF` statements plus `ELSIF`/`ELSEIF` branches. | +| `sql.procedural.loop_count` | int | Loops of any flavor: `LOOP`, `WHILE`, `FOR … LOOP/DO`, `REPEAT`. | +| `sql.procedural.case_statement_count` | int | Procedural `CASE` statements (`END CASE`), not CASE expressions. | +| `sql.procedural.exception_handler_count` | int | PL/SQL `EXCEPTION WHEN … THEN` handlers and T-SQL `BEGIN CATCH`. | +| `sql.procedural.return_count` | int | `RETURN` statements (a PL/SQL header's `RETURN ` is not one). | +| `sql.procedural.raise_throw_count` | int | `RAISE`, `RAISE_APPLICATION_ERROR`, `THROW`, `RAISERROR`, `SIGNAL`, `RESIGNAL`. | +| `sql.procedural.dynamic_sql_count` | int | `EXECUTE IMMEDIATE`, `sp_executesql`, `EXEC('…')`, `PREPARE … FROM`, `DBMS_SQL`. | +| `sql.procedural.cyclomatic_complexity` | float | Control-flow paths, Sonar's PL/SQL increments (below). | +| `sql.procedural.cognitive_complexity` | float | Comprehension burden with nesting penalties (below). | +| `sql.structural_complexity.max_embedded_query` | float | The worst [structural complexity](/metrics/sql/overview#composite-scores) of the queries embedded in any single routine. | + +File-level keys aggregate the whole file. Each routine additionally becomes a function-shaped +space carrying its own `sql.procedural.cyclomatic_complexity`, `sql.procedural.cognitive_complexity`, +and embedded `sql.structural_complexity` — the numbers `mehen top-offenders` shows next to a +routine name. Increments are attributed to the *innermost* enclosing routine, so a subprogram +declared inside another routine's `DECLARE` section owns its own branches. + +## Cyclomatic complexity + +The increments follow SonarSource's documented PL/SQL model — each item adds one independent +path: + +- the routine or anonymous block itself (the entry path); +- each `IF` and each `ELSIF`/`ELSEIF` branch; +- each loop (`LOOP`, `WHILE`, `FOR`, `REPEAT`); +- each `WHEN` arm of a procedural `CASE` statement; +- each exception handler (`WHEN … THEN` in an exception section, `BEGIN CATCH`); +- each conditional jump: `EXIT WHEN` / `CONTINUE WHEN`; +- each `RAISE`/`THROW`-family statement; +- each boolean `AND`/`OR` inside a body (the `AND` of `BETWEEN … AND …` is range syntax, not a + boolean). + +One documented deviation from Sonar: `WHEN` arms of CASE **expressions** are not counted here, +because they already belong to the declarative `sql.case.*` family — mehen keeps the two families +disjoint where Sonar reports a single number. + +```sql +create or replace procedure retry_orders(p_max number) is -- +1 entry +begin + for i in 1..p_max loop -- +1 loop + if mod(i, 2) = 0 and i > 2 then -- +1 if, +1 AND + update orders set state = 'RETRY' where id = i; + elsif i = 1 then -- +1 elsif + null; + end if; + exit when i > 10; -- +1 exit when + end loop; +exception + when others then -- +1 handler + raise; -- +1 raise +end; -- cyclomatic = 8 +``` + +## Cognitive complexity + +Mirrors the spirit of [code cognitive complexity](/metrics/code/cognitive): what matters is not +how many paths exist but how hard the flow is to follow. + +- Control structures (`IF`, loops, `CASE` statements, exception handlers) cost `1 + nesting`, + where nesting counts the enclosing control structures — a branch inside a loop inside a handler + costs 3. +- Flat continuations (`ELSIF`, `ELSE`) cost 1 — they extend a decision the reader already holds. +- `GOTO` costs 1 (a linear-flow break). +- Boolean operator *sequences* cost 1 per run of like operators: `a AND b AND c` costs 1, + `a AND b OR c` costs 2. Individual operators do not re-charge. + +## How the measurement works + +mehen's parser (sqruff) gives typed procedural nodes for Oracle, keyword-led statements for +T-SQL, and honest `Unparsable` runs where a dialect grammar gives up (MySQL routine bodies, +T-SQL `TRY/CATCH` tails). All three shapes share one property: the *token stream stays +classified* — comments lex as comments and string literals as literals even inside unparsable +regions. The procedural scanner therefore runs one dialect-agnostic state machine over the +classified tokens of procedural regions only (routine definitions, anonymous blocks, and +unparsable runs that carry unambiguous procedural markers). A comment saying `-- exec this` or a +literal `'goto'` can never count, and a broken `SELECT` never grows procedural metrics. + +The trade-off is honest degradation rather than false precision: when a dialect grammar loses a +routine's body to an `Unparsable` sibling, the body's increments still count file-level but +cannot always be attributed to the routine's space — and `sql.parser.unparsable_ratio` tells you +exactly how much parse confidence the file has lost. + +## How to read it + +| `cyclomatic` per routine | Interpretation | +|---|---| +| 1–10 | Routine business logic. | +| 11–20 | Worth a focused review; consider extracting helpers. | +| 21+ | Likely untestable as a unit — decompose. | + +Dynamic SQL deserves separate attention regardless of complexity: each occurrence also adds a +weighted term (+5) to [`sql.change_risk_score`](/metrics/sql/overview#composite-scores), because +`EXECUTE IMMEDIATE`/`sp_executesql` moves logic outside what any static analyzer — or reviewer — +can see. + +## References + +- SonarSource. *PL/SQL language reference — cyclomatic complexity increments.* + [Docs](https://docs.sonarsource.com/sonarqube-server/analyzing-source-code/languages/pl-sql). +- SonarSource. *T-SQL language reference.* + [Docs](https://docs.sonarsource.com/sonarqube-server/analyzing-source-code/languages/t-sql). +- Campbell, G. A. (2018). *Cognitive Complexity — A new way of measuring understandability.* + SonarSource white paper. [PDF](https://www.sonarsource.com/resources/cognitive-complexity/). +- McCabe, T. J. (1976). *A Complexity Measure.* IEEE Transactions on Software Engineering. + [DOI](https://doi.org/10.1109/TSE.1976.233837). +- Piattini, M. & Martínez, A. *Measuring for Database Programs Maintainability.* + [DOI](https://doi.org/10.1007/3-540-44469-6_7). + +## See also + +- [SQL metrics overview](/metrics/sql/overview) — the declarative `sql.*` families. +- [Cognitive complexity](/metrics/code/cognitive) — the source-code analogue. +- [Cyclomatic complexity](/metrics/code/cyclomatic) — the classic model this family adapts. +- [SQL metrics roadmap](/metrics/sql/roadmap) — implementation phases. diff --git a/docs/metrics/sql/roadmap.mdx b/docs/metrics/sql/roadmap.mdx index a935169ff..d4c2d373b 100644 --- a/docs/metrics/sql/roadmap.mdx +++ b/docs/metrics/sql/roadmap.mdx @@ -42,12 +42,19 @@ higher-is-worse and maintainability/health scores to higher-is-better; prefix a to override. Named profile presets (`sql.analytics_default`, `sql.migration_default`, `sql.procedural_default`) and diff-aware delta gates remain to be wired through the threshold engine. -## Phase 3 — procedural SQL - -- PL/SQL and T-SQL procedural block detection. -- Procedural cyclomatic / cognitive complexity (using Sonar's PL/SQL increments as reference). -- Exception / cursor / loop / dynamic-SQL metrics. -- Embedded query complexity attribution inside routines. +## Phase 3 — procedural SQL ✅ shipped + +- PL/SQL and T-SQL procedural block detection, plus MySQL and BigQuery-scripting constructs. +- Procedural cyclomatic / cognitive complexity (using Sonar's PL/SQL increments as reference), + file-level and per-routine, with contribution evidence. +- Exception / loop / dynamic-SQL / raise metrics; dynamic SQL feeds `sql.change_risk_score`. +- Embedded query complexity attribution inside routines + (`sql.structural_complexity.max_embedded_query` and per-routine spaces). + +See [Procedural SQL metrics](/metrics/sql/procedural) for the metric definitions and the +token-machine measurement model. Known parser-bound limitations (degrade to `Unparsable`, never +mis-count): PL/SQL cursor `FOR` loops and procedural `CASE` statements under the Oracle grammar, +MySQL routine bodies, and PostgreSQL `$$`-quoted function bodies (opaque strings). ## Phase 4 — optional schema and lineage enrichments