Skip to main content

pgsafe/
lib.rs

1//! `pgsafe` is a static safety linter for PostgreSQL DDL migrations.
2//!
3//! It parses SQL using the real PostgreSQL parser and checks every statement
4//! against a set of rules that flag lock-taking or destructive operations,
5//! producing a [`Finding`] for each match together with safe-rewrite guidance.
6//!
7//! The main entry point is [`lint_sql`]. A command-line interface wrapping
8//! the same rules is provided by the `pgsafe` binary.
9#![deny(missing_docs)]
10
11use std::collections::{BTreeMap, BTreeSet};
12
13mod ast;
14mod fix;
15mod output;
16mod rules;
17mod sarif;
18mod suppression;
19mod synthesized;
20
21// `known_rule_ids` names each synthesized lint's `ID`; bring those submodules into
22// crate-root scope so it refers to them unqualified (`timeout::ID`, …), the same as the
23// registered rules in `rules`. The engine dispatch itself lives in `synthesized::run_all`.
24use synthesized::{
25    do_block, enum_value, fk_index, forbid_nullable_fk, forbidden_types, identifier, naming,
26    require_columns, require_comment, require_if_exists, require_not_null, require_pk,
27    require_schema_qualified, timeout, txn,
28};
29
30#[cfg(feature = "cli")]
31pub mod cli;
32
33pub use output::{
34    gate, lint_input, render_errors, render_finding_body, render_finding_human, render_github,
35    render_human, render_human_styled, render_json, render_statement_header, render_summary,
36    ColorWhen, FailOn, FileReport, Format, Styling, SCHEMA_VERSION,
37};
38pub use sarif::render_sarif;
39
40/// The version of the `pgsafe` crate (its `Cargo.toml` `version`).
41///
42/// Tools that embed pgsafe can surface this to report which linter version
43/// produced a given result.
44///
45/// ```
46/// assert!(!pgsafe::VERSION.is_empty());
47/// ```
48pub const VERSION: &str = env!("CARGO_PKG_VERSION");
49
50/// Severity level of a [`Finding`], ordered by increasing severity
51/// (`Warning` < `Error`).
52#[non_exhaustive]
53#[derive(
54    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
55)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58    /// The statement is potentially unsafe but may be acceptable in some contexts.
59    Warning,
60    /// The statement is unsafe and should not be run against a live database.
61    Error,
62}
63
64/// Source position of a finding's statement: byte offset plus 1-based line and
65/// (character) column of the statement's first non-whitespace token.
66#[non_exhaustive]
67#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
68pub struct Location {
69    /// 0-based byte offset of the statement's first non-whitespace token within the input.
70    pub byte: u32,
71    /// 1-based line number of the statement's first non-whitespace character.
72    pub line: u32,
73    /// 1-based character column of the statement's first non-whitespace
74    /// character on its line.
75    pub column: u32,
76}
77
78/// The kind of identifier a naming-convention pattern applies to.
79#[non_exhaustive]
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
81pub enum NameKind {
82    /// A table.
83    Table,
84    /// A column.
85    Column,
86    /// An index.
87    Index,
88    /// A constraint.
89    Constraint,
90    /// A sequence.
91    Sequence,
92    /// A trigger.
93    Trigger,
94    /// A schema.
95    Schema,
96}
97
98impl NameKind {
99    /// The lowercase config-key / display name (`"table"`, `"column"`, …).
100    #[must_use]
101    pub fn as_str(self) -> &'static str {
102        match self {
103            NameKind::Table => "table",
104            NameKind::Column => "column",
105            NameKind::Index => "index",
106            NameKind::Constraint => "constraint",
107            NameKind::Sequence => "sequence",
108            NameKind::Trigger => "trigger",
109            NameKind::Schema => "schema",
110        }
111    }
112}
113
114impl std::fmt::Display for Severity {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Severity::Warning => f.write_str("warning"),
118            Severity::Error => f.write_str("error"),
119        }
120    }
121}
122
123/// Why a [`Finding`] was suppressed by an inline `-- pgsafe:ignore` directive.
124#[non_exhaustive]
125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126pub struct Suppression {
127    /// The reason text supplied in the directive.
128    pub reason: String,
129}
130
131/// What a rule emits when it matches. The engine adds identity and positional context.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct RuleHit {
134    pub message: String,
135    pub guidance: String,
136    pub fix: Option<crate::fix::FixDraft>,
137}
138
139/// A single text edit within the linted SQL: replace bytes `[start, end)` with
140/// `replacement`. `start == end` is a pure insertion.
141///
142/// All offsets are 0-based byte positions into the linted SQL string and are
143/// guaranteed to fall on UTF-8 character boundaries within the range of the
144/// linted input.
145#[non_exhaustive]
146#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147pub struct FixEdit {
148    /// 0-based byte offset where the edit begins.
149    pub start: u32,
150    /// 0-based byte offset where the edit ends (`== start` for an insertion).
151    pub end: u32,
152    /// Text to splice in place of `[start, end)`.
153    pub replacement: String,
154}
155
156/// A machine-applicable remediation for a [`Finding`]: a short title plus the
157/// edits that make the statement safe. Present only for findings whose fix is an
158/// unambiguous mechanical change.
159///
160/// # Guarantees (upheld by construction)
161///
162/// A consumer can rely on the following invariants:
163///
164/// - **Non-empty**: `edits` contains at least one edit.
165/// - **Ascending order**: edits are sorted by `start` in ascending order.
166/// - **Non-overlapping**: for each consecutive pair, `prev.end <= next.start`.
167/// - **In-range**: every `start` and `end` offset falls within the byte length
168///   of the linted SQL string.
169/// - **UTF-8 boundaries**: offsets land on UTF-8 character boundaries so that
170///   splicing via `str::replace_range` does not panic.
171///
172/// Apply edits **high-to-low** (sort descending by `start` before splicing) so
173/// that each splice does not shift the byte offsets of earlier edits.
174#[non_exhaustive]
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct Fix {
177    /// Short label for the fix, e.g. `"Add CONCURRENTLY"`.
178    pub title: String,
179    /// The edits to apply, in ascending `start` order; non-overlapping; at least one.
180    pub edits: Vec<FixEdit>,
181}
182
183/// A safety finding reported for a specific SQL statement.
184#[non_exhaustive]
185#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
186pub struct Finding {
187    /// Identifier of the rule that triggered this finding (e.g. `"add-index-non-concurrent"`).
188    pub rule_id: String,
189    /// Severity level of the finding.
190    pub severity: Severity,
191    /// Short human-readable description of what is unsafe about the statement.
192    pub message: String,
193    /// Guidance on how to rewrite the statement safely.
194    pub guidance: String,
195    /// 0-based index of the SQL statement within the input that triggered this
196    /// finding.  The human-readable CLI output renders this as `statement #0`,
197    /// `statement #1`, etc.
198    pub statement_index: usize,
199    /// Source location of the statement's first non-whitespace token.
200    pub location: Location,
201    /// Trimmed text of the statement that triggered the finding.
202    pub snippet: String,
203    /// `Some` when an inline `-- pgsafe:ignore` directive suppressed this finding.
204    /// A suppressed finding is still reported but does not affect the gate exit code.
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub suppression: Option<Suppression>,
207    /// `Some` when this finding has a safe, machine-applicable fix.
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub fix: Option<Fix>,
210}
211
212impl Finding {
213    /// Whether an inline directive suppressed this finding.
214    #[must_use]
215    pub fn is_suppressed(&self) -> bool {
216        self.suppression.is_some()
217    }
218}
219
220/// Options that adjust linting beyond the default static analysis.
221#[non_exhaustive]
222#[derive(Debug, Clone, Default)]
223pub struct LintOptions {
224    /// Treat the input as already running inside a transaction (e.g. a migration tool that wraps
225    /// each migration implicitly), so `CONCURRENTLY` index operations are flagged even without an
226    /// explicit `BEGIN`. Default `false`.
227    pub assume_in_transaction: bool,
228    /// Rule ids that must not run for this input (their findings — and, for synthesized rules,
229    /// their syntheses — are skipped). Default empty.
230    pub disabled_rules: BTreeSet<String>,
231    /// Rule ids explicitly enabled in config. Required for the **boolean** opt-in policy rules
232    /// (`require-primary-key`, `require-not-null`, `require-if-exists`, `require-comment`,
233    /// `forbid-nullable-fk`, `unchecked-do-block`) to run; has no effect on rules that are on by
234    /// default. The **data-configured** policies (`naming-convention`, `forbidden-column-type`,
235    /// `require-columns`) activate when their own field below is non-empty, independent of this set.
236    /// Default empty.
237    pub enabled_rules: BTreeSet<String>,
238    /// Per-rule severity overrides applied to the findings this run emits, keyed by rule id.
239    /// Default empty.
240    pub severity_overrides: BTreeMap<String, Severity>,
241    /// Per-kind naming-convention patterns (raw regex strings). The `naming-convention` rule runs only
242    /// when this is non-empty. Default empty.
243    pub naming_patterns: BTreeMap<NameKind, String>,
244    /// Forbidden column types mapped to a suggested replacement (raw spellings). The
245    /// `forbidden-column-type` rule runs only when this is non-empty. Default empty.
246    pub forbidden_column_types: BTreeMap<String, String>,
247    /// Column names every `CREATE TABLE` must include. Matched case-insensitively: the rule folds
248    /// each name to lower case to match PostgreSQL's unquoted-identifier folding, so any casing here
249    /// works (`Created_At` matches a `created_at` column). A quoted, mixed-case column keeps its case
250    /// and is not matched. The `require-columns` rule runs only when this is non-empty. Default empty.
251    pub required_columns: BTreeSet<String>,
252}
253
254/// Error returned when `pgsafe` cannot process the provided SQL.
255#[non_exhaustive]
256#[derive(Debug, thiserror::Error)]
257pub enum LintError {
258    /// The SQL input could not be parsed by the PostgreSQL parser.
259    #[error("parse error: {0}")]
260    Parse(String),
261}
262
263/// 1-based line and character-column of a byte offset within `sql`.
264pub(crate) fn line_col(sql: &str, byte: usize) -> (u32, u32) {
265    let mut line = 1u32;
266    let mut column = 1u32;
267    for (i, ch) in sql.char_indices() {
268        if i >= byte {
269            break;
270        }
271        if ch == '\n' {
272            line += 1;
273            column = 1;
274        } else {
275            column += 1;
276        }
277    }
278    (line, column)
279}
280
281/// Every lint-rule id: the registered rules plus the engine-synthesized ones, in push order
282/// (`concurrently-in-transaction`, `require-timeout`, `identifier-too-long`,
283/// `fk-without-covering-index`, `enum-value-used-in-transaction`, `require-primary-key`,
284/// `require-not-null`, `naming-convention`, `forbidden-column-type`, `require-if-exists`,
285/// `unchecked-do-block`, `require-comment`, `require-columns`, `forbid-nullable-fk`,
286/// `require-schema-qualified`).
287/// NOT the `suppression-*` hygiene ids.
288pub(crate) fn known_rule_ids() -> Vec<&'static str> {
289    let mut ids = rules::rule_ids();
290    ids.push(txn::ID);
291    ids.push(timeout::ID);
292    ids.push(identifier::ID);
293    ids.push(fk_index::ID);
294    ids.push(enum_value::ID);
295    ids.push(require_pk::ID);
296    ids.push(require_schema_qualified::ID);
297    ids.push(require_not_null::ID);
298    ids.push(naming::ID);
299    ids.push(forbidden_types::ID);
300    ids.push(require_if_exists::ID);
301    ids.push(do_block::ID);
302    ids.push(require_comment::ID);
303    ids.push(require_columns::ID);
304    ids.push(forbid_nullable_fk::ID);
305    ids
306}
307
308/// Every lint-rule id this build can emit — the registered AST rules plus the
309/// engine-synthesized/policy rules — in stable order. The public rule catalog,
310/// e.g. for `pgsafe --list-rules` and external tooling.
311#[must_use]
312pub fn list_rule_ids() -> Vec<&'static str> {
313    known_rule_ids()
314}
315
316/// Lint `sql` under `options`, returning findings in source order.
317///
318/// The SQL is parsed with the real PostgreSQL parser, so `sql` must be valid
319/// PostgreSQL syntax.  Every statement in the input is checked against all
320/// enabled rules; the returned [`Vec`] preserves source order.
321///
322/// # Errors
323/// Returns [`LintError::Parse`] if `sql` cannot be parsed by the PostgreSQL parser.
324///
325/// # Examples
326/// ```
327/// let findings = pgsafe::lint_sql(
328///     "CREATE INDEX i ON t (x)",
329///     &pgsafe::LintOptions::default(),
330/// ).unwrap();
331/// assert!(!findings.is_empty());
332/// ```
333pub fn lint_sql(sql: &str, options: &LintOptions) -> Result<Vec<Finding>, LintError> {
334    let parsed = crate::ast::parse(sql).map_err(|e| LintError::Parse(e.to_string()))?;
335    let stmts = &parsed.protobuf.stmts;
336    let comments = suppression::scan_comments(sql)?;
337    let geoms = suppression::geometry(sql, stmts, &comments);
338    let rules = rules::all_rules();
339    let mut findings = Vec::new();
340    let mut hits = Vec::new();
341    for (i, raw) in stmts.iter().enumerate() {
342        let Some(stmt_box) = raw.stmt.as_ref() else {
343            continue;
344        };
345        let Some(node) = stmt_box.node.as_ref() else {
346            continue;
347        };
348        let g = &geoms[i];
349        let (line, column) = line_col(sql, g.start);
350        let location = Location {
351            byte: u32::try_from(g.start).unwrap_or(u32::MAX),
352            line,
353            column,
354        };
355        let snippet = sql.get(g.start..g.end).unwrap_or("").trim().to_string();
356        for rule in rules {
357            if options.disabled_rules.contains(rule.id()) {
358                continue;
359            }
360            rule.check(node, &mut hits);
361            for h in hits.drain(..) {
362                let fix = h.fix.as_ref().and_then(|d| {
363                    let r = crate::fix::resolve(d, sql, g.start, g.body_end);
364                    debug_assert!(
365                        r.is_some() || d.may_legitimately_not_resolve(),
366                        "rule {}: fix draft {:?} failed to resolve",
367                        rule.id(),
368                        d.title
369                    );
370                    r
371                });
372                findings.push(Finding {
373                    rule_id: rule.id().to_string(),
374                    severity: rule.severity(),
375                    message: h.message,
376                    guidance: h.guidance,
377                    statement_index: i,
378                    location,
379                    snippet: snippet.clone(),
380                    suppression: None,
381                    fix,
382                });
383            }
384        }
385    }
386    let (mut findings, new_table_dropped) =
387        synthesized::run_all(sql, stmts, &geoms, options, findings);
388    // A CONCURRENTLY autofix would fail at runtime inside a transaction block; withdraw it
389    // there (unconditionally — disabling the txn warning must not re-enable an unsafe fix).
390    synthesized::txn::suppress_concurrently_fix_in_transaction(
391        stmts,
392        &mut findings,
393        options.assume_in_transaction,
394    );
395    if !options.severity_overrides.is_empty() {
396        for f in &mut findings {
397            if let Some(&sev) = options.severity_overrides.get(&f.rule_id) {
398                f.severity = sev;
399            }
400        }
401    }
402    let known_ids = known_rule_ids();
403    suppression::resolve(
404        sql,
405        &geoms,
406        &comments,
407        findings,
408        &known_ids,
409        &new_table_dropped,
410        &options.disabled_rules,
411    )
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn severity_is_ordered_warning_below_error() {
420        assert!(Severity::Warning < Severity::Error);
421    }
422
423    #[test]
424    fn empty_string_returns_no_findings() {
425        assert_eq!(lint_sql("", &LintOptions::default()).unwrap(), Vec::new());
426    }
427
428    #[test]
429    fn comment_only_returns_no_findings() {
430        assert_eq!(
431            lint_sql("-- just a comment\n", &LintOptions::default()).unwrap(),
432            Vec::new()
433        );
434    }
435
436    #[test]
437    fn valid_sql_returns_no_findings() {
438        assert_eq!(
439            lint_sql("SELECT 1", &LintOptions::default()).unwrap(),
440            Vec::new()
441        );
442    }
443
444    #[test]
445    fn invalid_sql_is_parse_error() {
446        assert!(matches!(
447            lint_sql("ALTER TABLE", &LintOptions::default()),
448            Err(LintError::Parse(_))
449        ));
450    }
451
452    #[test]
453    fn engine_finding_order_and_positional_enrichment() {
454        let sql = "CREATE INDEX i ON t (x);\n\nALTER TABLE t ALTER COLUMN a TYPE bigint, ALTER COLUMN a SET NOT NULL;\n";
455        let f = lint_sql(sql, &LintOptions::default()).unwrap();
456
457        // Order: statement source order; within a statement, rule-loop findings
458        // (registry order) precede engine-synthesized findings.
459        // set-not-null is registered before alter-column-type, so it comes first;
460        // require-timeout is synthesized after the rule loop for each statement.
461        let ids: Vec<&str> = f.iter().map(|x| x.rule_id.as_str()).collect();
462        assert_eq!(
463            ids,
464            [
465                "add-index-non-concurrent",
466                "require-timeout",
467                "set-not-null",
468                "alter-column-type",
469                "require-timeout",
470            ]
471        );
472
473        assert_eq!(f[0].statement_index, 0);
474        assert_eq!(f[1].statement_index, 0);
475        assert_eq!(f[2].statement_index, 1);
476        assert_eq!(f[3].statement_index, 1);
477        assert_eq!(f[4].statement_index, 1);
478
479        // location.byte points at the statement's first real token (NOT leading whitespace):
480        for finding in &f {
481            let b = finding.location.byte as usize;
482            assert!(
483                sql[b..].starts_with(&finding.snippet),
484                "location.byte must point at the trimmed statement start"
485            );
486        }
487
488        // line/column: CREATE on line 1, the ALTER on line 3 (after the blank line).
489        assert_eq!((f[0].location.line, f[0].location.column), (1, 1));
490        assert_eq!((f[2].location.line, f[2].location.column), (3, 1));
491        assert_eq!(f[3].location.line, 3);
492
493        assert!(f[2].snippet.starts_with("ALTER TABLE"));
494    }
495
496    #[test]
497    fn findings_json_round_trip() {
498        let findings = lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default()).unwrap();
499        let json = serde_json::to_string(&findings).unwrap();
500        let back: Vec<Finding> = serde_json::from_str(&json).unwrap();
501        assert_eq!(findings, back);
502    }
503
504    #[test]
505    fn suppression_field_defaults_to_none_and_is_omitted_from_json() {
506        let f = &lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default()).unwrap()[0];
507        assert!(!f.is_suppressed());
508        let json = serde_json::to_string(f).unwrap();
509        assert!(
510            !json.contains("suppression"),
511            "None suppression must be omitted"
512        );
513    }
514
515    #[test]
516    fn suppression_round_trips_when_present() {
517        let mut f = lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default())
518            .unwrap()
519            .remove(0);
520        f.suppression = Some(Suppression {
521            reason: "off-peak deploy".into(),
522        });
523        assert!(f.is_suppressed());
524        let back: Finding = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
525        assert_eq!(back.suppression.unwrap().reason, "off-peak deploy");
526    }
527
528    #[test]
529    fn assume_in_transaction_flags_top_level_concurrently() {
530        let sql = "CREATE INDEX CONCURRENTLY i ON t (x)";
531        let on = lint_sql(
532            sql,
533            &LintOptions {
534                assume_in_transaction: true,
535                ..LintOptions::default()
536            },
537        )
538        .unwrap();
539        assert!(on
540            .iter()
541            .any(|f| f.rule_id == "concurrently-in-transaction"));
542        let off = lint_sql(sql, &LintOptions::default()).unwrap();
543        assert!(!off
544            .iter()
545            .any(|f| f.rule_id == "concurrently-in-transaction"));
546    }
547
548    fn opts(disabled: &[&str], overrides: &[(&str, Severity)]) -> LintOptions {
549        LintOptions {
550            disabled_rules: disabled.iter().map(|s| (*s).to_string()).collect(),
551            severity_overrides: overrides
552                .iter()
553                .map(|(k, v)| ((*k).to_string(), *v))
554                .collect(),
555            ..LintOptions::default()
556        }
557    }
558
559    #[test]
560    fn disabled_registered_rule_produces_no_finding() {
561        let fs = lint_sql("DROP TABLE x;", &opts(&["drop-table"], &[])).unwrap();
562        assert!(!fs.iter().any(|f| f.rule_id == "drop-table"));
563    }
564
565    #[test]
566    fn disabled_synthesized_rule_is_silent() {
567        // require-timeout normally fires on a bare ALTER TABLE.
568        let fs = lint_sql(
569            "ALTER TABLE t ADD COLUMN c int;",
570            &opts(&["require-timeout"], &[]),
571        )
572        .unwrap();
573        assert!(!fs.iter().any(|f| f.rule_id == "require-timeout"));
574    }
575
576    #[test]
577    fn severity_override_changes_a_findings_severity() {
578        let fs = lint_sql(
579            "CREATE INDEX i ON t (x);",
580            &opts(&[], &[("add-index-non-concurrent", Severity::Warning)]),
581        )
582        .unwrap();
583        let f = fs
584            .iter()
585            .find(|f| f.rule_id == "add-index-non-concurrent")
586            .unwrap();
587        assert_eq!(f.severity, Severity::Warning); // default is Error
588    }
589
590    #[test]
591    fn directive_for_a_disabled_rule_is_not_reported_unused() {
592        let sql = "-- pgsafe:ignore drop-table  disabled in config anyway\nDROP TABLE x;";
593        let fs = lint_sql(sql, &opts(&["drop-table"], &[])).unwrap();
594        assert!(!fs.iter().any(|f| f.rule_id == "suppression-unused"));
595        assert!(!fs.iter().any(|f| f.rule_id == "drop-table"));
596    }
597
598    #[test]
599    fn hazard_inside_do_block_is_flagged_by_the_real_rule() {
600        let f = lint_sql(
601            "DO $$ BEGIN CREATE INDEX i ON t (c); END $$;",
602            &LintOptions::default(),
603        )
604        .unwrap();
605        let hit = f
606            .iter()
607            .find(|f| f.rule_id == "add-index-non-concurrent")
608            .expect("a non-CONCURRENTLY CREATE INDEX in a DO block must be flagged");
609        assert!(hit.message.starts_with("Inside a DO block:"));
610        assert!(hit.snippet.contains("CREATE INDEX"));
611    }
612
613    #[test]
614    fn safe_do_block_produces_no_findings() {
615        let f = lint_sql("DO $$ BEGIN PERFORM 1; END $$;", &LintOptions::default()).unwrap();
616        assert!(f.is_empty());
617    }
618
619    #[test]
620    fn multiple_hazards_in_one_do_block_each_flagged() {
621        let f = lint_sql(
622            "DO $$ BEGIN CREATE INDEX i ON t (c); DROP TABLE u; END $$;",
623            &LintOptions::default(),
624        )
625        .unwrap();
626        assert!(f.iter().any(|f| f.rule_id == "add-index-non-concurrent"));
627        assert!(f.iter().any(|f| f.rule_id == "drop-table"));
628    }
629
630    #[test]
631    fn embedded_finding_respects_disabled_rules() {
632        let opts = LintOptions {
633            disabled_rules: ["add-index-non-concurrent".to_string()]
634                .into_iter()
635                .collect(),
636            ..LintOptions::default()
637        };
638        let f = lint_sql("DO $$ BEGIN CREATE INDEX i ON t (c); END $$;", &opts).unwrap();
639        assert!(f.iter().all(|f| f.rule_id != "add-index-non-concurrent"));
640    }
641
642    #[test]
643    fn embedded_finding_is_suppressible_on_the_do_statement() {
644        let sql = "-- pgsafe:ignore add-index-non-concurrent reviewed\n\
645                   DO $$ BEGIN CREATE INDEX i ON t (c); END $$;";
646        let f = lint_sql(sql, &LintOptions::default()).unwrap();
647        let hit = f
648            .iter()
649            .find(|f| f.rule_id == "add-index-non-concurrent")
650            .expect("finding must still be present, just suppressed");
651        assert!(hit.is_suppressed());
652    }
653
654    #[test]
655    fn mixed_do_block_yields_both_embedded_and_residue_findings() {
656        let opts = LintOptions {
657            enabled_rules: ["unchecked-do-block".to_string()].into_iter().collect(),
658            ..LintOptions::default()
659        };
660        let f = lint_sql(
661            "DO $$ BEGIN CREATE INDEX i ON t (c); EXECUTE 'DROP TABLE u'; END $$;",
662            &opts,
663        )
664        .unwrap();
665        assert!(f.iter().any(|f| f.rule_id == "add-index-non-concurrent"
666            && f.message.starts_with("Inside a DO block:")));
667        assert!(f.iter().any(|f| f.rule_id == "unchecked-do-block"));
668    }
669
670    #[test]
671    fn finding_serializes_fix_when_present() {
672        let f = Finding {
673            rule_id: "add-index-non-concurrent".into(),
674            severity: Severity::Error,
675            message: "m".into(),
676            guidance: "g".into(),
677            statement_index: 0,
678            location: Location {
679                byte: 0,
680                line: 1,
681                column: 1,
682            },
683            snippet: "CREATE INDEX i ON t (c)".into(),
684            suppression: None,
685            fix: Some(Fix {
686                title: "Add CONCURRENTLY".into(),
687                edits: vec![FixEdit {
688                    start: 12,
689                    end: 12,
690                    replacement: " CONCURRENTLY".into(),
691                }],
692            }),
693        };
694        let json = serde_json::to_string(&f).unwrap();
695        assert!(
696            json.contains(r#""fix":{"title":"Add CONCURRENTLY""#),
697            "{json}"
698        );
699        assert!(
700            json.contains(r#""edits":[{"start":12,"end":12,"replacement":" CONCURRENTLY"}]"#),
701            "{json}"
702        );
703        // Round-trips back to an equal value.
704        assert_eq!(serde_json::from_str::<Finding>(&json).unwrap(), f);
705    }
706
707    #[test]
708    fn finding_omits_fix_when_absent() {
709        let f = Finding {
710            rule_id: "drop-column".into(),
711            severity: Severity::Warning,
712            message: "m".into(),
713            guidance: "g".into(),
714            statement_index: 0,
715            location: Location {
716                byte: 0,
717                line: 1,
718                column: 1,
719            },
720            snippet: "ALTER TABLE t DROP COLUMN c".into(),
721            suppression: None,
722            fix: None,
723        };
724        assert!(!serde_json::to_string(&f).unwrap().contains("fix"));
725    }
726
727    #[test]
728    fn rule_hit_default_carries_no_fix() {
729        // A rule that emits no fix still produces a finding with fix == None.
730        let fs = lint_sql("DROP TABLE t;", &LintOptions::default()).unwrap();
731        let f = fs.iter().find(|f| f.rule_id == "drop-table").unwrap();
732        assert!(f.fix.is_none());
733    }
734
735    #[test]
736    fn concurrently_fix_withdrawn_inside_transaction() {
737        // Every CONCURRENTLY-adding rule loses its fix inside a txn, but the finding stays.
738        for (sql, rule) in [
739            (
740                "BEGIN; CREATE INDEX i ON t (c); COMMIT;",
741                "add-index-non-concurrent",
742            ),
743            ("BEGIN; DROP INDEX i; COMMIT;", "drop-index-non-concurrent"),
744            ("BEGIN; REINDEX INDEX i; COMMIT;", "reindex-non-concurrent"),
745            (
746                "BEGIN; ALTER TABLE p DETACH PARTITION p1; COMMIT;",
747                "detach-partition-non-concurrent",
748            ),
749        ] {
750            let fs = lint_sql(sql, &LintOptions::default()).unwrap();
751            let f = fs
752                .iter()
753                .find(|f| f.rule_id == rule)
754                .expect("finding present");
755            assert!(
756                f.fix.is_none(),
757                "fix must be withdrawn in a txn for {rule}: {sql}"
758            );
759        }
760    }
761
762    #[test]
763    fn concurrently_fix_withdrawn_only_for_the_in_transaction_statement() {
764        // One CREATE INDEX outside a txn (keeps its fix) and one inside (withdrawn), in a
765        // single input — pins the finding statement_index ↔ in-transaction alignment.
766        let sql = "CREATE INDEX a ON t (c); BEGIN; CREATE INDEX b ON t (c); COMMIT;";
767        let fs = lint_sql(sql, &LintOptions::default()).unwrap();
768        let hits: Vec<_> = fs
769            .iter()
770            .filter(|f| f.rule_id == "add-index-non-concurrent")
771            .collect();
772        assert_eq!(hits.len(), 2, "both CREATE INDEX statements are flagged");
773        let outside = hits.iter().find(|f| f.statement_index == 0).unwrap();
774        let inside = hits.iter().find(|f| f.statement_index == 2).unwrap();
775        assert!(outside.fix.is_some(), "outside-txn fix must be kept");
776        assert!(inside.fix.is_none(), "in-txn fix must be withdrawn");
777    }
778
779    #[test]
780    fn concurrently_fix_kept_outside_transaction() {
781        // Regression guard: outside a txn the fix is still offered.
782        let fs = lint_sql("CREATE INDEX i ON t (c);", &LintOptions::default()).unwrap();
783        let f = fs
784            .iter()
785            .find(|f| f.rule_id == "add-index-non-concurrent")
786            .unwrap();
787        assert!(f.fix.is_some());
788    }
789
790    #[test]
791    fn assume_in_transaction_withdraws_top_level_concurrently_fix() {
792        let opts = LintOptions {
793            assume_in_transaction: true,
794            ..LintOptions::default()
795        };
796        let fs = lint_sql("CREATE INDEX i ON t (c);", &opts).unwrap();
797        let f = fs
798            .iter()
799            .find(|f| f.rule_id == "add-index-non-concurrent")
800            .unwrap();
801        assert!(f.fix.is_none());
802    }
803}