1use crate::parser::ParsedStatement;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum FixSeverity {
32 Info,
34 Warning,
36 Blocking,
38}
39
40impl std::fmt::Display for FixSeverity {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 FixSeverity::Info => write!(f, "INFO"),
44 FixSeverity::Warning => write!(f, "WARNING"),
45 FixSeverity::Blocking => write!(f, "BLOCKING"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FixSuggestion {
53 pub rule_id: String,
55 pub title: String,
57 pub explanation: String,
59 pub fixed_sql: Option<String>,
61 pub migration_steps: Option<Vec<String>>,
63 pub severity: FixSeverity,
65 pub docs_url: Option<String>,
67 pub auto_fixable: bool,
69}
70
71pub fn suggest_fixes(
81 statements: &[ParsedStatement],
82 row_counts: &HashMap<String, u64>,
83) -> Vec<FixSuggestion> {
84 let mut suggestions: Vec<FixSuggestion> = Vec::new();
85
86 for stmt in statements {
87 match stmt {
88 ParsedStatement::CreateIndex {
90 table,
91 columns,
92 concurrently,
93 index_name,
94 unique,
95 } => {
96 if !concurrently {
97 let rows = row_counts.get(table).copied().unwrap_or(0);
98 let col_list = columns.join(", ");
99 let unique_kw = if *unique { "UNIQUE " } else { "" };
100 let name = index_name.as_deref().unwrap_or("idx_name");
101 suggestions.push(rule_r01_index_concurrently(
102 table, &col_list, unique_kw, name, rows,
103 ));
104 }
105 }
106
107 ParsedStatement::AlterTableAddColumn { table, column } => {
109 let rows = row_counts.get(table).copied().unwrap_or(0);
110 if !column.nullable && !column.has_default {
112 suggestions.push(rule_r02_add_not_null(
113 table,
114 &column.name,
115 &column.data_type,
116 rows,
117 ));
118 }
119 if column.has_default {
121 suggestions.push(rule_r09_add_column_default(
122 table,
123 &column.name,
124 &column.data_type,
125 rows,
126 ));
127 }
128 }
129
130 ParsedStatement::AlterTableDropColumn { table, column, .. } => {
132 let rows = row_counts.get(table).copied().unwrap_or(0);
133 suggestions.push(rule_r03_drop_column(table, column, rows));
134 }
135
136 ParsedStatement::AlterTableAddForeignKey { table, fk } => {
138 suggestions.push(rule_r04_missing_fk_index(table, &fk.columns));
139 }
140
141 ParsedStatement::AlterTableRenameColumn { table, old, new } => {
143 suggestions.push(rule_r05_rename_column(table, old, new));
144 }
145
146 ParsedStatement::AlterTableRenameTable { old, new } => {
148 suggestions.push(rule_r06_rename_table(old, new));
149 }
150
151 ParsedStatement::AlterTableAlterColumnType {
153 table,
154 column,
155 new_type,
156 } => {
157 let rows = row_counts.get(table).copied().unwrap_or(0);
158 suggestions.push(rule_r07_alter_column_type(table, column, new_type, rows));
160 }
161
162 ParsedStatement::DropTable { tables, .. } => {
164 for t in tables {
165 let rows = row_counts.get(t).copied().unwrap_or(0);
166 let est = estimate_lock_secs(rows);
167 if est > 5 {
168 suggestions.push(rule_r08_long_lock(&format!("DROP TABLE {t}"), est));
169 }
170 }
171 }
172
173 _ => {}
174 }
175 }
176
177 suggestions.sort_by(|a, b| b.severity.cmp(&a.severity));
179 suggestions
180}
181
182pub fn apply_fixes(sql: &str, suggestions: &[FixSuggestion]) -> String {
194 let has_r01 = suggestions
195 .iter()
196 .any(|s| s.rule_id == "R01" && s.auto_fixable);
197 if !has_r01 {
198 return sql.to_string();
199 }
200 if is_inside_transaction_block(sql) {
201 return rewrite_index_concurrent_in_txn(sql);
203 }
204 rewrite_index_concurrent(sql)
205}
206
207fn is_inside_transaction_block(sql: &str) -> bool {
210 let upper = sql.to_uppercase();
211 let has_begin = upper.contains("BEGIN") || upper.contains("START TRANSACTION");
212 let has_commit = upper.contains("COMMIT") || upper.contains("ROLLBACK");
213 has_begin && has_commit
214}
215
216fn rewrite_index_concurrent_in_txn(sql: &str) -> String {
219 sql.lines()
220 .flat_map(|line| {
221 let upper = line.to_uppercase();
222 if (upper.contains("CREATE INDEX") || upper.contains("CREATE UNIQUE INDEX"))
223 && !upper.contains("CONCURRENTLY")
224 {
225 vec![
226 "-- ⚠️ schemarisk: Cannot use CONCURRENTLY inside a transaction block."
227 .to_string(),
228 "-- Remove BEGIN/COMMIT wrapper and run this statement standalone.".to_string(),
229 line.to_string(),
230 ]
231 } else {
232 vec![line.to_string()]
233 }
234 })
235 .collect::<Vec<_>>()
236 .join("\n")
237}
238
239fn rule_r01_index_concurrently(
245 table: &str,
246 columns: &str,
247 unique_kw: &str,
248 name: &str,
249 rows: u64,
250) -> FixSuggestion {
251 let rows_note = if rows > 0 {
252 format!(
253 " The table has approximately {} rows — index build will take roughly {} seconds.",
254 fmt_rows(rows),
255 rows / 500_000 + 1
256 )
257 } else {
258 String::new()
259 };
260 FixSuggestion {
261 rule_id: "R01".to_string(),
262 title: "Use CREATE INDEX CONCURRENTLY to avoid blocking writes".to_string(),
263 explanation: format!(
264 "CREATE INDEX without CONCURRENTLY acquires a SHARE lock that blocks all \
265 INSERT, UPDATE, and DELETE statements for the entire duration of the index \
266 build.{rows_note} Using CONCURRENTLY performs two table scans and allows \
267 DML to continue throughout, at the cost of a longer total build time. \
268 Note: CONCURRENTLY cannot run inside a transaction block."
269 ),
270 fixed_sql: Some(format!(
271 "CREATE {unique_kw}INDEX CONCURRENTLY {name}\n ON {table}({columns});"
272 )),
273 migration_steps: None,
274 severity: FixSeverity::Blocking,
275 docs_url: Some(
276 "https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY"
277 .to_string(),
278 ),
279 auto_fixable: true,
280 }
281}
282
283fn rule_r02_add_not_null(table: &str, column: &str, data_type: &str, rows: u64) -> FixSuggestion {
285 let rows_note = if rows > 0 {
286 format!(" (~{} rows)", fmt_rows(rows))
287 } else {
288 String::new()
289 };
290 FixSuggestion {
291 rule_id: "R02".to_string(),
292 title: format!(
293 "Adding NOT NULL column '{column}' without DEFAULT will fail on non-empty tables"
294 ),
295 explanation: format!(
296 "ALTER TABLE {table}{rows_note} ADD COLUMN {column} {data_type} NOT NULL \
297 fails immediately if the table contains any existing rows — PostgreSQL \
298 cannot assign a value to the new column for those rows. Use the \
299 three-step pattern below for a zero-downtime migration that works on \
300 PostgreSQL 11+ (which stores the DEFAULT without rewriting the table)."
301 ),
302 fixed_sql: None,
303 migration_steps: Some(vec![
304 "-- Step 1: Add the column with a sensible DEFAULT (instant on PG ≥ 11 for constant defaults)".to_string(),
305 format!("ALTER TABLE {table}"),
306 format!(" ADD COLUMN {column} {data_type} NOT NULL DEFAULT 'YOUR_DEFAULT_VALUE';"),
307 String::new(),
308 "-- Step 2: Back-fill outdated rows in batches (avoids long lock)".to_string(),
309 format!("-- Run this in a loop until 0 rows are updated:"),
310 format!("UPDATE {table}"),
311 format!(" SET {column} = 'YOUR_REAL_VALUE'"),
312 format!(" WHERE {column} = 'YOUR_DEFAULT_VALUE'"),
313 format!(" LIMIT 10000;"),
314 String::new(),
315 "-- Step 3: Remove the synthetic default (optional) once all rows are back-filled".to_string(),
316 format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT;"),
317 ]),
318 severity: FixSeverity::Blocking,
319 docs_url: Some(
320 "https://www.postgresql.org/docs/current/sql-altertable.html".to_string(),
321 ),
322 auto_fixable: false,
323 }
324}
325
326fn rule_r03_drop_column(table: &str, column: &str, rows: u64) -> FixSuggestion {
328 let rows_note = if rows > 1_000_000 {
329 format!(" The table has ~{} rows.", fmt_rows(rows))
330 } else {
331 String::new()
332 };
333 let severity = if rows > 1_000_000 {
334 FixSeverity::Blocking
335 } else {
336 FixSeverity::Warning
337 };
338 FixSuggestion {
339 rule_id: "R03".to_string(),
340 title: format!("Deploy app changes before dropping column '{column}' from '{table}'"),
341 explanation: format!(
342 "DROP COLUMN is irreversible and holds an ACCESS EXCLUSIVE lock for the \
343 duration of the catalog update.{rows_note} Any in-flight query or \
344 transaction referencing this column will fail. Use the two-phase \
345 deployment pattern to ensure zero application downtime."
346 ),
347 fixed_sql: None,
348 migration_steps: Some(vec![
349 format!("-- Phase 1 — Application deploy (no DB change yet)"),
350 format!("-- Remove all code that reads or writes '{column}' from '{table}'"),
351 format!("-- Verify no ORM model, query, or migration references this column"),
352 String::new(),
353 format!("-- Phase 2 — Run this migration AFTER the app is fully deployed"),
354 format!("ALTER TABLE {table} DROP COLUMN IF EXISTS {column};"),
355 String::new(),
356 format!("-- Optional: create a backup before dropping"),
357 format!("-- CREATE TABLE {table}_{column}_backup AS"),
358 format!("-- SELECT id, {column} FROM {table};"),
359 ]),
360 severity,
361 docs_url: Some("https://www.postgresql.org/docs/current/sql-altertable.html".to_string()),
362 auto_fixable: false,
363 }
364}
365
366fn rule_r04_missing_fk_index(table: &str, fk_columns: &[String]) -> FixSuggestion {
368 let col_list = fk_columns.join(", ");
369 let col_snake = fk_columns.join("_");
370 FixSuggestion {
371 rule_id: "R04".to_string(),
372 title: format!("Add an index on FK column(s) ({col_list}) to prevent sequential scans"),
373 explanation: format!(
374 "PostgreSQL does NOT automatically create an index on foreign key columns. \
375 Without an index on '{table}.({col_list})', every DELETE or UPDATE on the \
376 referenced parent table triggers a full sequential scan of '{table}' to \
377 check referential integrity. This is catastrophic on tables larger than \
378 10k rows and grows linearly with table size."
379 ),
380 fixed_sql: Some(format!(
381 "CREATE INDEX CONCURRENTLY idx_{table}_{col_snake}\n ON {table}({col_list});"
382 )),
383 migration_steps: None,
384 severity: FixSeverity::Warning,
385 docs_url: Some("https://www.postgresql.org/docs/current/indexes-intro.html".to_string()),
386 auto_fixable: false,
387 }
388}
389
390fn rule_r05_rename_column(table: &str, old: &str, new: &str) -> FixSuggestion {
392 FixSuggestion {
393 rule_id: "R05".to_string(),
394 title: format!("Use expand-contract pattern to rename '{old}' → '{new}' without downtime"),
395 explanation: format!(
396 "Renaming column '{old}' in '{table}' is a **breaking change** for every \
397 piece of application code, ORM model, stored procedure, view, and query \
398 that references the old column name. The expand-contract (aka \
399 parallel-change) pattern lets you rename a column while keeping both \
400 names alive during the transition window, giving you a zero-downtime \
401 path."
402 ),
403 fixed_sql: None,
404 migration_steps: Some(vec![
405 format!("-- Migration A: Add new column and sync data"),
406 format!("ALTER TABLE {table} ADD COLUMN {new} <same_type_as_{old}>;"),
407 format!("UPDATE {table} SET {new} = {old};"),
408 String::new(),
409 format!("-- Application deploy: Dual-write to both '{old}' and '{new}'"),
410 format!("-- (reads still come from '{old}')"),
411 String::new(),
412 format!("-- Application deploy: Switch reads to '{new}'"),
413 format!("-- (still write to both)"),
414 String::new(),
415 format!("-- Application deploy: Stop writing to '{old}'"),
416 String::new(),
417 format!("-- Migration B: Drop old column"),
418 format!("ALTER TABLE {table} DROP COLUMN {old};"),
419 ]),
420 severity: FixSeverity::Blocking,
421 docs_url: Some("https://martinfowler.com/bliki/ParallelChange.html".to_string()),
422 auto_fixable: false,
423 }
424}
425
426fn rule_r06_rename_table(old: &str, new: &str) -> FixSuggestion {
428 FixSuggestion {
429 rule_id: "R06".to_string(),
430 title: format!("Renaming table '{old}' → '{new}' breaks all downstream code instantly"),
431 explanation: format!(
432 "Renaming table '{old}' invalidates ALL queries, ORM models, foreign key \
433 constraints, views, triggers, and stored procedures that reference the old \
434 name. This is one of the most dangerous DDL operations. Use a transitional \
435 compatibility view (Option A) or the full expand-contract pattern (Option B) \
436 to provide a zero-downtime migration path."
437 ),
438 fixed_sql: None,
439 migration_steps: Some(vec![
440 format!("-- ── Option A: Rename + leave compatibility view ────────────────"),
441 format!("ALTER TABLE {old} RENAME TO {new};"),
442 format!("-- Create a view using the old name so existing queries still work:"),
443 format!("CREATE VIEW {old} AS SELECT * FROM {new};"),
444 format!("-- Remove the view after all app code has been updated to use '{new}'"),
445 String::new(),
446 format!("-- ── Option B: Full expand-contract ────────────────────────────"),
447 format!("-- Step 1: Create new table '{new}' with identical schema"),
448 format!("-- Step 2: Create triggers to sync writes from '{old}' → '{new}'"),
449 format!("-- Step 3: Back-fill '{new}' from '{old}' for historical rows"),
450 format!("-- Step 4: Deploy app to write to '{new}', read from both"),
451 format!("-- Step 5: Deploy app to read only from '{new}'"),
452 format!("-- Step 6: Drop triggers + old table '{old}'"),
453 ]),
454 severity: FixSeverity::Blocking,
455 docs_url: Some(
456 "https://braintreepayments.com/blog/safe-operations-for-high-volume-postgresql/"
457 .to_string(),
458 ),
459 auto_fixable: false,
460 }
461}
462
463fn rule_r07_alter_column_type(
465 table: &str,
466 column: &str,
467 new_type: &str,
468 rows: u64,
469) -> FixSuggestion {
470 let rows_clause = if rows > 0 {
471 format!(" (~{} rows)", fmt_rows(rows))
472 } else {
473 String::new()
474 };
475 FixSuggestion {
476 rule_id: "R07".to_string(),
477 title: format!(
478 "Type change on '{table}.{column}' triggers full table rewrite under ACCESS EXCLUSIVE lock"
479 ),
480 explanation: format!(
481 "Changing the type of column '{column}' in '{table}'{rows_clause} causes \
482 PostgreSQL to rewrite the entire table while holding an ACCESS EXCLUSIVE \
483 lock. All reads and writes are blocked for the entire duration. For \
484 large tables this can mean minutes of downtime. Use the shadow-column \
485 pattern to perform the type change online."
486 ),
487 fixed_sql: None,
488 migration_steps: Some(vec![
489 format!("-- Step 1: Add shadow column with new type"),
490 format!("ALTER TABLE {table} ADD COLUMN {column}_v2 {new_type};"),
491 String::new(),
492 format!("-- Step 2: Back-fill in batches (prevents long lock)"),
493 format!("-- Run in a loop until UPDATE returns 0 rows:"),
494 format!("UPDATE {table}"),
495 format!(" SET {column}_v2 = {column}::{new_type}"),
496 format!(" WHERE {column}_v2 IS NULL"),
497 format!(" LIMIT 10000;"),
498 String::new(),
499 format!("-- Step 3: Deploy app to write to both columns"),
500 String::new(),
501 format!("-- Step 4: Atomically swap column names"),
502 format!("ALTER TABLE {table}"),
503 format!(" RENAME COLUMN {column} TO {column}_old;"),
504 format!("ALTER TABLE {table}"),
505 format!(" RENAME COLUMN {column}_v2 TO {column};"),
506 String::new(),
507 format!("-- Step 5: Drop old column after verifying app health"),
508 format!("ALTER TABLE {table} DROP COLUMN {column}_old;"),
509 ]),
510 severity: FixSeverity::Blocking,
511 docs_url: Some(
512 "https://www.postgresql.org/docs/current/sql-altertable.html".to_string(),
513 ),
514 auto_fixable: false,
515 }
516}
517
518fn rule_r09_add_column_default(
522 table: &str,
523 column: &str,
524 data_type: &str,
525 rows: u64,
526) -> FixSuggestion {
527 let rows_note = if rows > 0 {
528 format!(" (~{} rows)", fmt_rows(rows))
529 } else {
530 String::new()
531 };
532 FixSuggestion {
533 rule_id: "R09".to_string(),
534 title: format!(
535 "ADD COLUMN '{column}' WITH DEFAULT: safe on PG11+, table-rewrite on PG10 and below"
536 ),
537 explanation: format!(
538 "PostgreSQL 11 introduced the ability to add a column with a constant DEFAULT \
539 value without rewriting the table — the default is stored in the system catalog \
540 and applied on-the-fly at query time. On PostgreSQL 10 and below, adding a column \
541 with ANY default requires a full table rewrite{rows_note} holding ACCESS EXCLUSIVE \
542 lock. Always run 'SELECT version()' to confirm your PostgreSQL version. \
543 Pass --pg-version to SchemaRisk to get accurate risk scores."
544 ),
545 fixed_sql: None,
546 migration_steps: Some(vec![
547 format!("-- If you are on PostgreSQL 11+ (recommended):"),
548 format!("-- This is already safe — no changes needed."),
549 format!("ALTER TABLE {table}"),
550 format!(" ADD COLUMN {column} {data_type} DEFAULT <your_value>;"),
551 String::new(),
552 format!("-- ── If you are on PostgreSQL 10 or below ────────────────────"),
553 format!("-- Step 1: Add column as nullable (no default, no rewrite)"),
554 format!("ALTER TABLE {table} ADD COLUMN {column} {data_type};"),
555 String::new(),
556 format!("-- Step 2: Back-fill in batches during low-traffic window"),
557 format!("UPDATE {table}"),
558 format!(" SET {column} = <your_value>"),
559 format!(" WHERE {column} IS NULL"),
560 format!(" LIMIT 10000;"),
561 String::new(),
562 format!("-- Step 3: Set NOT NULL constraint after back-fill is complete"),
563 format!("ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL;"),
564 ]),
565 severity: FixSeverity::Info,
566 docs_url: Some(
567 "https://www.postgresql.org/docs/11/release-11.html#id-1.11.6.14.4".to_string(),
568 ),
569 auto_fixable: false,
570 }
571}
572
573fn rule_r08_long_lock(description: &str, est_secs: u64) -> FixSuggestion {
575 FixSuggestion {
576 rule_id: "R08".to_string(),
577 title: format!("ACCESS EXCLUSIVE lock held for ~{est_secs}s — protect with lock_timeout"),
578 explanation: format!(
579 "The operation '{}' acquires ACCESS EXCLUSIVE lock for an estimated \
580 ~{est_secs} seconds. During this window every query, transaction, and \
581 connection waiting to access the table is queued. A single long-running \
582 transaction before the migration can turn a 30-second lock into minutes \
583 of application downtime. Set lock_timeout to prevent lock pile-up.",
584 shorten_desc(description, 80)
585 ),
586 fixed_sql: None,
587 migration_steps: Some(vec![
588 "-- Wrap your migration in a lock_timeout guard:".to_string(),
589 "BEGIN;".to_string(),
590 " SET lock_timeout = '3s'; -- abort if lock is not acquired in 3s".to_string(),
591 " SET statement_timeout = '120s'; -- abort if statement runs > 2 min".to_string(),
592 String::new(),
593 " -- YOUR MIGRATION HERE".to_string(),
594 String::new(),
595 "COMMIT;".to_string(),
596 String::new(),
597 "-- For tables > 1GB, consider pg_repack for zero-downtime online rewrites:"
598 .to_string(),
599 "-- https://github.com/reorg/pg_repack".to_string(),
600 "-- pg_repack --dbname=<db_url> --table=<table_name>".to_string(),
601 ]),
602 severity: FixSeverity::Warning,
603 docs_url: Some("https://github.com/reorg/pg_repack".to_string()),
604 auto_fixable: false,
605 }
606}
607
608pub fn rewrite_index_concurrent(sql: &str) -> String {
617 sql.lines()
618 .map(|line| {
619 let upper = line.to_uppercase();
620 if upper.contains("CONCURRENTLY") {
622 return line.to_string();
623 }
624
625 if let Some(pos) = upper.find("CREATE UNIQUE INDEX") {
627 let after = pos + "CREATE UNIQUE INDEX".len();
628 let prefix = &line[..after];
629 let rest = line[after..].trim_start();
630 return format!("{prefix} CONCURRENTLY {rest}");
631 }
632
633 if let Some(pos) = upper.find("CREATE INDEX") {
635 let after = pos + "CREATE INDEX".len();
636 let prefix = &line[..after];
637 let rest = line[after..].trim_start();
638 return format!("{prefix} CONCURRENTLY {rest}");
639 }
640
641 line.to_string()
642 })
643 .collect::<Vec<_>>()
644 .join("\n")
645}
646
647fn fmt_rows(n: u64) -> String {
653 if n >= 1_000_000_000 {
654 format!("{:.1}B", n as f64 / 1_000_000_000.0)
655 } else if n >= 1_000_000 {
656 format!("{:.1}M", n as f64 / 1_000_000.0)
657 } else if n >= 1_000 {
658 format!("{:.0}K", n as f64 / 1_000.0)
659 } else {
660 n.to_string()
661 }
662}
663
664fn estimate_lock_secs(rows: u64) -> u64 {
667 if rows < 500_000 {
668 1
669 } else {
670 rows / 500_000
671 }
672}
673
674fn shorten_desc(s: &str, max: usize) -> &str {
676 if s.len() <= max {
677 s
678 } else {
679 &s[..max]
680 }
681}
682
683#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::parser;
691
692 #[test]
693 fn test_r01_triggers_for_non_concurrent_index() {
694 let sql = "CREATE INDEX idx_users_email ON users(email);";
695 let stmts = parser::parse(sql).expect("parse");
696 let fixes = suggest_fixes(&stmts, &HashMap::new());
697 assert!(fixes.iter().any(|f| f.rule_id == "R01"));
698 assert!(fixes.iter().any(|f| f.auto_fixable));
699 }
700
701 #[test]
702 fn test_r01_skipped_for_concurrent_index() {
703 let sql = "CREATE INDEX CONCURRENTLY idx_users_email ON users(email);";
704 let stmts = parser::parse(sql).expect("parse");
705 let fixes = suggest_fixes(&stmts, &HashMap::new());
706 assert!(fixes.iter().all(|f| f.rule_id != "R01"));
707 }
708
709 #[test]
710 fn test_r02_triggers_for_not_null_no_default() {
711 let sql = "ALTER TABLE users ADD COLUMN verified BOOLEAN NOT NULL;";
712 let stmts = parser::parse(sql).expect("parse");
713 let fixes = suggest_fixes(&stmts, &HashMap::new());
714 assert!(fixes.iter().any(|f| f.rule_id == "R02"));
715 }
716
717 #[test]
718 fn test_rewrite_concurrent() {
719 let sql = "CREATE INDEX idx_a ON t(col);";
720 let result = rewrite_index_concurrent(sql);
721 assert!(result.contains("CONCURRENTLY"), "got: {result}");
722 }
723
724 #[test]
725 fn test_rewrite_concurrent_idempotent() {
726 let sql = "CREATE INDEX CONCURRENTLY idx_a ON t(col);";
727 let result = rewrite_index_concurrent(sql);
728 let count = result.matches("CONCURRENTLY").count();
730 assert_eq!(count, 1, "got: {result}");
731 }
732}