From 5c4ca585abbb20ee1bd7ed817649ba2caf21b23c Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Fri, 6 Feb 2026 21:51:15 +0900 Subject: [PATCH 1/7] fix(db): add CHECK constraint introspection for all database dialects The database introspectors were missing CHECK constraint queries in their get_constraints() implementations, causing CHECK constraints to be silently lost during strata export. Added CHECK constraint support for: - PostgreSQL: queries pg_constraint with contype='c' and uses pg_get_constraintdef() to extract the expression - MySQL: queries information_schema.check_constraints (MySQL 8.0.16+) with filtering for auto-generated NOT NULL checks - SQLite: parses CHECK constraints from CREATE TABLE DDL in sqlite_master Fixes #24 Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 328 +++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index caff1fc..9edd460 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -100,6 +100,32 @@ fn parse_mysql_enum_values(column_type: &str) -> Option> { } } +/// MySQL の CHECK 式からカラム名を推定する +/// +/// MySQL の check_clause にはバッククォートで囲まれたカラム名が含まれる。 +/// 例: "(`balance` >= 0)" -> ["balance"] +fn extract_columns_from_check_expression(expression: &str, _table_name: &str) -> Vec { + let mut columns = Vec::new(); + let mut chars = expression.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '`' { + let mut name = String::new(); + for c in chars.by_ref() { + if c == '`' { + break; + } + name.push(c); + } + if !name.is_empty() && !columns.contains(&name) { + columns.push(name); + } + } + } + + columns +} + /// 生のカラム情報(DB固有フォーマット) /// /// データベースから取得したカラム情報を保持する構造体。 @@ -468,6 +494,61 @@ impl DatabaseIntrospector for PostgresIntrospector { constraints.push(RawConstraintInfo::Unique { columns }); } + // CHECK制約 + // pg_constraintからCHECK制約を取得(contype = 'c') + // string_agg でカラム名をカンマ区切りで返す(Any ドライバは配列非対応) + let check_sql = r#" + SELECT + con.conname::text, + pg_get_constraintdef(con.oid)::text AS check_expression, + string_agg(a.attname::text, ',' ORDER BY u.ord) AS columns + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, ord) + JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = u.attnum + WHERE con.contype = 'c' + AND c.relname = $1 + AND n.nspname = 'public' + GROUP BY con.conname, con.oid + ORDER BY con.conname + "#; + + let check_rows = sqlx::query(check_sql) + .bind(table_name) + .fetch_all(pool) + .await?; + + for row in check_rows { + let _constraint_name: String = row.get(0); + let raw_expression: String = row.get(1); + let columns_str: String = row.get(2); + let columns: Vec = columns_str + .split(',') + .map(|s| s.trim().to_string()) + .collect(); + + // pg_get_constraintdef returns "CHECK ((expression))" + // Strip the outer "CHECK (" prefix and ")" suffix to get the actual expression + let expression = raw_expression + .strip_prefix("CHECK (") + .and_then(|s| s.strip_suffix(')')) + .unwrap_or(&raw_expression) + .to_string(); + + // PostgreSQL wraps expressions in extra parentheses, strip those too + let expression = expression + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .unwrap_or(&expression) + .to_string(); + + constraints.push(RawConstraintInfo::Check { + columns, + expression, + }); + } + Ok(constraints) } @@ -798,6 +879,51 @@ impl DatabaseIntrospector for MySqlIntrospector { constraints.push(RawConstraintInfo::Unique { columns }); } + // CHECK制約 (MySQL 8.0.16+) + // information_schema.check_constraints と table_constraints を結合して取得 + let check_sql = r#" + SELECT + cc.constraint_name, + cc.check_clause + FROM information_schema.check_constraints cc + JOIN information_schema.table_constraints tc + ON cc.constraint_name = tc.constraint_name + AND cc.constraint_schema = tc.constraint_schema + WHERE tc.table_name = ? AND tc.table_schema = DATABASE() + AND tc.constraint_type = 'CHECK' + ORDER BY cc.constraint_name + "#; + + let check_rows = sqlx::query(check_sql) + .bind(table_name) + .fetch_all(pool) + .await?; + + for row in &check_rows { + let constraint_name = mysql_get_string(row, 0); + let check_clause = mysql_get_string(row, 1); + + // MySQL auto-generates NOT NULL-like constraints with names ending in "_chk_N" + // for ENUM columns. Filter out constraints that are just IS NOT NULL checks. + let trimmed = check_clause.trim(); + if trimmed.ends_with("is not null") + || trimmed.ends_with("IS NOT NULL") + || constraint_name.ends_with("_chk_1") + && (trimmed.contains("in (") || trimmed.contains("IN (")) + { + continue; + } + + // Try to extract column names from the expression + // For simple single-column checks like "(balance >= 0)", extract column name + let columns = extract_columns_from_check_expression(&check_clause, table_name); + + constraints.push(RawConstraintInfo::Check { + columns, + expression: check_clause, + }); + } + Ok(constraints) } @@ -1012,6 +1138,22 @@ impl DatabaseIntrospector for SqliteIntrospector { }); } + // CHECK制約 + // sqlite_masterからCREATE TABLE文を取得してCHECK制約をパースする + let create_sql_query = format!( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = {}", + quoted_table + ); + let create_rows = sqlx::query(&create_sql_query).fetch_all(pool).await?; + + if let Some(row) = create_rows.first() { + let create_sql: Option = row.get(0); + if let Some(sql) = create_sql { + let check_constraints = parse_sqlite_check_constraints(&sql); + constraints.extend(check_constraints); + } + } + Ok(constraints) } @@ -1066,6 +1208,86 @@ fn extract_view_definition_from_create_sql(create_sql: &str) -> String { } } +/// SQLite の CREATE TABLE 文からCHECK制約をパースする +/// +/// テーブルレベルのCHECK制約を抽出する。 +/// 例: `CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))` +fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { + let mut results = Vec::new(); + + // 大文字小文字を無視して CHECK キーワードを検索 + // テーブルレベルの CHECK 制約: CHECK (expression) の形式 + let re = regex::Regex::new(r"(?i)\bCHECK\s*\(").unwrap(); + + for m in re.find_iter(create_sql) { + let start = m.end(); // '(' の直後 + // 対応する閉じ括弧を見つける(ネスト対応) + let mut depth = 1; + let mut end = start; + for (i, ch) in create_sql[start..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = start + i; + break; + } + } + _ => {} + } + } + + if depth == 0 { + let expression = create_sql[start..end].trim().to_string(); + + // 式からカラム名を推定(識別子として使われている単語を抽出) + let columns = extract_columns_from_sqlite_check(&expression); + + results.push(RawConstraintInfo::Check { + columns, + expression, + }); + } + } + + results +} + +/// SQLite CHECK式からカラム名を推定する +/// +/// SQLのキーワードや数値リテラルを除外し、識別子と思われる単語を抽出する +fn extract_columns_from_sqlite_check(expression: &str) -> Vec { + let keywords = [ + "AND", + "OR", + "NOT", + "IN", + "IS", + "NULL", + "LIKE", + "BETWEEN", + "EXISTS", + "TRUE", + "FALSE", + "CHECK", + "CONSTRAINT", + ]; + + let re = regex::Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b").unwrap(); + let mut columns = Vec::new(); + + for cap in re.captures_iter(expression) { + let word = &cap[1]; + let upper = word.to_uppercase(); + if !keywords.contains(&upper.as_str()) && !columns.contains(&word.to_string()) { + columns.push(word.to_string()); + } + } + + columns +} + #[cfg(test)] mod tests { use super::*; @@ -1374,4 +1596,110 @@ mod tests { Some(vec!["a".to_string(), "b".to_string(), "".to_string()]) ); } + + // ========================================================================= + // parse_sqlite_check_constraints テスト + // ========================================================================= + + #[test] + fn test_parse_sqlite_check_simple() { + let sql = "CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))"; + let checks = super::parse_sqlite_check_constraints(sql); + assert_eq!(checks.len(), 1); + match &checks[0] { + RawConstraintInfo::Check { + columns, + expression, + } => { + assert_eq!(expression, "balance >= 0"); + assert!(columns.contains(&"balance".to_string())); + } + _ => panic!("Expected Check constraint"), + } + } + + #[test] + fn test_parse_sqlite_check_multiple() { + let sql = "CREATE TABLE t (id INTEGER, age INTEGER, balance REAL, CHECK (age >= 0), CHECK (balance > 0))"; + let checks = super::parse_sqlite_check_constraints(sql); + assert_eq!(checks.len(), 2); + } + + #[test] + fn test_parse_sqlite_check_nested_parens() { + let sql = "CREATE TABLE t (id INTEGER, val INTEGER, CHECK ((val >= 0) AND (val <= 100)))"; + let checks = super::parse_sqlite_check_constraints(sql); + assert_eq!(checks.len(), 1); + match &checks[0] { + RawConstraintInfo::Check { expression, .. } => { + assert_eq!(expression, "(val >= 0) AND (val <= 100)"); + } + _ => panic!("Expected Check constraint"), + } + } + + #[test] + fn test_parse_sqlite_check_case_insensitive() { + let sql = "CREATE TABLE t (id INTEGER, x INTEGER, check (x > 0))"; + let checks = super::parse_sqlite_check_constraints(sql); + assert_eq!(checks.len(), 1); + } + + #[test] + fn test_parse_sqlite_check_no_checks() { + let sql = "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"; + let checks = super::parse_sqlite_check_constraints(sql); + assert!(checks.is_empty()); + } + + // ========================================================================= + // extract_columns_from_check_expression テスト (MySQL) + // ========================================================================= + + #[test] + fn test_extract_columns_from_mysql_check_single() { + let columns = super::extract_columns_from_check_expression("`balance` >= 0", "t"); + assert_eq!(columns, vec!["balance".to_string()]); + } + + #[test] + fn test_extract_columns_from_mysql_check_multiple() { + let columns = + super::extract_columns_from_check_expression("`start_date` < `end_date`", "t"); + assert_eq!( + columns, + vec!["start_date".to_string(), "end_date".to_string()] + ); + } + + #[test] + fn test_extract_columns_from_mysql_check_no_backticks() { + let columns = super::extract_columns_from_check_expression("balance >= 0", "t"); + assert!(columns.is_empty()); + } + + // ========================================================================= + // extract_columns_from_sqlite_check テスト + // ========================================================================= + + #[test] + fn test_extract_columns_from_sqlite_check_simple() { + let columns = super::extract_columns_from_sqlite_check("balance >= 0"); + assert_eq!(columns, vec!["balance".to_string()]); + } + + #[test] + fn test_extract_columns_from_sqlite_check_with_and() { + let columns = super::extract_columns_from_sqlite_check("age >= 0 AND age <= 150"); + assert_eq!(columns, vec!["age".to_string()]); + } + + #[test] + fn test_extract_columns_from_sqlite_check_multiple_columns() { + let columns = super::extract_columns_from_sqlite_check("start_date < end_date"); + assert_eq!( + columns, + vec!["start_date".to_string(), "end_date".to_string()] + ); + } } From a9f6f929e965474f4ad72351f219daecfff1e678 Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Fri, 6 Feb 2026 22:29:58 +0900 Subject: [PATCH 2/7] fix(db): address PR review feedback for CHECK constraint introspection - Fix PostgreSQL expression parsing: use matching-paren check instead of blind double-strip to avoid corrupting expressions like "(val >= 0) AND (val <= 100)" - Fix SQLite CHECK parser: rewrite with character-by-character scanning to correctly handle CHECK keywords inside string literals and column names like "check_date" - Fix SQLite column extraction: strip string literals before parsing to avoid extracting words like 'pending' as column names; expand keyword list with CASE/WHEN/THEN/ELSE/END, data types, and common functions - Fix MySQL filter condition: use explicit boolean variables for clarity instead of relying on operator precedence - Fix MySQL backtick parsing: handle escaped backticks (``) in column identifiers - Remove unused _table_name parameter from extract_columns_from_check_expression() - Cache IDENTIFIER_REGEX with std::sync::LazyLock for performance - Add 12 new tests covering edge cases: string literal handling, paren stripping, escaped backticks, keyword filtering Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 439 ++++++++++++++++--- 1 file changed, 387 insertions(+), 52 deletions(-) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index 9edd460..42760c4 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -3,11 +3,18 @@ // データベースからスキーマ情報を取得するための抽象化レイヤー。 // 各方言固有のINFORMATION_SCHEMA/PRAGMAクエリを実装します。 +use std::sync::LazyLock; + use anyhow::Result; use async_trait::async_trait; +use regex::Regex; use sqlx::AnyPool; use sqlx::Row; +/// 識別子検出用の正規表現(コンパイル済みキャッシュ) +static IDENTIFIER_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b").unwrap()); + /// MySQL の information_schema は多くのカラムを BLOB/VARBINARY 型で返す。 /// sqlx の Any ドライバは String として直接デコードできないため、 /// まず String を試し、失敗したら Vec → String 変換にフォールバックする。 @@ -103,16 +110,24 @@ fn parse_mysql_enum_values(column_type: &str) -> Option> { /// MySQL の CHECK 式からカラム名を推定する /// /// MySQL の check_clause にはバッククォートで囲まれたカラム名が含まれる。 +/// エスケープされたバッククォート(``)にも対応する。 /// 例: "(`balance` >= 0)" -> ["balance"] -fn extract_columns_from_check_expression(expression: &str, _table_name: &str) -> Vec { +fn extract_columns_from_check_expression(expression: &str) -> Vec { let mut columns = Vec::new(); let mut chars = expression.chars().peekable(); while let Some(ch) = chars.next() { if ch == '`' { let mut name = String::new(); - for c in chars.by_ref() { + // バッククォート内の識別子をパース(`` エスケープ対応) + while let Some(c) = chars.next() { if c == '`' { + // 連続バッククォートはエスケープ: リテラルの ` として追加 + if chars.peek() == Some(&'`') { + chars.next(); + name.push('`'); + continue; + } break; } name.push(c); @@ -536,12 +551,9 @@ impl DatabaseIntrospector for PostgresIntrospector { .unwrap_or(&raw_expression) .to_string(); - // PostgreSQL wraps expressions in extra parentheses, strip those too - let expression = expression - .strip_prefix('(') - .and_then(|s| s.strip_suffix(')')) - .unwrap_or(&expression) - .to_string(); + // PostgreSQL wraps simple expressions in extra parentheses. + // Only strip if the entire expression is wrapped in a single matching pair. + let expression = strip_outer_parens(&expression); constraints.push(RawConstraintInfo::Check { columns, @@ -903,20 +915,20 @@ impl DatabaseIntrospector for MySqlIntrospector { let constraint_name = mysql_get_string(row, 0); let check_clause = mysql_get_string(row, 1); - // MySQL auto-generates NOT NULL-like constraints with names ending in "_chk_N" - // for ENUM columns. Filter out constraints that are just IS NOT NULL checks. + // MySQL の自動生成制約をフィルタリング: + // 1. NOT NULL チェック(ENUM カラムに自動付与される) + // 2. ENUM バリデーション(_chk_N の名前で IN (...) を含む) let trimmed = check_clause.trim(); - if trimmed.ends_with("is not null") - || trimmed.ends_with("IS NOT NULL") - || constraint_name.ends_with("_chk_1") - && (trimmed.contains("in (") || trimmed.contains("IN (")) - { + let is_not_null_check = + trimmed.ends_with("is not null") || trimmed.ends_with("IS NOT NULL"); + let is_enum_validation = constraint_name.ends_with("_chk_1") + && (trimmed.contains("in (") || trimmed.contains("IN (")); + if is_not_null_check || is_enum_validation { continue; } - // Try to extract column names from the expression - // For simple single-column checks like "(balance >= 0)", extract column name - let columns = extract_columns_from_check_expression(&check_clause, table_name); + // バッククォートで囲まれたカラム名を抽出 + let columns = extract_columns_from_check_expression(&check_clause); constraints.push(RawConstraintInfo::Check { columns, @@ -1196,6 +1208,39 @@ impl DatabaseIntrospector for SqliteIntrospector { } } +/// 式全体が一対の括弧で囲まれている場合のみ外側の括弧を除去する +/// +/// `(balance >= 0)` → `balance >= 0` (除去) +/// `(val >= 0) AND (val <= 100)` → そのまま (除去しない: 先頭の `(` と末尾の `)` が対応していない) +fn strip_outer_parens(expr: &str) -> String { + let trimmed = expr.trim(); + if !trimmed.starts_with('(') || !trimmed.ends_with(')') { + return trimmed.to_string(); + } + + // 先頭の '(' に対応する ')' が末尾であることを確認 + let mut depth = 0; + for (i, ch) in trimmed.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + // 先頭の '(' の対応が末尾の ')' と一致するか + return if i == trimmed.len() - 1 { + trimmed[1..i].trim().to_string() + } else { + trimmed.to_string() + }; + } + } + _ => {} + } + } + + trimmed.to_string() +} + /// CREATE VIEW 文からビュー定義(AS以降)を抽出する fn extract_view_definition_from_create_sql(create_sql: &str) -> String { // 大文字小文字を無視して \s+AS\s+ パターンを検索(改行・タブにも対応) @@ -1211,44 +1256,157 @@ fn extract_view_definition_from_create_sql(create_sql: &str) -> String { /// SQLite の CREATE TABLE 文からCHECK制約をパースする /// /// テーブルレベルのCHECK制約を抽出する。 +/// 文字列リテラル('...')およびダブルクォート識別子("...")内の CHECK は無視する。 /// 例: `CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))` fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { let mut results = Vec::new(); + let chars: Vec<(usize, char)> = create_sql.char_indices().collect(); + let len = chars.len(); + + let mut i = 0; + while i < len { + let (_, ch) = chars[i]; + + // シングルクォート文字列リテラルをスキップ('' エスケープ対応) + if ch == '\'' { + i += 1; + while i < len { + if chars[i].1 == '\'' { + i += 1; + // '' はエスケープ: 文字列継続 + if i < len && chars[i].1 == '\'' { + i += 1; + continue; + } + break; + } + i += 1; + } + continue; + } - // 大文字小文字を無視して CHECK キーワードを検索 - // テーブルレベルの CHECK 制約: CHECK (expression) の形式 - let re = regex::Regex::new(r"(?i)\bCHECK\s*\(").unwrap(); - - for m in re.find_iter(create_sql) { - let start = m.end(); // '(' の直後 - // 対応する閉じ括弧を見つける(ネスト対応) - let mut depth = 1; - let mut end = start; - for (i, ch) in create_sql[start..].char_indices() { - match ch { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - end = start + i; - break; + // ダブルクォート識別子をスキップ("" エスケープ対応) + if ch == '"' { + i += 1; + while i < len { + if chars[i].1 == '"' { + i += 1; + if i < len && chars[i].1 == '"' { + i += 1; + continue; } + break; } - _ => {} + i += 1; } + continue; } - if depth == 0 { - let expression = create_sql[start..end].trim().to_string(); + // CHECK キーワードを検出(大文字小文字無視、単語境界チェック) + if ch.eq_ignore_ascii_case(&'C') && i + 4 < len { + let is_check = chars[i + 1].1.eq_ignore_ascii_case(&'H') + && chars[i + 2].1.eq_ignore_ascii_case(&'E') + && chars[i + 3].1.eq_ignore_ascii_case(&'C') + && chars[i + 4].1.eq_ignore_ascii_case(&'K'); + + if is_check { + // 単語境界の確認 + let prev_is_ident = i > 0 && { + let prev = chars[i - 1].1; + prev == '_' || prev.is_ascii_alphanumeric() + }; + let next_is_ident = i + 5 < len && { + let next_ch = chars[i + 5].1; + next_ch == '_' || next_ch.is_ascii_alphanumeric() + }; - // 式からカラム名を推定(識別子として使われている単語を抽出) - let columns = extract_columns_from_sqlite_check(&expression); + if !prev_is_ident && !next_is_ident { + // CHECK の後の空白をスキップして '(' を探す + let mut k = i + 5; + while k < len && chars[k].1.is_whitespace() { + k += 1; + } - results.push(RawConstraintInfo::Check { - columns, - expression, - }); + if k < len && chars[k].1 == '(' { + let paren_start = chars[k].0 + chars[k].1.len_utf8(); + + // 対応する閉じ括弧を見つける(ネスト・クォート対応) + let mut depth = 1; + let mut expr_end = None; + let mut m = k + 1; + let mut in_sq = false; + let mut in_dq = false; + + while m < len { + let (m_byte, mch) = chars[m]; + + if mch == '\'' && !in_dq { + if in_sq { + if m + 1 < len && chars[m + 1].1 == '\'' { + m += 2; + continue; + } + in_sq = false; + } else { + in_sq = true; + } + m += 1; + continue; + } else if mch == '"' && !in_sq { + if in_dq { + if m + 1 < len && chars[m + 1].1 == '"' { + m += 2; + continue; + } + in_dq = false; + } else { + in_dq = true; + } + m += 1; + continue; + } + + if in_sq || in_dq { + m += 1; + continue; + } + + match mch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + expr_end = Some(m_byte); + break; + } + } + _ => {} + } + + m += 1; + } + + if let Some(end_byte) = expr_end { + let expression = create_sql[paren_start..end_byte].trim().to_string(); + let columns = extract_columns_from_sqlite_check(&expression); + + results.push(RawConstraintInfo::Check { + columns, + expression, + }); + } + + i = m + 1; + continue; + } + } + + i += 5; + continue; + } } + + i += 1; } results @@ -1256,28 +1414,74 @@ fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { /// SQLite CHECK式からカラム名を推定する /// -/// SQLのキーワードや数値リテラルを除外し、識別子と思われる単語を抽出する +/// 文字列リテラル('...')内の単語は無視し、 +/// SQLキーワード・関数名・データ型を除外して識別子を抽出する。 fn extract_columns_from_sqlite_check(expression: &str) -> Vec { + // 文字列リテラルを除去してからパース + let stripped = strip_string_literals(expression); + let keywords = [ + // 論理演算子・比較・制御構文 "AND", "OR", "NOT", "IN", "IS", - "NULL", "LIKE", "BETWEEN", "EXISTS", + "CASE", + "WHEN", + "THEN", + "ELSE", + "END", + // リテラル・真偽値 + "NULL", "TRUE", "FALSE", + // データ型 + "INTEGER", + "REAL", + "TEXT", + "BLOB", + "NUMERIC", + "DATE", + "TIME", + // 関数 + "LENGTH", + "LOWER", + "UPPER", + "SUBSTR", + "ABS", + "ROUND", + "COALESCE", + "IFNULL", + "NULLIF", + "TRIM", + "LTRIM", + "RTRIM", + "MIN", + "MAX", + "AVG", + "COUNT", + "SUM", + "RANDOM", + "CHAR", + "HEX", + // その他 + "AS", + "CAST", + "COLLATE", + "GLOB", + "MATCH", + "REGEXP", "CHECK", "CONSTRAINT", ]; - let re = regex::Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b").unwrap(); let mut columns = Vec::new(); - for cap in re.captures_iter(expression) { + for cap in IDENTIFIER_REGEX.captures_iter(&stripped) { let word = &cap[1]; let upper = word.to_uppercase(); if !keywords.contains(&upper.as_str()) && !columns.contains(&word.to_string()) { @@ -1288,6 +1492,38 @@ fn extract_columns_from_sqlite_check(expression: &str) -> Vec { columns } +/// SQL文字列リテラル(シングルクォート)を除去する +/// +/// `status IN ('pending', 'active')` → `status IN (, )` +fn strip_string_literals(sql: &str) -> String { + let mut result = String::with_capacity(sql.len()); + let mut chars = sql.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\'' { + // 文字列リテラルをスキップ + loop { + match chars.next() { + Some('\'') => { + // '' エスケープのチェック + if chars.peek() == Some(&'\'') { + chars.next(); + continue; + } + break; + } + None => break, + _ => {} + } + } + } else { + result.push(ch); + } + } + + result +} + #[cfg(test)] mod tests { use super::*; @@ -1652,20 +1888,75 @@ mod tests { assert!(checks.is_empty()); } + // ========================================================================= + // parse_sqlite_check_constraints 追加テスト(クォート対応) + // ========================================================================= + + #[test] + fn test_parse_sqlite_check_ignores_check_in_string_literal() { + // 文字列リテラル内の 'CHECK' は無視する + let sql = "CREATE TABLE t (val TEXT, CHECK (val != 'CHECK'))"; + let checks = super::parse_sqlite_check_constraints(sql); + assert_eq!(checks.len(), 1); + match &checks[0] { + RawConstraintInfo::Check { expression, .. } => { + assert_eq!(expression, "val != 'CHECK'"); + } + _ => panic!("Expected Check constraint"), + } + } + + #[test] + fn test_parse_sqlite_check_column_named_check_prefix() { + // check_date のようなカラム名は CHECK として誤検出しない + let sql = "CREATE TABLE t (check_date TEXT, CHECK (check_date IS NOT NULL))"; + let checks = super::parse_sqlite_check_constraints(sql); + assert_eq!(checks.len(), 1); + } + + // ========================================================================= + // strip_outer_parens テスト + // ========================================================================= + + #[test] + fn test_strip_outer_parens_simple() { + assert_eq!(super::strip_outer_parens("(balance >= 0)"), "balance >= 0"); + } + + #[test] + fn test_strip_outer_parens_no_parens() { + assert_eq!(super::strip_outer_parens("balance >= 0"), "balance >= 0"); + } + + #[test] + fn test_strip_outer_parens_non_matching() { + // 先頭の ( と末尾の ) が対応していないケース + let expr = "(val >= 0) AND (val <= 100)"; + assert_eq!(super::strip_outer_parens(expr), expr); + } + + #[test] + fn test_strip_outer_parens_nested_matching() { + // 全体が一対の括弧で囲まれたネスト式 + assert_eq!( + super::strip_outer_parens("((a >= 0) AND (b <= 100))"), + "(a >= 0) AND (b <= 100)" + ); + } + // ========================================================================= // extract_columns_from_check_expression テスト (MySQL) // ========================================================================= #[test] fn test_extract_columns_from_mysql_check_single() { - let columns = super::extract_columns_from_check_expression("`balance` >= 0", "t"); + let columns = super::extract_columns_from_check_expression("`balance` >= 0"); assert_eq!(columns, vec!["balance".to_string()]); } #[test] fn test_extract_columns_from_mysql_check_multiple() { - let columns = - super::extract_columns_from_check_expression("`start_date` < `end_date`", "t"); + let columns = super::extract_columns_from_check_expression("`start_date` < `end_date`"); assert_eq!( columns, vec!["start_date".to_string(), "end_date".to_string()] @@ -1674,10 +1965,17 @@ mod tests { #[test] fn test_extract_columns_from_mysql_check_no_backticks() { - let columns = super::extract_columns_from_check_expression("balance >= 0", "t"); + let columns = super::extract_columns_from_check_expression("balance >= 0"); assert!(columns.is_empty()); } + #[test] + fn test_extract_columns_from_mysql_check_escaped_backtick() { + // エスケープされたバッククォート(``)を含むカラム名 + let columns = super::extract_columns_from_check_expression("`my``col` >= 0"); + assert_eq!(columns, vec!["my`col".to_string()]); + } + // ========================================================================= // extract_columns_from_sqlite_check テスト // ========================================================================= @@ -1702,4 +2000,41 @@ mod tests { vec!["start_date".to_string(), "end_date".to_string()] ); } + + #[test] + fn test_extract_columns_from_sqlite_check_ignores_string_literals() { + // 文字列リテラル内の単語はカラム名として抽出しない + let columns = super::extract_columns_from_sqlite_check("status IN ('pending', 'active')"); + assert_eq!(columns, vec!["status".to_string()]); + } + + #[test] + fn test_extract_columns_from_sqlite_check_ignores_keywords() { + // CASE/WHEN/THEN/ELSE/END はキーワードとして除外される + let columns = + super::extract_columns_from_sqlite_check("CASE WHEN val > 0 THEN 1 ELSE 0 END = 1"); + assert_eq!(columns, vec!["val".to_string()]); + } + + // ========================================================================= + // strip_string_literals テスト + // ========================================================================= + + #[test] + fn test_strip_string_literals_simple() { + assert_eq!( + super::strip_string_literals("status IN ('pending', 'active')"), + "status IN (, )" + ); + } + + #[test] + fn test_strip_string_literals_escaped_quote() { + assert_eq!(super::strip_string_literals("val != 'it''s'"), "val != "); + } + + #[test] + fn test_strip_string_literals_no_strings() { + assert_eq!(super::strip_string_literals("balance >= 0"), "balance >= 0"); + } } From 0ca5c1252b7893bc5ceb2256dd91537b2a6b0d86 Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Fri, 6 Feb 2026 22:48:53 +0900 Subject: [PATCH 3/7] fix(db): address second-round review feedback for CHECK constraints - PostgreSQL: use LEFT JOIN LATERAL instead of CROSS JOIN for expression-only CHECK constraints where conkey is empty - PostgreSQL: add COALESCE to handle NULL from string_agg when no columns are referenced - MySQL: broaden _chk_1 filter to _chk_N pattern for all auto-generated ENUM validation constraints - MySQL: use case-insensitive matching via to_lowercase() - SQLite: replace string interpolation with parameterized bind query for sqlite_master lookup - Remove data type keywords (INTEGER, REAL, TEXT, etc.) from SQLite column extraction filter to avoid false negatives for columns named after types Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 56 +++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index 42760c4..06b6273 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -511,17 +511,18 @@ impl DatabaseIntrospector for PostgresIntrospector { // CHECK制約 // pg_constraintからCHECK制約を取得(contype = 'c') + // LEFT JOIN で式のみの制約(conkey が空)にも対応 // string_agg でカラム名をカンマ区切りで返す(Any ドライバは配列非対応) let check_sql = r#" SELECT con.conname::text, pg_get_constraintdef(con.oid)::text AS check_expression, - string_agg(a.attname::text, ',' ORDER BY u.ord) AS columns + COALESCE(string_agg(a.attname::text, ',' ORDER BY u.ord), '') AS columns FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace - CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, ord) - JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = u.attnum + LEFT JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, ord) ON true + LEFT JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = u.attnum WHERE con.contype = 'c' AND c.relname = $1 AND n.nspname = 'public' @@ -538,10 +539,14 @@ impl DatabaseIntrospector for PostgresIntrospector { let _constraint_name: String = row.get(0); let raw_expression: String = row.get(1); let columns_str: String = row.get(2); - let columns: Vec = columns_str - .split(',') - .map(|s| s.trim().to_string()) - .collect(); + let columns: Vec = if columns_str.is_empty() { + Vec::new() + } else { + columns_str + .split(',') + .map(|s| s.trim().to_string()) + .collect() + }; // pg_get_constraintdef returns "CHECK ((expression))" // Strip the outer "CHECK (" prefix and ")" suffix to get the actual expression @@ -918,11 +923,20 @@ impl DatabaseIntrospector for MySqlIntrospector { // MySQL の自動生成制約をフィルタリング: // 1. NOT NULL チェック(ENUM カラムに自動付与される) // 2. ENUM バリデーション(_chk_N の名前で IN (...) を含む) - let trimmed = check_clause.trim(); - let is_not_null_check = - trimmed.ends_with("is not null") || trimmed.ends_with("IS NOT NULL"); - let is_enum_validation = constraint_name.ends_with("_chk_1") - && (trimmed.contains("in (") || trimmed.contains("IN (")); + let lower = check_clause.trim().to_lowercase(); + let is_not_null_check = lower.ends_with("is not null"); + let is_enum_validation = { + // MySQL は _chk_1, _chk_2, ... の名前で ENUM バリデーションを自動生成する + let has_chk_suffix = constraint_name + .rfind("_chk_") + .map(|pos| { + constraint_name[pos + 5..] + .chars() + .all(|c| c.is_ascii_digit()) + }) + .unwrap_or(false); + has_chk_suffix && (lower.contains("in (") || lower.contains("in(")) + }; if is_not_null_check || is_enum_validation { continue; } @@ -1152,11 +1166,11 @@ impl DatabaseIntrospector for SqliteIntrospector { // CHECK制約 // sqlite_masterからCREATE TABLE文を取得してCHECK制約をパースする - let create_sql_query = format!( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = {}", - quoted_table - ); - let create_rows = sqlx::query(&create_sql_query).fetch_all(pool).await?; + let create_sql_query = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?"; + let create_rows = sqlx::query(create_sql_query) + .bind(table_name) + .fetch_all(pool) + .await?; if let Some(row) = create_rows.first() { let create_sql: Option = row.get(0); @@ -1439,14 +1453,6 @@ fn extract_columns_from_sqlite_check(expression: &str) -> Vec { "NULL", "TRUE", "FALSE", - // データ型 - "INTEGER", - "REAL", - "TEXT", - "BLOB", - "NUMERIC", - "DATE", - "TIME", // 関数 "LENGTH", "LOWER", From 302d8591ca413aff6488eb48a2a92e563315a0f1 Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Fri, 6 Feb 2026 23:23:34 +0900 Subject: [PATCH 4/7] fix(db): address third-round review feedback for CHECK constraints - MySQL: apply strip_outer_parens to normalize CHECK expressions, matching PostgreSQL and SQLite behavior for cross-dialect consistency - MySQL: unwrap outer parentheses before NOT NULL / ENUM filter checks, handling (`col` is not null) format from information_schema - PostgreSQL: replace strip_prefix/strip_suffix with bracket-balanced extract_pg_check_expression() to handle "CHECK (...) NOT VALID" and "CHECK (...) NO INHERIT" suffixes - SQLite: use fetch_optional() instead of fetch_all() + first() for sqlite_master query (single row expected) - SQLite: update parse_sqlite_check_constraints doc comment to reflect both table-level and column-level CHECK constraint extraction - Add 5 tests for extract_pg_check_expression (simple, NOT VALID, NO INHERIT, complex nested, no prefix) Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 139 +++++++++++++++++-- 1 file changed, 124 insertions(+), 15 deletions(-) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index d2f7afb..2a82c1e 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -621,13 +621,10 @@ impl DatabaseIntrospector for PostgresIntrospector { .collect() }; - // pg_get_constraintdef returns "CHECK ((expression))" - // Strip the outer "CHECK (" prefix and ")" suffix to get the actual expression - let expression = raw_expression - .strip_prefix("CHECK (") - .and_then(|s| s.strip_suffix(')')) - .unwrap_or(&raw_expression) - .to_string(); + // pg_get_constraintdef returns "CHECK ((expression))" or + // "CHECK ((expression)) NOT VALID" / "CHECK (...) NO INHERIT" + // "CHECK (" 以降の括弧ペアをバランス取りで抽出し、末尾トークンに対応する + let expression = extract_pg_check_expression(&raw_expression); // PostgreSQL wraps simple expressions in extra parentheses. // Only strip if the entire expression is wrapped in a single matching pair. @@ -1011,8 +1008,35 @@ impl DatabaseIntrospector for MySqlIntrospector { // MySQL の自動生成制約をフィルタリング: // 1. NOT NULL チェック(ENUM カラムに自動付与される) // 2. ENUM バリデーション(_chk_N の名前で IN (...) を含む) + // NOT NULL チェックは `(`col` is not null)` のように括弧で囲まれることがあるため、 + // 外側の括弧を剥がした上で判定する let lower = check_clause.trim().to_lowercase(); - let is_not_null_check = lower.ends_with("is not null"); + let mut normalized = lower.as_str(); + while normalized.starts_with('(') && normalized.ends_with(')') { + let inner = &normalized[1..normalized.len() - 1]; + // strip_outer_parens と同様に、先頭の ( と末尾の ) が対応しているか確認 + let mut depth = 0i32; + let mut matched = true; + for (i, ch) in inner.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth < 0 && i < inner.len() - 1 { + matched = false; + break; + } + } + _ => {} + } + } + if matched && depth == 0 { + normalized = inner.trim(); + } else { + break; + } + } + let is_not_null_check = normalized.ends_with("is not null"); let is_enum_validation = { // MySQL は _chk_1, _chk_2, ... の名前で ENUM バリデーションを自動生成する let has_chk_suffix = constraint_name @@ -1023,7 +1047,7 @@ impl DatabaseIntrospector for MySqlIntrospector { .all(|c| c.is_ascii_digit()) }) .unwrap_or(false); - has_chk_suffix && (lower.contains("in (") || lower.contains("in(")) + has_chk_suffix && (normalized.contains("in (") || normalized.contains("in(")) }; if is_not_null_check || is_enum_validation { continue; @@ -1032,9 +1056,13 @@ impl DatabaseIntrospector for MySqlIntrospector { // バッククォートで囲まれたカラム名を抽出 let columns = extract_columns_from_check_expression(&check_clause); + // MySQL の check_clause は外側に括弧が付く (例: "(`balance` >= 0)") + // 他方言と統一するため strip_outer_parens で正規化する + let expression = strip_outer_parens(&check_clause); + constraints.push(RawConstraintInfo::Check { columns, - expression: check_clause, + expression, }); } @@ -1257,12 +1285,12 @@ impl DatabaseIntrospector for SqliteIntrospector { // CHECK制約 // sqlite_masterからCREATE TABLE文を取得してCHECK制約をパースする let create_sql_query = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?"; - let create_rows = sqlx::query(create_sql_query) + let create_row = sqlx::query(create_sql_query) .bind(table_name) - .fetch_all(pool) + .fetch_optional(pool) .await?; - if let Some(row) = create_rows.first() { + if let Some(row) = create_row { let create_sql: Option = row.get(0); if let Some(sql) = create_sql { let check_constraints = parse_sqlite_check_constraints(&sql); @@ -1312,6 +1340,42 @@ impl DatabaseIntrospector for SqliteIntrospector { } } +/// PostgreSQL の pg_get_constraintdef() 出力から CHECK 式を抽出する +/// +/// "CHECK ((expression))" から expression 部分を取り出す。 +/// "CHECK (...) NOT VALID" や "CHECK (...) NO INHERIT" のように +/// 末尾にトークンが付くケースにも対応する(括弧のバランスで式の範囲を特定)。 +fn extract_pg_check_expression(raw: &str) -> String { + let prefix = "CHECK ("; + let Some(start) = raw.find(prefix) else { + return raw.to_string(); + }; + let after_prefix = start + prefix.len(); + + // "CHECK (" の直後の '(' を含む位置から括弧のバランスを追跡 + // depth は既に 1("CHECK (" の '(' を含む) + let mut depth = 1i32; + let mut end = None; + for (i, ch) in raw[after_prefix..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = Some(after_prefix + i); + break; + } + } + _ => {} + } + } + + match end { + Some(pos) => raw[after_prefix..pos].to_string(), + None => raw.to_string(), + } +} + /// 式全体が一対の括弧で囲まれている場合のみ外側の括弧を除去する /// /// `(balance >= 0)` → `balance >= 0` (除去) @@ -1359,9 +1423,10 @@ fn extract_view_definition_from_create_sql(create_sql: &str) -> String { /// SQLite の CREATE TABLE 文からCHECK制約をパースする /// -/// テーブルレベルのCHECK制約を抽出する。 +/// テーブルレベルおよびカラム定義内の両方のCHECK制約を抽出する。 /// 文字列リテラル('...')およびダブルクォート識別子("...")内の CHECK は無視する。 -/// 例: `CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))` +/// 例(テーブルレベル): `CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))` +/// 例(カラムレベル) : `CREATE TABLE t (id INTEGER CHECK (id > 0), balance REAL)` fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { let mut results = Vec::new(); let chars: Vec<(usize, char)> = create_sql.char_indices().collect(); @@ -2138,6 +2203,50 @@ mod tests { assert_eq!(super::strip_string_literals("balance >= 0"), "balance >= 0"); } + // ========================================================================= + // extract_pg_check_expression テスト + // ========================================================================= + + #[test] + fn test_extract_pg_check_expression_simple() { + assert_eq!( + super::extract_pg_check_expression("CHECK ((balance >= 0))"), + "(balance >= 0)" + ); + } + + #[test] + fn test_extract_pg_check_expression_not_valid() { + // NOT VALID 末尾トークンがあっても式部分だけ抽出 + assert_eq!( + super::extract_pg_check_expression("CHECK ((balance >= 0)) NOT VALID"), + "(balance >= 0)" + ); + } + + #[test] + fn test_extract_pg_check_expression_no_inherit() { + assert_eq!( + super::extract_pg_check_expression("CHECK ((val > 0)) NO INHERIT"), + "(val > 0)" + ); + } + + #[test] + fn test_extract_pg_check_expression_complex() { + assert_eq!( + super::extract_pg_check_expression("CHECK (((val >= 0) AND (val <= 100))) NOT VALID"), + "((val >= 0) AND (val <= 100))" + ); + } + + #[test] + fn test_extract_pg_check_expression_no_prefix() { + // CHECK プレフィックスがない場合はそのまま返す + let raw = "something else"; + assert_eq!(super::extract_pg_check_expression(raw), raw); + } + // ========================================================================= // parse_mysql_set_values テスト // ========================================================================= From 037bdf7dc08578a5093f407c79a9511b781d082e Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Sat, 7 Feb 2026 00:07:51 +0900 Subject: [PATCH 5/7] fix(db): address fourth-round review feedback for CHECK constraints - MySQL: restrict NOT NULL filter to simple single-column expressions only (e.g. `col IS NOT NULL`), preventing compound expressions like `(a > 0) AND (b IS NOT NULL)` from being incorrectly filtered out - SQLite: detect CAST(... AS ) pattern and exclude the type name from column extraction, avoiding false positives like "INTEGER" being treated as a column name without adding data types to keyword list - Add 9 tests: CAST type exclusion, date column preservation, and 7 MySQL filter tests covering NOT NULL (simple, no-parens, compound), ENUM validation (_chk_2, _chk_3), and user-defined constraint preservation Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 164 ++++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index 2a82c1e..eb34783 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -1036,7 +1036,14 @@ impl DatabaseIntrospector for MySqlIntrospector { break; } } - let is_not_null_check = normalized.ends_with("is not null"); + // 単純な ` is not null` パターンのみをフィルタ(複合式は除外しない) + // 例: "`col` is not null" → フィルタ, "`a` > 0 and `b` is not null" → 保持 + let is_not_null_check = { + let trimmed_norm = normalized.trim(); + trimmed_norm.ends_with("is not null") + && !trimmed_norm.contains(" and ") + && !trimmed_norm.contains(" or ") + }; let is_enum_validation = { // MySQL は _chk_1, _chk_2, ... の名前で ENUM バリデーションを自動生成する let has_chk_suffix = constraint_name @@ -1640,14 +1647,34 @@ fn extract_columns_from_sqlite_check(expression: &str) -> Vec { "CONSTRAINT", ]; + // CAST(... AS ) パターンで の位置を収集 + // 例: "CAST(x AS INTEGER)" → "INTEGER" のバイト開始位置を記録し、カラム名から除外する + // これによりデータ型名をキーワードリストに含めずとも、CAST 式内の型名を安全に除外できる + let upper_stripped = stripped.to_uppercase(); + let mut cast_type_positions = std::collections::HashSet::new(); + for m in upper_stripped.match_indices(" AS ") { + let after_as = m.0 + m.1.len(); + // AS の直後の空白をスキップ + let type_start = upper_stripped[after_as..] + .find(|c: char| !c.is_whitespace()) + .map(|p| after_as + p) + .unwrap_or(after_as); + cast_type_positions.insert(type_start); + } + let mut columns = Vec::new(); for cap in IDENTIFIER_REGEX.captures_iter(&stripped) { let word = &cap[1]; let upper = word.to_uppercase(); - if !keywords.contains(&upper.as_str()) && !columns.contains(&word.to_string()) { - columns.push(word.to_string()); + let start = cap.get(1).unwrap().start(); + if keywords.contains(&upper.as_str()) + || columns.contains(&word.to_string()) + || cast_type_positions.contains(&start) + { + continue; } + columns.push(word.to_string()); } columns @@ -2181,6 +2208,137 @@ mod tests { assert_eq!(columns, vec!["val".to_string()]); } + #[test] + fn test_extract_columns_from_sqlite_check_cast_as_type() { + // CAST(x AS INTEGER) の INTEGER はカラム名として抽出しない + let columns = super::extract_columns_from_sqlite_check("CAST(val AS INTEGER) > 0"); + assert_eq!(columns, vec!["val".to_string()]); + } + + #[test] + fn test_extract_columns_from_sqlite_check_date_column() { + // date はデータ型名だがカラム名としても使われるため、除外しない + let columns = super::extract_columns_from_sqlite_check("date >= '2020-01-01'"); + assert_eq!(columns, vec!["date".to_string()]); + } + + // ========================================================================= + // MySQL 自動生成制約フィルタ テスト(ユニット的検証) + // ========================================================================= + + /// MySQL の NOT NULL / ENUM フィルタロジックを再現するヘルパー + fn should_filter_mysql_check(constraint_name: &str, check_clause: &str) -> bool { + let lower = check_clause.trim().to_lowercase(); + let mut normalized = lower.as_str(); + loop { + if normalized.starts_with('(') && normalized.ends_with(')') { + let inner = &normalized[1..normalized.len() - 1]; + let mut depth = 0i32; + let mut matched = true; + for (i, ch) in inner.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth < 0 && i < inner.len() - 1 { + matched = false; + break; + } + } + _ => {} + } + } + if matched && depth == 0 { + normalized = inner.trim(); + } else { + break; + } + } else { + break; + } + } + let is_not_null_check = { + let trimmed_norm = normalized.trim(); + trimmed_norm.ends_with("is not null") + && !trimmed_norm.contains(" and ") + && !trimmed_norm.contains(" or ") + }; + let is_enum_validation = { + let has_chk_suffix = constraint_name + .rfind("_chk_") + .map(|pos| { + constraint_name[pos + 5..] + .chars() + .all(|c| c.is_ascii_digit()) + }) + .unwrap_or(false); + has_chk_suffix && (normalized.contains("in (") || normalized.contains("in(")) + }; + is_not_null_check || is_enum_validation + } + + #[test] + fn test_mysql_filter_not_null_simple() { + // 単純な NOT NULL は自動生成としてフィルタされる + assert!(should_filter_mysql_check( + "users_chk_1", + "(`role` is not null)" + )); + } + + #[test] + fn test_mysql_filter_not_null_without_parens() { + assert!(should_filter_mysql_check( + "users_chk_1", + "`col` is not null" + )); + } + + #[test] + fn test_mysql_filter_compound_not_null_preserved() { + // 複合式は NOT NULL で終わっていてもフィルタしない + assert!(!should_filter_mysql_check( + "users_chk_1", + "(`a` > 0 AND `b` IS NOT NULL)" + )); + } + + #[test] + fn test_mysql_filter_enum_validation() { + // ENUM バリデーション制約は _chk_N + IN (...) でフィルタ + assert!(should_filter_mysql_check( + "users_chk_2", + "(`role` in ('admin','user','guest'))" + )); + } + + #[test] + fn test_mysql_filter_enum_validation_chk_3() { + // _chk_3 パターンもフィルタされる + assert!(should_filter_mysql_check( + "table_chk_3", + "(`status` in('active','inactive'))" + )); + } + + #[test] + fn test_mysql_filter_user_defined_preserved() { + // ユーザー定義の CHECK 制約はフィルタしない + assert!(!should_filter_mysql_check( + "users_balance_check", + "(`balance` >= 0)" + )); + } + + #[test] + fn test_mysql_filter_user_defined_with_in_preserved() { + // _chk_ パターンでなければ IN を含んでいてもフィルタしない + assert!(!should_filter_mysql_check( + "custom_check", + "(`val` in (1, 2, 3))" + )); + } + // ========================================================================= // strip_string_literals テスト // ========================================================================= From 237651d33887e38e1091eb46e1a44eee7d0cce5f Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Sat, 7 Feb 2026 00:36:07 +0900 Subject: [PATCH 6/7] fix(db): address fifth-round review feedback for CHECK constraints - MySQL NOT NULL filter now requires _chk_N naming pattern to avoid filtering user-defined CHECK (col IS NOT NULL) constraints - PostgreSQL string_agg delimiter changed from comma to Unit Separator (U+001F) to safely handle column names containing commas - SQLite column extraction now uses PRAGMA table_info column names instead of keyword heuristic, eliminating false positives from function names and data type keywords - Fixed unnecessary String allocation in SQLite column deduplication Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 240 +++++++++---------- 1 file changed, 113 insertions(+), 127 deletions(-) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index eb34783..3236085 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -585,12 +585,12 @@ impl DatabaseIntrospector for PostgresIntrospector { // CHECK制約 // pg_constraintからCHECK制約を取得(contype = 'c') // LEFT JOIN で式のみの制約(conkey が空)にも対応 - // string_agg でカラム名をカンマ区切りで返す(Any ドライバは配列非対応) + // string_agg で Unit Separator (U+001F) 区切りで返す(カラム名にカンマが含まれる場合に備える) let check_sql = r#" SELECT con.conname::text, pg_get_constraintdef(con.oid)::text AS check_expression, - COALESCE(string_agg(a.attname::text, ',' ORDER BY u.ord), '') AS columns + COALESCE(string_agg(a.attname::text, E'\x1f' ORDER BY u.ord), '') AS columns FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace @@ -616,7 +616,7 @@ impl DatabaseIntrospector for PostgresIntrospector { Vec::new() } else { columns_str - .split(',') + .split('\x1f') .map(|s| s.trim().to_string()) .collect() }; @@ -1036,26 +1036,29 @@ impl DatabaseIntrospector for MySqlIntrospector { break; } } - // 単純な ` is not null` パターンのみをフィルタ(複合式は除外しない) - // 例: "`col` is not null" → フィルタ, "`a` > 0 and `b` is not null" → 保持 + // MySQL は _chk_1, _chk_2, ... の名前で NOT NULL / ENUM バリデーションを自動生成する + // ユーザー定義の制約名(_chk_N パターンでない)は常に保持する + let has_chk_suffix = constraint_name + .rfind("_chk_") + .map(|pos| { + constraint_name[pos + 5..] + .chars() + .all(|c| c.is_ascii_digit()) + }) + .unwrap_or(false); + // 単純な ` is not null` パターン + 自動生成名のみをフィルタ(複合式は除外しない) + // 例: "`col` is not null" + _chk_N名 → フィルタ + // "`a` > 0 and `b` is not null" → 保持(複合式) + // "`col` is not null" + カスタム名 → 保持(ユーザー定義) let is_not_null_check = { let trimmed_norm = normalized.trim(); - trimmed_norm.ends_with("is not null") + has_chk_suffix + && trimmed_norm.ends_with("is not null") && !trimmed_norm.contains(" and ") && !trimmed_norm.contains(" or ") }; - let is_enum_validation = { - // MySQL は _chk_1, _chk_2, ... の名前で ENUM バリデーションを自動生成する - let has_chk_suffix = constraint_name - .rfind("_chk_") - .map(|pos| { - constraint_name[pos + 5..] - .chars() - .all(|c| c.is_ascii_digit()) - }) - .unwrap_or(false); - has_chk_suffix && (normalized.contains("in (") || normalized.contains("in(")) - }; + let is_enum_validation = + has_chk_suffix && (normalized.contains("in (") || normalized.contains("in(")); if is_not_null_check || is_enum_validation { continue; } @@ -1291,6 +1294,10 @@ impl DatabaseIntrospector for SqliteIntrospector { // CHECK制約 // sqlite_masterからCREATE TABLE文を取得してCHECK制約をパースする + // PRAGMA table_info の結果から全カラム名を抽出し、カラム名照合に使用する + let all_column_names: Vec = + rows.iter().map(|row| row.get::(1)).collect(); + let create_sql_query = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?"; let create_row = sqlx::query(create_sql_query) .bind(table_name) @@ -1300,7 +1307,7 @@ impl DatabaseIntrospector for SqliteIntrospector { if let Some(row) = create_row { let create_sql: Option = row.get(0); if let Some(sql) = create_sql { - let check_constraints = parse_sqlite_check_constraints(&sql); + let check_constraints = parse_sqlite_check_constraints(&sql, &all_column_names); constraints.extend(check_constraints); } } @@ -1434,7 +1441,10 @@ fn extract_view_definition_from_create_sql(create_sql: &str) -> String { /// 文字列リテラル('...')およびダブルクォート識別子("...")内の CHECK は無視する。 /// 例(テーブルレベル): `CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))` /// 例(カラムレベル) : `CREATE TABLE t (id INTEGER CHECK (id > 0), balance REAL)` -fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { +fn parse_sqlite_check_constraints( + create_sql: &str, + table_columns: &[String], +) -> Vec { let mut results = Vec::new(); let chars: Vec<(usize, char)> = create_sql.char_indices().collect(); let len = chars.len(); @@ -1564,7 +1574,8 @@ fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { if let Some(end_byte) = expr_end { let expression = create_sql[paren_start..end_byte].trim().to_string(); - let columns = extract_columns_from_sqlite_check(&expression); + let columns = + extract_columns_from_sqlite_check(&expression, table_columns); results.push(RawConstraintInfo::Check { columns, @@ -1588,93 +1599,26 @@ fn parse_sqlite_check_constraints(create_sql: &str) -> Vec { results } -/// SQLite CHECK式からカラム名を推定する +/// SQLite CHECK式からカラム名を抽出する /// /// 文字列リテラル('...')内の単語は無視し、 -/// SQLキーワード・関数名・データ型を除外して識別子を抽出する。 -fn extract_columns_from_sqlite_check(expression: &str) -> Vec { +/// PRAGMA table_info から取得した実カラム名一覧と照合して +/// 式中に出現するカラム名のみを返す。 +fn extract_columns_from_sqlite_check(expression: &str, table_columns: &[String]) -> Vec { // 文字列リテラルを除去してからパース let stripped = strip_string_literals(expression); - let keywords = [ - // 論理演算子・比較・制御構文 - "AND", - "OR", - "NOT", - "IN", - "IS", - "LIKE", - "BETWEEN", - "EXISTS", - "CASE", - "WHEN", - "THEN", - "ELSE", - "END", - // リテラル・真偽値 - "NULL", - "TRUE", - "FALSE", - // 関数 - "LENGTH", - "LOWER", - "UPPER", - "SUBSTR", - "ABS", - "ROUND", - "COALESCE", - "IFNULL", - "NULLIF", - "TRIM", - "LTRIM", - "RTRIM", - "MIN", - "MAX", - "AVG", - "COUNT", - "SUM", - "RANDOM", - "CHAR", - "HEX", - // その他 - "AS", - "CAST", - "COLLATE", - "GLOB", - "MATCH", - "REGEXP", - "CHECK", - "CONSTRAINT", - ]; - - // CAST(... AS ) パターンで の位置を収集 - // 例: "CAST(x AS INTEGER)" → "INTEGER" のバイト開始位置を記録し、カラム名から除外する - // これによりデータ型名をキーワードリストに含めずとも、CAST 式内の型名を安全に除外できる - let upper_stripped = stripped.to_uppercase(); - let mut cast_type_positions = std::collections::HashSet::new(); - for m in upper_stripped.match_indices(" AS ") { - let after_as = m.0 + m.1.len(); - // AS の直後の空白をスキップ - let type_start = upper_stripped[after_as..] - .find(|c: char| !c.is_whitespace()) - .map(|p| after_as + p) - .unwrap_or(after_as); - cast_type_positions.insert(type_start); - } - let mut columns = Vec::new(); for cap in IDENTIFIER_REGEX.captures_iter(&stripped) { let word = &cap[1]; - let upper = word.to_uppercase(); - let start = cap.get(1).unwrap().start(); - if keywords.contains(&upper.as_str()) - || columns.contains(&word.to_string()) - || cast_type_positions.contains(&start) + if table_columns.iter().any(|c| c.eq_ignore_ascii_case(word)) + && !columns + .iter() + .any(|c: &String| c.eq_ignore_ascii_case(word)) { - continue; + columns.push(word.to_string()); } - columns.push(word.to_string()); } columns @@ -2032,7 +1976,8 @@ mod tests { #[test] fn test_parse_sqlite_check_simple() { let sql = "CREATE TABLE t (id INTEGER, balance REAL, CHECK (balance >= 0))"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["id".to_string(), "balance".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert_eq!(checks.len(), 1); match &checks[0] { RawConstraintInfo::Check { @@ -2049,14 +1994,16 @@ mod tests { #[test] fn test_parse_sqlite_check_multiple() { let sql = "CREATE TABLE t (id INTEGER, age INTEGER, balance REAL, CHECK (age >= 0), CHECK (balance > 0))"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["id".to_string(), "age".to_string(), "balance".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert_eq!(checks.len(), 2); } #[test] fn test_parse_sqlite_check_nested_parens() { let sql = "CREATE TABLE t (id INTEGER, val INTEGER, CHECK ((val >= 0) AND (val <= 100)))"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["id".to_string(), "val".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert_eq!(checks.len(), 1); match &checks[0] { RawConstraintInfo::Check { expression, .. } => { @@ -2069,14 +2016,16 @@ mod tests { #[test] fn test_parse_sqlite_check_case_insensitive() { let sql = "CREATE TABLE t (id INTEGER, x INTEGER, check (x > 0))"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["id".to_string(), "x".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert_eq!(checks.len(), 1); } #[test] fn test_parse_sqlite_check_no_checks() { let sql = "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["id".to_string(), "name".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert!(checks.is_empty()); } @@ -2088,7 +2037,8 @@ mod tests { fn test_parse_sqlite_check_ignores_check_in_string_literal() { // 文字列リテラル内の 'CHECK' は無視する let sql = "CREATE TABLE t (val TEXT, CHECK (val != 'CHECK'))"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["val".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert_eq!(checks.len(), 1); match &checks[0] { RawConstraintInfo::Check { expression, .. } => { @@ -2102,7 +2052,8 @@ mod tests { fn test_parse_sqlite_check_column_named_check_prefix() { // check_date のようなカラム名は CHECK として誤検出しない let sql = "CREATE TABLE t (check_date TEXT, CHECK (check_date IS NOT NULL))"; - let checks = super::parse_sqlite_check_constraints(sql); + let cols = vec!["check_date".to_string()]; + let checks = super::parse_sqlite_check_constraints(sql, &cols); assert_eq!(checks.len(), 1); } @@ -2174,19 +2125,24 @@ mod tests { #[test] fn test_extract_columns_from_sqlite_check_simple() { - let columns = super::extract_columns_from_sqlite_check("balance >= 0"); + let table_cols = vec!["balance".to_string()]; + let columns = super::extract_columns_from_sqlite_check("balance >= 0", &table_cols); assert_eq!(columns, vec!["balance".to_string()]); } #[test] fn test_extract_columns_from_sqlite_check_with_and() { - let columns = super::extract_columns_from_sqlite_check("age >= 0 AND age <= 150"); + let table_cols = vec!["age".to_string()]; + let columns = + super::extract_columns_from_sqlite_check("age >= 0 AND age <= 150", &table_cols); assert_eq!(columns, vec!["age".to_string()]); } #[test] fn test_extract_columns_from_sqlite_check_multiple_columns() { - let columns = super::extract_columns_from_sqlite_check("start_date < end_date"); + let table_cols = vec!["start_date".to_string(), "end_date".to_string()]; + let columns = + super::extract_columns_from_sqlite_check("start_date < end_date", &table_cols); assert_eq!( columns, vec!["start_date".to_string(), "end_date".to_string()] @@ -2196,32 +2152,53 @@ mod tests { #[test] fn test_extract_columns_from_sqlite_check_ignores_string_literals() { // 文字列リテラル内の単語はカラム名として抽出しない - let columns = super::extract_columns_from_sqlite_check("status IN ('pending', 'active')"); + let table_cols = vec!["status".to_string()]; + let columns = super::extract_columns_from_sqlite_check( + "status IN ('pending', 'active')", + &table_cols, + ); assert_eq!(columns, vec!["status".to_string()]); } #[test] fn test_extract_columns_from_sqlite_check_ignores_keywords() { - // CASE/WHEN/THEN/ELSE/END はキーワードとして除外される - let columns = - super::extract_columns_from_sqlite_check("CASE WHEN val > 0 THEN 1 ELSE 0 END = 1"); + // CASE/WHEN/THEN/ELSE/END はテーブルカラムでないため抽出しない + let table_cols = vec!["val".to_string()]; + let columns = super::extract_columns_from_sqlite_check( + "CASE WHEN val > 0 THEN 1 ELSE 0 END = 1", + &table_cols, + ); assert_eq!(columns, vec!["val".to_string()]); } #[test] fn test_extract_columns_from_sqlite_check_cast_as_type() { - // CAST(x AS INTEGER) の INTEGER はカラム名として抽出しない - let columns = super::extract_columns_from_sqlite_check("CAST(val AS INTEGER) > 0"); + // CAST(x AS INTEGER) の INTEGER はテーブルカラムでないため抽出しない + let table_cols = vec!["val".to_string()]; + let columns = + super::extract_columns_from_sqlite_check("CAST(val AS INTEGER) > 0", &table_cols); assert_eq!(columns, vec!["val".to_string()]); } #[test] fn test_extract_columns_from_sqlite_check_date_column() { - // date はデータ型名だがカラム名としても使われるため、除外しない - let columns = super::extract_columns_from_sqlite_check("date >= '2020-01-01'"); + // date はテーブルカラムとして存在するため抽出される + let table_cols = vec!["date".to_string()]; + let columns = super::extract_columns_from_sqlite_check("date >= '2020-01-01'", &table_cols); assert_eq!(columns, vec!["date".to_string()]); } + #[test] + fn test_extract_columns_from_sqlite_check_function_name_excluded() { + // 関数名 (STRFTIME, JSON_VALID) はテーブルカラムでないため除外される + let table_cols = vec!["created_at".to_string()]; + let columns = super::extract_columns_from_sqlite_check( + "STRFTIME('%Y', created_at) > '2020'", + &table_cols, + ); + assert_eq!(columns, vec!["created_at".to_string()]); + } + // ========================================================================= // MySQL 自動生成制約フィルタ テスト(ユニット的検証) // ========================================================================= @@ -2257,23 +2234,23 @@ mod tests { break; } } + let has_chk_suffix = constraint_name + .rfind("_chk_") + .map(|pos| { + constraint_name[pos + 5..] + .chars() + .all(|c| c.is_ascii_digit()) + }) + .unwrap_or(false); let is_not_null_check = { let trimmed_norm = normalized.trim(); - trimmed_norm.ends_with("is not null") + has_chk_suffix + && trimmed_norm.ends_with("is not null") && !trimmed_norm.contains(" and ") && !trimmed_norm.contains(" or ") }; - let is_enum_validation = { - let has_chk_suffix = constraint_name - .rfind("_chk_") - .map(|pos| { - constraint_name[pos + 5..] - .chars() - .all(|c| c.is_ascii_digit()) - }) - .unwrap_or(false); - has_chk_suffix && (normalized.contains("in (") || normalized.contains("in(")) - }; + let is_enum_validation = + has_chk_suffix && (normalized.contains("in (") || normalized.contains("in(")); is_not_null_check || is_enum_validation } @@ -2339,6 +2316,15 @@ mod tests { )); } + #[test] + fn test_mysql_filter_user_defined_not_null_preserved() { + // ユーザー定義の制約名で IS NOT NULL はフィルタしない + assert!(!should_filter_mysql_check( + "require_col_not_null", + "(`col` is not null)" + )); + } + // ========================================================================= // strip_string_literals テスト // ========================================================================= From ba7f7b57e0ee3f44505ba1a73bd95190e0b7a47b Mon Sep 17 00:00:00 2001 From: Naoki Takahashi Date: Sat, 7 Feb 2026 01:01:04 +0900 Subject: [PATCH 7/7] fix(db): support Unicode identifiers in SQLite CHECK column extraction Change IDENTIFIER_REGEX from ASCII-only pattern to Unicode XID_Start / XID_Continue to correctly extract non-ASCII column names (e.g. Japanese) from SQLite CHECK constraint expressions. Co-Authored-By: Claude Opus 4.6 --- src/db/src/adapters/database_introspector.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db/src/adapters/database_introspector.rs b/src/db/src/adapters/database_introspector.rs index 3236085..3f3270e 100644 --- a/src/db/src/adapters/database_introspector.rs +++ b/src/db/src/adapters/database_introspector.rs @@ -12,8 +12,9 @@ use sqlx::AnyPool; use sqlx::Row; /// 識別子検出用の正規表現(コンパイル済みキャッシュ) +/// Unicode 識別子 (XID_Start/XID_Continue) とアンダースコアを許可する。 static IDENTIFIER_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b").unwrap()); + LazyLock::new(|| Regex::new(r"\b([\p{XID_Start}_][\p{XID_Continue}_]*)\b").unwrap()); /// MySQL の information_schema は多くのカラムを BLOB/VARBINARY 型で返す。 /// sqlx の Any ドライバは String として直接デコードできないため、