diff --git a/src/catalog/constraint_populator.rs b/src/catalog/constraint_populator.rs index 2cd9fe75..a689bb6e 100644 --- a/src/catalog/constraint_populator.rs +++ b/src/catalog/constraint_populator.rs @@ -6,7 +6,7 @@ use regex::Regex; // Pre-compiled regex patterns for constraint parsing static PK_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\b(\w+)\s+[^,\)]*\bPRIMARY\s+KEY\b").unwrap() + Regex::new(r"(?i)\b(\w+)\s+[^,()]*(?:\([^)]*\)[^,()]*)?\s*\bPRIMARY\s+KEY\b").unwrap() }); static TABLE_PK_REGEX: Lazy = Lazy::new(|| { @@ -14,7 +14,7 @@ static TABLE_PK_REGEX: Lazy = Lazy::new(|| { }); static UNIQUE_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\b(\w+)\s+[^,\)]*\bUNIQUE\b").unwrap() + Regex::new(r"(?i)\b(\w+)\s+[^,()]*(?:\([^)]*\)[^,()]*)?\s*\bUNIQUE\b").unwrap() }); static TABLE_UNIQUE_REGEX: Lazy = Lazy::new(|| { @@ -26,11 +26,11 @@ static CHECK_REGEX: Lazy = Lazy::new(|| { }); static NOT_NULL_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\b(\w+)\s+[^,\)]*\bNOT\s+NULL\b").unwrap() + Regex::new(r"(?i)\b(\w+)\s+[^,()]*(?:\([^)]*\)[^,()]*)?\s*\bNOT\s+NULL\b").unwrap() }); static DEFAULT_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\b(\w+)\s+[^,\)]*\bDEFAULT\s+([^,\)]+)").unwrap() + Regex::new(r"(?i)\b(\w+)\s+[^,()]*(?:\([^)]*\)[^,()]*)?\s*\bDEFAULT\s+([^,()]+)").unwrap() }); static FOREIGN_KEY_REGEX: Lazy = Lazy::new(|| { @@ -38,11 +38,11 @@ static FOREIGN_KEY_REGEX: Lazy = Lazy::new(|| { }); static INLINE_FOREIGN_KEY_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\b(\w+)\s+[^,\)]*\bREFERENCES\s+(\w+)\s*\(\s*([^)]+)\s*\)").unwrap() + Regex::new(r"(?i)\b(\w+)\s+[^,()]*(?:\([^)]*\)[^,()]*)?\s*\bREFERENCES\s+(\w+)\s*\(\s*([^)]+)\s*\)").unwrap() }); static TABLE_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)CREATE\s+TABLE\s+[^(]+\(\s*(.+)\s*\)").unwrap() + Regex::new(r"(?is)CREATE\s+TABLE\s+[^(]+\(\s*(.+)\s*\)").unwrap() }); /// Populate PostgreSQL catalog tables with constraint information for a newly created table @@ -115,7 +115,7 @@ fn get_referenced_table_oid(_conn: &Connection, definition: &str) -> Result Result<()> { +pub fn populate_table_constraints(conn: &Connection, table_name: &str, create_sql: &str, table_oid: &str) -> Result<()> { let constraints = parse_table_constraints(table_name, create_sql); eprintln!("🔎 Found {} constraints for table {}", constraints.len(), table_name); for c in &constraints { @@ -615,4 +615,67 @@ fn generate_sequence_oid(table_name: &str, column_name: &str) -> u32 { let sequence_name = format!("{}_{}_seq", table_name, column_name); // Use the standard OID generator but offset for sequences to avoid conflicts generate_oid(&sequence_name) + 50000 -} \ No newline at end of file +} + +#[cfg(test)] +mod v25_column_regex_tests { + use super::*; + + const CONFIG_DDL: &str = "CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')"; + const WISH_DDL: &str = r#"CREATE TABLE wish_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL REFERENCES users(id), + item_id INTEGER NOT NULL REFERENCES shop_items(id), + active INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)"#; + + #[test] + fn v25_pk_extracts_real_column_not_create() { + let cons = parse_table_constraints("config", CONFIG_DDL); + let pk: Vec<_> = cons.iter().filter(|c| c.contype == "p").collect(); + assert_eq!(pk.len(), 1, "应恰好 1 个主键"); + assert_eq!(pk[0].columns, vec!["key".to_string()], + "PK 列应为 key,而不是被 CREATE 劫持"); + } + + #[test] + fn v25_not_null_extracts_column() { + let cons = parse_table_constraints("config", CONFIG_DDL); + let nn: Vec<_> = cons.iter().filter(|c| c.contype == "c").collect(); + assert!(!nn.is_empty()); + assert!(nn.iter().any(|c| c.columns == vec!["value".to_string()])); + } + + #[test] + fn v25_inline_fk_extracts_column() { + let cons = parse_table_constraints("wish_goals", WISH_DDL); + let fks: Vec<_> = cons.iter().filter(|c| c.contype == "f").collect(); + assert_eq!(fks.len(), 2, "wish_goals 应 2 个外键"); + assert_eq!(fks[0].columns, vec!["child_id".to_string()]); + assert_eq!(fks[1].columns, vec!["item_id".to_string()]); + } + + #[test] + fn v25_pk_autoincrement_column() { + let cons = parse_table_constraints("wish_goals", WISH_DDL); + let pk: Vec<_> = cons.iter().filter(|c| c.contype == "p").collect(); + assert_eq!(pk.len(), 1); + assert_eq!(pk[0].columns, vec!["id".to_string()]); + } + + #[test] + fn v25_get_column_number_multiline_ddl() { + // 多行 DDL(wish_goals 真实形态)必须能定位列号 + assert_eq!(get_column_number(WISH_DDL, "id"), Some(1), "id 应为第 1 列"); + assert_eq!(get_column_number(WISH_DDL, "child_id"), Some(2), "child_id 应为第 2 列"); + assert_eq!(get_column_number(WISH_DDL, "item_id"), Some(3), "item_id 应为第 3 列"); + assert_eq!(get_column_number(WISH_DDL, "active"), Some(4), "active 应为第 4 列"); + } + + #[test] + fn v25_get_column_number_single_line_ddl() { + assert_eq!(get_column_number(CONFIG_DDL, "key"), Some(1), "key 应为第 1 列"); + assert_eq!(get_column_number(CONFIG_DDL, "value"), Some(2), "value 应为第 2 列"); + } +} diff --git a/src/catalog/pg_settings.rs b/src/catalog/pg_settings.rs index 4aea4162..631bcb31 100644 --- a/src/catalog/pg_settings.rs +++ b/src/catalog/pg_settings.rs @@ -140,6 +140,19 @@ impl PgSettingsHandler { ("statement_timeout", "0", "ms", "Client Connection Defaults", "Statement timeout", "user", "integer"), ("lock_timeout", "0", "ms", "Client Connection Defaults", "Lock timeout", "user", "integer"), ("idle_in_transaction_session_timeout", "0", "ms", "Client Connection Defaults", "Idle transaction timeout", "user", "integer"), + // === v38: pgjdbc 通过 pg_catalog.pg_settings 读取这些 preset。 + // 带 pg_catalog. 前缀的查询会被路由到本 handler(而不是 SQLite 视图), + // 此前列表里缺 max_index_keys,pgjdbc getMaxIndexKeys() 拿到 0 行后抛 + // "Unable to determine a value for MaxIndexKeys due to missing system + // catalog data",导致 getImportedKeys / getExportedKeys(DBeaver 外键 + // 标签页)整体不可用。取值与 SQLite 视图 (migration v33) 保持一致。 + ("max_index_keys", "32", "", "Preset Options", "Maximum number of index keys", "internal", "integer"), + ("max_identifier_length", "63", "", "Preset Options", "Maximum identifier length", "internal", "integer"), + ("max_function_args", "100", "", "Preset Options", "Maximum function arguments", "internal", "integer"), + ("block_size", "8192", "", "Preset Options", "Database block size", "internal", "integer"), + ("segment_size", "131072", "8kB", "Preset Options", "Segment size", "internal", "integer"), + ("wal_block_size", "8192", "", "Preset Options", "WAL block size", "internal", "integer"), + ("wal_segment_size", "16MB", "", "Preset Options", "WAL segment size", "internal", "integer"), ]; settings_data diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 54a829ca..793c8f60 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -23,7 +23,1874 @@ pub struct CatalogInterceptor; impl CatalogInterceptor { /// Check if a query is targeting pg_catalog and handle it - pub async fn intercept_query(query: &str, db: Arc, session: Option>) -> Option> { + pub fn is_catalog_query(query: &str) -> bool { + let lower_query = query.to_lowercase(); + + // pgsqlite's own cache-status pseudo table is served by the interceptor. + if lower_query.contains("select * from pgsqlite_cache_status") { + return true; + } + + // version() is deliberately left to the SQLite function layer. + if lower_query.trim() == "select pg_catalog.version()" + || lower_query.trim() == "select version()" + { + return false; + } + + // Check for catalog tables + let has_catalog_tables = lower_query.contains("pg_catalog") || lower_query.contains("pg_type") || + lower_query.contains("pg_namespace") || lower_query.contains("pg_range") || + lower_query.contains("pg_tablespace") || + lower_query.contains("pg_class") || lower_query.contains("pg_attribute") || + lower_query.contains("pg_enum") || + lower_query.contains("pg_description") || lower_query.contains("pg_roles") || + lower_query.contains("pg_user") || lower_query.contains("pg_authid") || + lower_query.contains("pg_stats") || lower_query.contains("pg_constraint") || + lower_query.contains("pg_depend") || lower_query.contains("pg_sequence") || + lower_query.contains("pg_trigger") || lower_query.contains("pg_settings") || + lower_query.contains("pg_collation") || + lower_query.contains("pg_replication_slots") || + lower_query.contains("pg_shdepend") || + lower_query.contains("pg_statistic") || + lower_query.contains("information_schema") || + lower_query.contains("pg_stat_") || lower_query.contains("pg_database") || + lower_query.contains("pg_foreign_data_wrapper"); + + // Check for system functions + let has_system_functions = lower_query.contains("to_regtype") || + lower_query.contains("pg_get_constraintdef") || lower_query.contains("pg_table_is_visible") || + lower_query.contains("format_type") || lower_query.contains("pg_get_expr") || + lower_query.contains("pg_get_userbyid") || lower_query.contains("pg_get_indexdef") || + lower_query.contains("pg_size_pretty"); + + has_catalog_tables || has_system_functions + } + async fn v38_jdbc_metadata( + query: &str, + db: &Arc, + ) -> Option> { + let lower = query.to_lowercase(); + + // ---- v39: getImportedKeys / getExportedKeys / getCrossReference ---- + // 指纹:模板里同时出现 PKTABLE_CAT 与 FKTABLE_CAT 两个别名,形状极稳。 + // 原 SQL 有两处 SQLite 死语法:`generate_series(1,32) AS pos (n)`(表别名后 + // 的列别名列表)与 `con.confkey[pos.n]`(数组下标);且 pg_constraint 里 + // 压根没有外键行。改走 PRAGMA foreign_key_list。 + if lower.contains("pktable_cat") && lower.contains("fktable_cat") { + let child = Self::v39_extract_eq_str(query, "fkc.relname"); + let parent = Self::v39_extract_eq_str(query, "pkc.relname"); + info!( + "v39: intercept pgjdbc foreign keys child={child:?} parent={parent:?}" + ); + return Some( + Self::v39_get_foreign_keys(db, child.as_deref(), parent.as_deref()).await, + ); + } + + // ---- getSchemas:靠 current_schemas 数组下标识别 ---- + if lower.contains("current_schemas") && lower.contains("table_schem") { + info!("v38: intercept pgjdbc getSchemas"); + return Some(Self::v38_get_schemas(db).await); + } + + if !lower.contains("_pg_expandarray") { + return None; + } + let table = match Self::v38_extract_relname(query) { + Some(t) => t, + None => return None, + }; + + // getIndexInfo 模板带 pg_am/amname 与 ASC_OR_DESC;getPrimaryKeys 带 indisprimary + if lower.contains("am.amname") || lower.contains("asc_or_desc") { + info!("v38: intercept pgjdbc getIndexInfo for '{}'", table); + let unique_only = lower.contains("and i.indisunique"); + return Some(Self::v38_get_index_info(db, &table, unique_only).await); + } + if lower.contains("indisprimary") { + info!("v38: intercept pgjdbc getPrimaryKeys for '{}'", table); + return Some(Self::v38_get_primary_keys(db, &table).await); + } + None + } + fn v38_extract_relname(query: &str) -> Option { + let lower = query.to_lowercase(); + let key = "ct.relname"; + let mut from = 0usize; + while let Some(pos) = lower[from..].find(key) { + let after = from + pos + key.len(); + let rest = &query[after..]; + let b = rest.as_bytes(); + let mut i = 0usize; + while i < b.len() && (b[i] == b' ' || b[i] == b'=') { + i += 1; + } + if rest.len() > i && rest[i..].to_lowercase().starts_with("like") { + i += 4; + } + while i < b.len() && b[i] == b' ' { + i += 1; + } + if i < b.len() && b[i] == b'\'' { + let s = i + 1; + if let Some(e) = rest[s..].find('\'') { + let raw = &rest[s..s + e]; + return Some(raw.replace("\\_", "_").replace("\\%", "%")); + } + } + from = after; + } + None + } + fn v38_sqlq(s: &str) -> String { + s.replace('\'', "''") + } + fn v38_cell(row: &[Option>], idx: usize) -> Option> { + row.get(idx).cloned().flatten() + } + fn v38_text(row: &[Option>], idx: usize) -> String { + match Self::v38_cell(row, idx) { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => String::new(), + } + } + async fn v38_get_primary_keys( + db: &Arc, + table: &str, + ) -> Result { + let sql = format!( + "SELECT name, pk FROM pragma_table_info('{}') WHERE pk > 0 ORDER BY pk", + Self::v38_sqlq(table) + ); + let res = db.query(&sql).await.map_err(PgSqliteError::Sqlite)?; + let pk_name = format!("{table}_pkey"); + let mut rows = Vec::new(); + for r in &res.rows { + let col = Self::v38_cell(r, 0).unwrap_or_default(); + let seq = Self::v38_cell(r, 1).unwrap_or_else(|| b"1".to_vec()); + rows.push(vec![ + None, // TABLE_CAT + Some(b"public".to_vec()), // TABLE_SCHEM + Some(table.as_bytes().to_vec()), // TABLE_NAME + Some(col), // COLUMN_NAME + Some(seq), // KEY_SEQ + Some(pk_name.as_bytes().to_vec()), // PK_NAME + ]); + } + let n = rows.len(); + Ok(DbResponse { + columns: vec![ + "TABLE_CAT".to_string(), + "TABLE_SCHEM".to_string(), + "TABLE_NAME".to_string(), + "COLUMN_NAME".to_string(), + "KEY_SEQ".to_string(), + "PK_NAME".to_string(), + ], + rows, + rows_affected: n, + }) + } + async fn v38_get_index_info( + db: &Arc, + table: &str, + unique_only: bool, + ) -> Result { + let list_sql = format!( + "SELECT name, \"unique\" FROM pragma_index_list('{}')", + Self::v38_sqlq(table) + ); + let list = db.query(&list_sql).await.map_err(PgSqliteError::Sqlite)?; + let mut rows = Vec::new(); + for r in &list.rows { + let iname = Self::v38_text(r, 0); + if iname.is_empty() { + continue; + } + let uniq = Self::v38_text(r, 1) == "1"; + if unique_only && !uniq { + continue; + } + let info_sql = format!( + "SELECT seqno, name FROM pragma_index_info('{}') ORDER BY seqno", + Self::v38_sqlq(&iname) + ); + let info = match db.query(&info_sql).await { + Ok(v) => v, + Err(_) => continue, + }; + for ir in &info.rows { + let seqno: i64 = Self::v38_text(ir, 0).parse().unwrap_or(0); + let cname = Self::v38_cell(ir, 1).unwrap_or_default(); + rows.push(vec![ + None, // TABLE_CAT + Some(b"public".to_vec()), // TABLE_SCHEM + Some(table.as_bytes().to_vec()), // TABLE_NAME + Some(if uniq { b"f".to_vec() } else { b"t".to_vec() }), // NON_UNIQUE + None, // INDEX_QUALIFIER + Some(iname.as_bytes().to_vec()), // INDEX_NAME + Some(b"3".to_vec()), // TYPE: 3 = btree + Some((seqno + 1).to_string().into_bytes()), // ORDINAL_POSITION + Some(cname), // COLUMN_NAME + Some(b"A".to_vec()), // ASC_OR_DESC + Some(b"0".to_vec()), // CARDINALITY + Some(b"0".to_vec()), // PAGES + None, // FILTER_CONDITION + ]); + } + } + let n = rows.len(); + Ok(DbResponse { + columns: vec![ + "TABLE_CAT".to_string(), + "TABLE_SCHEM".to_string(), + "TABLE_NAME".to_string(), + "NON_UNIQUE".to_string(), + "INDEX_QUALIFIER".to_string(), + "INDEX_NAME".to_string(), + "TYPE".to_string(), + "ORDINAL_POSITION".to_string(), + "COLUMN_NAME".to_string(), + "ASC_OR_DESC".to_string(), + "CARDINALITY".to_string(), + "PAGES".to_string(), + "FILTER_CONDITION".to_string(), + ], + rows, + rows_affected: n, + }) + } + async fn v38_get_schemas(db: &Arc) -> Result { + let mut names: Vec = Vec::new(); + if let Ok(res) = db + .query("SELECT nspname FROM pg_namespace WHERE nspname <> 'pg_toast' ORDER BY nspname") + .await + { + for r in &res.rows { + let v = Self::v38_text(r, 0); + if !v.is_empty() { + names.push(v); + } + } + } + if names.is_empty() { + names = vec![ + "information_schema".to_string(), + "pg_catalog".to_string(), + "public".to_string(), + ]; + } + let rows: Vec>>> = names + .iter() + .map(|n| vec![Some(n.as_bytes().to_vec()), None]) + .collect(); + let n = rows.len(); + Ok(DbResponse { + columns: vec!["TABLE_SCHEM".to_string(), "TABLE_CATALOG".to_string()], + rows, + rows_affected: n, + }) + } + fn v39_extract_eq_str(query: &str, key: &str) -> Option { + let lower = query.to_lowercase(); + let key_l = key.to_lowercase(); + let mut from = 0usize; + while let Some(pos) = lower[from..].find(key_l.as_str()) { + let after = from + pos + key_l.len(); + let rest = &query[after..]; + let b = rest.as_bytes(); + let mut i = 0usize; + while i < b.len() && (b[i] == b' ' || b[i] == b'=') { + i += 1; + } + if rest.len() > i && rest[i..].to_lowercase().starts_with("like") { + i += 4; + } + while i < b.len() && b[i] == b' ' { + i += 1; + } + if i < b.len() && b[i] == b'\'' { + let s = i + 1; + if let Some(e) = rest[s..].find('\'') { + let raw = &rest[s..s + e]; + return Some(raw.replace("\\_", "_").replace("\\%", "%")); + } + } + from = after; + } + None + } + fn v39_fk_rule(action: &str) -> &'static str { + match action.trim().to_uppercase().as_str() { + "CASCADE" => "0", // importedKeyCascade + "RESTRICT" => "1", // importedKeyRestrict + "SET NULL" => "2", // importedKeySetNull + "SET DEFAULT" => "4", // importedKeySetDefault + _ => "3", // importedKeyNoAction + } + } + async fn v39_get_foreign_keys( + db: &Arc, + child_filter: Option<&str>, + parent_filter: Option<&str>, + ) -> Result { + // 1) 需要扫描哪些子表 + let mut children: Vec = Vec::new(); + if let Some(c) = child_filter { + children.push(c.to_string()); + } else if let Ok(res) = db + .query( + "SELECT name FROM sqlite_master WHERE type = 'table' \ + AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 2) <> '__' \ + ORDER BY name", + ) + .await + { + for r in &res.rows { + let n = Self::v38_text(r, 0); + if !n.is_empty() { + children.push(n); + } + } + } + + // 2) 逐表读外键。元组含义: + // (parent, parent_col, child, child_col, key_seq, on_update, on_delete, fk_name) + let mut recs: Vec<(String, String, String, String, i64, String, String, String)> = + Vec::new(); + for child in &children { + let sql = format!( + "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete \ + FROM pragma_foreign_key_list('{}') ORDER BY id, seq", + Self::v38_sqlq(child) + ); + let res = match db.query(&sql).await { + Ok(v) => v, + Err(_) => continue, + }; + // 同一约束(同 id)的多列必须共用一个 FK_NAME,否则复合外键会被 + // 客户端当成多个独立约束。取 seq=0 那列作为命名基准。 + let mut first_col: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &res.rows { + if Self::v38_text(r, 1) == "0" { + first_col.insert(Self::v38_text(r, 0), Self::v38_text(r, 3)); + } + } + for r in &res.rows { + let id = Self::v38_text(r, 0); + let seq: i64 = Self::v38_text(r, 1).parse().unwrap_or(0); + let parent = Self::v38_text(r, 2); + let child_col = Self::v38_text(r, 3); + let mut parent_col = Self::v38_text(r, 4); + let upd = Self::v38_text(r, 5); + let del = Self::v38_text(r, 6); + if parent.is_empty() || child_col.is_empty() { + continue; + } + if let Some(p) = parent_filter { + if !p.eq_ignore_ascii_case(&parent) { + continue; + } + } + // `REFERENCES parent` 省略列名时 `to` 为 NULL,回退到父表主键第 seq 列 + if parent_col.is_empty() { + let pk_sql = format!( + "SELECT name FROM pragma_table_info('{}') WHERE pk > 0 ORDER BY pk", + Self::v38_sqlq(&parent) + ); + if let Ok(pk) = db.query(&pk_sql).await { + if let Some(row) = pk.rows.get(seq as usize) { + parent_col = Self::v38_text(row, 0); + } + } + if parent_col.is_empty() { + parent_col = "rowid".to_string(); + } + } + let base = first_col + .get(&id) + .cloned() + .unwrap_or_else(|| child_col.clone()); + let fk_name = format!("{child}_{base}_fkey"); + recs.push(( + parent, + parent_col, + child.clone(), + child_col, + seq, + upd, + del, + fk_name, + )); + } + } + + // 3) 排序对齐 PG 模板:imported 按 (pk 表, 约束名, 列序),exported 按 (fk 表, ...) + let exported = child_filter.is_none() && parent_filter.is_some(); + if exported { + recs.sort_by(|a, b| (&a.2, &a.7, a.4).cmp(&(&b.2, &b.7, b.4))); + } else { + recs.sort_by(|a, b| (&a.0, &a.7, a.4).cmp(&(&b.0, &b.7, b.4))); + } + + let mut rows: Vec>>> = Vec::new(); + for (parent, parent_col, child, child_col, seq, upd, del, fk_name) in recs { + let pk_name = format!("{parent}_pkey"); + rows.push(vec![ + None, // PKTABLE_CAT + Some(b"public".to_vec()), // PKTABLE_SCHEM + Some(parent.into_bytes()), // PKTABLE_NAME + Some(parent_col.into_bytes()), // PKCOLUMN_NAME + None, // FKTABLE_CAT + Some(b"public".to_vec()), // FKTABLE_SCHEM + Some(child.into_bytes()), // FKTABLE_NAME + Some(child_col.into_bytes()), // FKCOLUMN_NAME + Some((seq + 1).to_string().into_bytes()), // KEY_SEQ + Some(Self::v39_fk_rule(&upd).as_bytes().to_vec()), // UPDATE_RULE + Some(Self::v39_fk_rule(&del).as_bytes().to_vec()), // DELETE_RULE + Some(fk_name.into_bytes()), // FK_NAME + Some(pk_name.into_bytes()), // PK_NAME + Some(b"7".to_vec()), // importedKeyNotDeferrable + ]); + } + let n = rows.len(); + Ok(DbResponse { + columns: vec![ + "PKTABLE_CAT".to_string(), + "PKTABLE_SCHEM".to_string(), + "PKTABLE_NAME".to_string(), + "PKCOLUMN_NAME".to_string(), + "FKTABLE_CAT".to_string(), + "FKTABLE_SCHEM".to_string(), + "FKTABLE_NAME".to_string(), + "FKCOLUMN_NAME".to_string(), + "KEY_SEQ".to_string(), + "UPDATE_RULE".to_string(), + "DELETE_RULE".to_string(), + "FK_NAME".to_string(), + "PK_NAME".to_string(), + "DEFERRABILITY".to_string(), + ], + rows, + rows_affected: n, + }) + } + fn normalize_projection_qualifiers(query: &mut sqlparser::ast::Query) { + fn strip(expr: &mut Expr) { + match expr { + Expr::CompoundIdentifier(parts) => { + if let Some(last) = parts.last().cloned() { + *expr = Expr::Identifier(last); + } + } + Expr::Cast { expr: inner, .. } => strip(inner), + Expr::Nested(inner) => strip(inner), + _ => {} + } + } + fn walk(body: &mut SetExpr) { + match body { + SetExpr::Select(select) => { + for item in select.projection.iter_mut() { + match item { + SelectItem::UnnamedExpr(expr) => strip(expr), + SelectItem::ExprWithAlias { expr, .. } => strip(expr), + _ => {} + } + } + } + SetExpr::Query(q) => walk(&mut q.body), + SetExpr::SetOperation { left, right, .. } => { + walk(left); + walk(right); + } + _ => {} + } + } + walk(&mut query.body); + } + fn projection_output_name(item: &SelectItem) -> Option { + match item { + SelectItem::UnnamedExpr(expr) => Self::extract_projection_source_column(expr), + SelectItem::ExprWithAlias { alias, .. } => Some(Self::alias_output_name(alias)), + _ => None, + } + } + fn align_response_to_projection(projection: &[SelectItem], response: &mut DbResponse) { + if projection.is_empty() { + return; + } + // Wildcards: the client cannot know the arity in advance, leave it alone. + if projection.iter().any(|i| { + matches!(i, SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _)) + }) { + return; + } + + let mut expected: Vec = Vec::with_capacity(projection.len()); + for item in projection { + match Self::projection_output_name(item) { + Some(name) => expected.push(name), + // An expression we cannot name confidently — do not second-guess the handler. + None => return, + } + } + + let have = response.columns.len(); + if expected.len() <= have { + return; + } + // Rows must currently be well formed, otherwise bail out. + if response.rows.iter().any(|r| r.len() != have) { + return; + } + + // Order-preserving merge: walk the requested names, consuming handler columns in order. + let mut names: Vec = Vec::with_capacity(expected.len()); + let mut mapping: Vec> = Vec::with_capacity(expected.len()); + let mut cursor = 0usize; + for want in &expected { + if cursor < have && response.columns[cursor].eq_ignore_ascii_case(want) { + names.push(response.columns[cursor].clone()); + mapping.push(Some(cursor)); + cursor += 1; + } else { + names.push(want.clone()); + mapping.push(None); + } + } + // A handler column we could not place means the merge is unreliable. + if cursor != have { + return; + } + + let new_rows: Vec>>> = response + .rows + .iter() + .map(|row| { + mapping + .iter() + .map(|m| match m { + Some(i) => row[*i].clone(), + None => None, + }) + .collect() + }) + .collect(); + + info!( + "CATALOG ARITY: padded catalog response {} -> {} columns {:?}", + have, + names.len(), + names + ); + response.columns = names; + response.rows = new_rows; + } + fn handle_pg_namespace_query(select: &Select) -> DbResponse { + let all_columns = vec!["oid".to_string(), "nspname".to_string()]; + let (columns, column_indices) = Self::extract_selected_columns(select, &all_columns); + + let full_rows = vec![ + vec![ + Some("11".to_string().into_bytes()), + Some("pg_catalog".to_string().into_bytes()), + ], + vec![ + Some("2200".to_string().into_bytes()), + Some("public".to_string().into_bytes()), + ], + ]; + if columns.is_empty() && Self::is_count_star_projection(&select.projection) { + return Self::count_response(full_rows.len()); + } + + let rows: Vec>>> = full_rows + .into_iter() + .map(|full_row| { + column_indices + .iter() + .map(|&idx| full_row[idx].clone()) + .collect() + }) + .collect(); + + let rows_affected = rows.len(); + debug!("Returning {} rows for pg_namespace query with {} columns: {:?}", rows_affected, columns.len(), columns); + DbResponse { + columns, + rows, + rows_affected, + } + } + async fn v40_get_index_list( + db: &Arc, + table: Option<&str>, + ) -> Result { + let cols = vec![ + "index_name".to_string(), + "columns".to_string(), + "is_unique".to_string(), + "is_primary".to_string(), + "filter_expr".to_string(), + "index_type".to_string(), + "nkeyatts".to_string(), + "indkey".to_string(), + "index_comment".to_string(), + ]; + let mut rows: Vec>>> = Vec::new(); + if let Some(table) = table { + let list_sql = format!( + "SELECT name, \"unique\", origin FROM pragma_index_list('{}') ORDER BY name", + Self::v38_sqlq(table) + ); + if let Ok(list) = db.query(&list_sql).await { + for r in &list.rows { + let iname = Self::v38_text(r, 0); + if iname.is_empty() { + continue; + } + let uniq = Self::v38_text(r, 1) == "1"; + let is_primary = Self::v38_text(r, 2) == "pk"; + let info_sql = format!( + "SELECT name FROM pragma_index_info('{}') ORDER BY seqno", + Self::v38_sqlq(&iname) + ); + let mut col_names: Vec = Vec::new(); + let mut indkey_parts: Vec = Vec::new(); + if let Ok(info) = db.query(&info_sql).await { + for (i, ir) in info.rows.iter().enumerate() { + let cn = Self::v38_text(ir, 0); + if !cn.is_empty() { + col_names.push(cn); + indkey_parts.push((i + 1).to_string()); + } + } + } + let columns_str = col_names.join(","); + let nkeyatts = col_names.len().to_string(); + let indkey_str = indkey_parts.join(" "); + rows.push(vec![ + Some(iname.as_bytes().to_vec()), + Some(columns_str.into_bytes()), + Some(if uniq { b"t".to_vec() } else { b"f".to_vec() }), + Some(if is_primary { b"t".to_vec() } else { b"f".to_vec() }), + None, + Some(b"btree".to_vec()), + Some(nkeyatts.into_bytes()), + Some(indkey_str.into_bytes()), + None, + ]); + } + } + } + let n = rows.len(); + Ok(DbResponse { columns: cols, rows, rows_affected: n }) + } + + // === PATCH v42: DBX 列默认值 (03_attrdef) === + // 用 PRAGMA table_info 的 dflt_value 合成 (attname, pg_get_expr) 两列。 + // table=None 时返回 2 列 0 行 (供 extended Describe 宣告列数)。 + async fn v42_get_attrdef( + db: &Arc, + table: Option<&str>, + ) -> Result { + let cols = vec![ + "attname".to_string(), + "pg_get_expr".to_string(), + ]; + let mut rows: Vec>>> = Vec::new(); + if let Some(table) = table { + let sql = format!( + "PRAGMA table_info('{}')", + Self::v38_sqlq(table) + ); + if let Ok(res) = db.query(&sql).await { + for r in &res.rows { + let cname = Self::v38_text(r, 1); + let dflt = Self::v38_text(r, 4); + // 仅返回有默认值的列 (对齐 PG: 无默认值则无 pg_attrdef 行) + if !dflt.is_empty() { + rows.push(vec![ + Some(cname.into_bytes()), + Some(dflt.into_bytes()), + ]); + } + } + } + } + let n = rows.len(); + Ok(DbResponse { columns: cols, rows, rows_affected: n }) + } + + // === PATCH v43: DBX / DataGrip schema 对象列表 (展开 schema 树) 专属短路 === + // DataGrip 展开 schema 时发的对象列表查询: + // SELECT c.relname AS object_name, CASE c.relkind ... FROM pg_class c + // JOIN pg_namespace n ... LEFT JOIN LATERAL pg_stat_file(...) stat ON true ... + // UNION ALL SELECT p.proname ... FROM pg_proc p ... WHERE ... NOT p.proisagg AND NOT p.proiswindow ... + // pgsqlite 当前不支持 LATERAL pg_stat_file, 且 pg_proc 视图缺 proisagg/proiswindow 列, + // 整条报 "near "(": syntax error", 导致对象树(含表/视图)全部为空。 + // 改写为可执行的简化版: 去掉 LATERAL pg_stat_file 与 proisagg/proiswindow, + // created_at/updated_at 等填 NULL, 列名/顺序严格对齐原查询, 供 DBX 正常渲染。 + async fn v43_dbx_object_list( + db: &Arc, + query: &str, + ) -> Option> { + let lower = query.to_lowercase(); + // 指纹: 对象列表形态 + 触发不支持语法的标记 + let has_obj_shape = lower.contains("object_name") + && lower.contains("sort_order") + && lower.contains("union all") + && lower.contains("pg_proc"); + let has_unsupported = lower.contains("pg_stat_file") + || lower.contains("proisagg") + || lower.contains("proiswindow"); + if !has_obj_shape || !has_unsupported { + return None; + } + // 提取 schema 名 (第一个 nspname = 'X'); DBX 一般发 'public' + let schema = { + let re = regex::Regex::new(r"nspname\s*=\s*'([^']+)'").ok()?; + re.captures(query) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "public".to_string()) + }; + let sql = format!( + "SELECT c.relname AS object_name, \ + CASE c.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED_VIEW' WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END AS object_type, \ + obj_description(c.oid) AS object_comment, \ + CAST(NULL AS TEXT) AS created_at, CAST(NULL AS TEXT) AS updated_at, \ + CAST(NULL AS TEXT) AS parent_schema, CAST(NULL AS TEXT) AS parent_name, \ + CAST(NULL AS TEXT) AS signature, \ + CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 WHEN 'S' THEN 4 ELSE 0 END AS sort_order \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = '{schema}' AND c.relkind IN ('r','v','m','f','p','S') \ + UNION ALL \ + SELECT p.proname AS object_name, 'FUNCTION' AS object_type, obj_description(p.oid) AS object_comment, \ + CAST(NULL AS TEXT), CAST(NULL AS TEXT), CAST(NULL AS TEXT), CAST(NULL AS TEXT), \ + pg_get_function_arguments(p.oid) AS signature, 3 AS sort_order \ + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = '{schema}' \ + ORDER BY sort_order, object_name" + ); + match db.query(&sql).await { + Ok(res) => { + println!( + "INTERCEPT: dbx object-list handled (schema={}, {} rows)", + schema, + res.rows.len() + ); + Some(Ok(DbResponse { + columns: res.columns.clone(), + rows: res.rows.clone(), + rows_affected: res.rows.len(), + })) + } + Err(e) => { + eprintln!("INTERCEPT: dbx object-list rewrite failed: {:?}", e); + Some(Err(PgSqliteError::Sqlite(e))) + } + } + } + + // === PATCH v44: DBX / DataGrip 表属主 + 默认权限查询 (点开表属性时触发) 专属短路 === + // DataGrip 点开表属性时发: + // SELECT pg_get_userbyid(c.relowner)::text, + // ARRAY(SELECT default_acl.privilege_type::text + // FROM pg_catalog.aclexplode(pg_catalog.acldefault('r', c.relowner)) default_acl + // WHERE default_acl.grantee = c.relowner ORDER BY default_acl.privilege_type) + // FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + // WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') ORDER BY c.oid LIMIT 1 + // pgsqlite 不支持 ARRAY(SELECT ...) 标量子查询 -> "near "SELECT": syntax error"。 + // 改写为: 第1列用 pg_get_userbyid(c.relowner) (pgsqlite 已实现, 返回 'postgres'), + // 第2列(默认权限数组)用 NULL 表示无额外默认权限。列名/顺序对齐, 供 DBX 正常渲染。 + async fn v44_dbx_table_acl( + db: &Arc, + query: &str, + ) -> Option> { + let lower = query.to_lowercase(); + // 指纹: 表属主 + 默认权限数组 + acldefault 函数 (acldefault 极罕见, 足以唯一定位) + let is_match = lower.contains("pg_get_userbyid") + && lower.contains("acldefault") + && lower.contains("array("); + if !is_match { + return None; + } + // 提取 schema 与 table (参数在执行时已替换为字面量, 见 dune 日志报错行) + let schema = { + let re = regex::Regex::new(r"nspname\s*=\s*'([^']+)'").ok()?; + re.captures(query) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "public".to_string()) + }; + let table = { + let re = regex::Regex::new(r"relname\s*=\s*'([^']+)'").ok()?; + re.captures(query) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + }; + let sql = match table { + Some(ref t) => format!( + "SELECT pg_get_userbyid(c.relowner)::text AS owner, NULL::text AS defacl \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = '{schema}' AND c.relname = '{t}' AND c.relkind IN ('r','p') \ + ORDER BY c.oid LIMIT 1", + schema = Self::v38_sqlq(&schema), + t = Self::v38_sqlq(t), + ), + None => { + // 解析不到表名(极端: 参数未替换仍 $1/$2)时给一个干净的兜底行, 避免 DBX 整条报错 + "SELECT 'postgres'::text AS owner, NULL::text AS defacl".to_string() + } + }; + match db.query(&sql).await { + Ok(res) => { + println!( + "INTERCEPT: dbx table-acl handled (schema={}, table={:?}, {} rows)", + schema, table, res.rows.len() + ); + Some(Ok(DbResponse { + columns: res.columns.clone(), + rows: res.rows.clone(), + rows_affected: res.rows.len(), + })) + } + Err(e) => { + // 兜底: 即便查询失败也返回一行干净的属主信息, 不让 DBX 弹错中断 + eprintln!("INTERCEPT: dbx table-acl rewrite failed: {:?}, fallback", e); + let cols = vec!["owner".to_string(), "defacl".to_string()]; + let rows = vec![vec![Some(b"postgres".to_vec()), None]]; + Some(Ok(DbResponse { columns: cols, rows, rows_affected: 1 })) + } + } + } + + // === PATCH v45: DBX / DataGrip schema 列表 (展开树第一层) 专属短路 === + // 刷新数据源时 DBX 先枚举 schema: + // SELECT n.nspname AS schema_name, d.description AS schema_comment + // FROM pg_catalog.pg_namespace n + // LEFT JOIN pg_catalog.pg_description d ON d.objoid = n.oid AND d.objsubid = 0 AND d.classoid = 'pg_namespace' + // WHERE n.nspname NOT IN ('information_schema','pg_catalog','pg_toast') + // AND n.nspname NOT LIKE 'pg_toast_temp_%' AND n.nspname NOT LIKE 'pg_temp_%' + // ORDER BY n.nspname + // 原路径会掉进 PgDescriptionHandler (pg_description 单表 handler), 返回 0 列 -> + // Describe 发 NoData -> 整棵树空白 (连 schema 这一层都过不去)。 + // 直接返回 DBX 期望的 (schema_name, schema_comment) 列 + 实际 schema 列表。 + // pgsqlite 单库单 schema, 即 public。 + async fn v45_dbx_schema_list( + _db: &Arc, + query: &str, + ) -> Option> { + let lower = query.to_lowercase(); + // 指纹: pg_namespace + 投影 nspname AS schema_name (DBX schema 树专有形态) + if !(lower.contains("pg_namespace") && lower.contains("nspname as schema_name")) { + return None; + } + println!("INTERCEPT: dbx schema-list handled"); + let columns = vec!["schema_name".to_string(), "schema_comment".to_string()]; + let rows = vec![vec![ + Some("public".to_string().into_bytes()), + Some("".to_string().into_bytes()), + ]]; + let rows_affected = rows.len(); + Some(Ok(DbResponse { + columns, + rows, + rows_affected, + })) + } + + // === PATCH v46: DBX / DataGrip extension 列表 专属短路 === + // 刷新时 DBX 枚举 extension: + // SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname + // FROM pg_catalog.pg_extension e + // JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace + // LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension' + // ORDER BY n.nspname, e.extname + // SQLite 无 extension 概念, 原路径 (check_table_factor 无 pg_extension 分支) 落回裸 SQL -> 0 列 -> NoData。 + // 返回 DBX 期望的 4 列 + 0 行, 让 DBX 渲染空 extension 列表而不报错中断树加载。 + async fn v46_dbx_extension_list( + _db: &Arc, + query: &str, + ) -> Option> { + let lower = query.to_lowercase(); + // 指纹: pg_extension + 投影 extname + if !(lower.contains("pg_extension") && lower.contains("extname")) { + return None; + } + println!("INTERCEPT: dbx extension-list handled"); + let columns = vec![ + "extname".to_string(), + "extversion".to_string(), + "description".to_string(), + "nspname".to_string(), + ]; + // SQLite 无 extension: 返回正确列名 + 0 行 + let rows: Vec>>> = Vec::new(); + Some(Ok(DbResponse { + columns, + rows, + rows_affected: 0, + })) + } + + // === PATCH v47: DBX / DataGrip "Tables" 节点列表 专属短路 === + // DBX 点开 schema 下的 Tables 节点时发: + // SELECT c.relname AS table_name, + // CASE c.relkind WHEN 'r' THEN 'BASE TABLE' ... END AS table_type, + // obj_description(c.oid) AS table_comment, + // CASE WHEN pc.relkind='p' THEN pn.nspname ELSE NULL END AS parent_schema, + // CASE WHEN pc.relkind='p' THEN pc.relname ELSE NULL END AS parent_name + // FROM pg_class c JOIN pg_namespace n ... + // LEFT JOIN pg_inherits i ... LEFT JOIN pg_class pc ... LEFT JOIN pg_namespace pn ... + // WHERE n.nspname='public' AND c.relkind IN ('r','v','m','f','p') + // AND (... c.relname LIKE '%%' ...) -- 搜索过滤, 空时恒真 + // ORDER BY ... c.relname + // LIMIT -1 OFFSET CAST(0 AS INTEGER) + // SQLite 的 pg_inherits 视图无 ihparent 列 -> "no such column: i.ihparent" -> 整条报错 -> 表列表空白。 + // SQLite 不支持表继承, parent_schema/parent_name 恒为 NULL。改写为不含 pg_inherits 的等价查询, + // 列名/顺序严格对齐 (table_name, table_type, table_comment, parent_schema, parent_name)。 + // 注意: 搜索过滤 (c.relname LIKE 'x%') 在改写中被丢弃, 返回该 schema 下全部关系 (v48 可补)。 + async fn v47_dbx_table_list( + db: &Arc, + query: &str, + ) -> Option> { + let lower = query.to_lowercase(); + // 指纹: 投影 parent_schema + parent_name + 引入 pg_inherits (Tables 节点专有形态) + if !(lower.contains("as parent_schema") && lower.contains("as parent_name") && lower.contains("pg_inherits")) { + return None; + } + // 提取 schema (nspname = 'X') + let schema = { + let re = regex::Regex::new(r"nspname\s*=\s*'([^']+)'").ok()?; + re.captures(query) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "public".to_string()) + }; + // 提取 relkind IN (...) 列表 (不同 DBX 版本可能不同) + let relkinds = { + let re = regex::Regex::new(r"relkind\s+in\s*\(([^)]*)\)").ok(); + match re.and_then(|re| re.captures(query)) { + Some(caps) => caps + .get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "'r','v','m','f','p'".to_string()), + None => "'r','v','m','f','p'".to_string(), + } + }; + let sql = format!( + "SELECT c.relname AS table_name, \ + CASE c.relkind WHEN 'r' THEN 'BASE TABLE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED_VIEW' WHEN 'f' THEN 'FOREIGN TABLE' WHEN 'p' THEN 'BASE TABLE' END AS table_type, \ + obj_description(c.oid) AS table_comment, \ + CAST(NULL AS TEXT) AS parent_schema, \ + CAST(NULL AS TEXT) AS parent_name \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = '{schema}' AND c.relkind IN ({relkinds}) \ + ORDER BY c.relname" + ); + match db.query(&sql).await { + Ok(res) => { + println!( + "INTERCEPT: dbx table-list handled (schema={}, {} rows)", + schema, + res.rows.len() + ); + let columns = vec![ + "table_name".to_string(), + "table_type".to_string(), + "table_comment".to_string(), + "parent_schema".to_string(), + "parent_name".to_string(), + ]; + Some(Ok(DbResponse { + columns, + rows: res.rows.clone(), + rows_affected: res.rows.len(), + })) + } + Err(e) => { + eprintln!("INTERCEPT: dbx table-list rewrite failed: {:?}", e); + Some(Err(PgSqliteError::Sqlite(e))) + } + } + } + + // 从 dbx 列默认值查询提取表名: WHERE ... c.relname = 。 + // 参数化版本被 v29i 探测替换成 c.relname = NULL, 提取不到则返回 None。 + fn v42_extract_attrdef_table(query: &str) -> Option { + let marker = "relname = '"; + if let Some(pos) = query.to_lowercase().find(marker) { + let rest = &query[pos + marker.len()..]; + let q = 39u8 as char; + if let Some(end) = rest.find(q) { + let raw = &rest[..end]; + if !raw.is_empty() { + return Some(raw.to_string()); + } + } + } + None + } + fn v40_extract_index_table(query: &str) -> Option { + let marker = "relname = '"; + if let Some(pos) = query.to_lowercase().find(marker) { + let rest = &query[pos + marker.len()..]; + let q = 39u8 as char; + if let Some(end) = rest.find(q) { + let raw = &rest[..end]; + if !raw.is_empty() { + return Some(raw.to_string()); + } + } + } + None + } + async fn handle_dbeaver_columns_query_if_match( + query: &str, + db: &DbHandler, + session: Option<&Arc>, + ) -> Option> { + let lower = query.to_lowercase(); + // 指纹: from pg_attribute + 投影含 column_name / is_pk (DBeaver 特征) + if !lower.contains("pg_attribute") + || !lower.contains("as column_name") + || !lower.contains("as is_pk") + { + return None; + } + + // 抽表名: quote_ident('schema') || '.' || quote_ident('table') + let table: String = { + let re_concat = regex::Regex::new( + r"quote_ident\('([^']+)'\)\s*\|\|\s*'\.'\s*\|\|\s*quote_ident\('([^']+)'\)", + ) + .ok()?; + if let Some(c) = re_concat.captures(query) { + c.get(2).map(|m| m.as_str().to_string()) + } else { + // 退化: attrelid = 'schema.table' + let re_reg = + regex::Regex::new(r"attrelid\s*=\s*'((?:[^'.]+\.)?[^']+)'").ok()?; + re_reg.captures(query).and_then(|c| { + c.get(1).map(|m| { + let s = m.as_str(); + s.rsplit('.').next().unwrap_or(s).to_string() + }) + }) + } + }?; + + let session_id = session?.id; + + let rows_data = match db.connection_manager().execute_with_session(&session_id, |conn| { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?; + let mut out: Vec<(i32, String, String, i32, Option, i32)> = Vec::new(); + let mut q = stmt.query([])?; + while let Some(r) = q.next()? { + let cid: i32 = r.get(0)?; + let name: String = r.get(1)?; + let type_name: String = r.get(2)?; + let notnull: i32 = r.get(3)?; + let dflt: Option = r.get(4)?; + let pk: i32 = r.get(5)?; + out.push((cid, name, type_name, notnull, dflt, pk)); + } + Ok(out) + }) { + Ok(d) => d, + Err(_) => { + return Some(Ok(DbResponse { + columns: vec!["column_name".to_string()], + rows: vec![], + rows_affected: 0, + })); + } + }; + + let columns = vec![ + "column_name".to_string(), + "full_type".to_string(), + "is_nullable".to_string(), + "column_default".to_string(), + "is_pk".to_string(), + "column_comment".to_string(), + "column_extra".to_string(), + "numeric_precision".to_string(), + "numeric_scale".to_string(), + "character_maximum_length".to_string(), + "enum_values".to_string(), + ]; + + let mut rows: Vec>>> = Vec::new(); + for (_, name, type_name, notnull, dflt, pk) in rows_data { + let full_type = type_name.clone(); + let is_nullable = if notnull != 0 { "NO" } else { "YES" }; + let column_default = dflt.unwrap_or_default(); + let is_pk = if pk != 0 { "t" } else { "f" }; + let (num_prec, num_scale, char_max) = Self::parse_pg_type_modifiers(&type_name); + rows.push(vec![ + Some(name.into_bytes()), + Some(full_type.into_bytes()), + Some(is_nullable.to_string().into_bytes()), + if column_default.is_empty() { + None + } else { + Some(column_default.into_bytes()) + }, + Some(is_pk.to_string().into_bytes()), + None, // column_comment + None, // column_extra + num_prec, + num_scale, + char_max, + None, // enum_values + ]); + } + + Some(Ok(DbResponse { + columns, + rows, + rows_affected: 0, + })) + } + pub async fn handle_information_schema_columns_query_with_session(select: &Select, db: &DbHandler, session_id: &Uuid) -> Result { + debug!("Handling information_schema.columns query"); + + // Define information_schema.columns columns (PostgreSQL standard) + let all_columns = vec![ + "table_catalog".to_string(), + "table_schema".to_string(), + "table_name".to_string(), + "column_name".to_string(), + "ordinal_position".to_string(), + "column_default".to_string(), + "is_nullable".to_string(), + "data_type".to_string(), + "character_maximum_length".to_string(), + "character_octet_length".to_string(), + "numeric_precision".to_string(), + "numeric_precision_radix".to_string(), + "numeric_scale".to_string(), + "datetime_precision".to_string(), + "interval_type".to_string(), + "interval_precision".to_string(), + "character_set_catalog".to_string(), + "character_set_schema".to_string(), + "character_set_name".to_string(), + "collation_catalog".to_string(), + "collation_schema".to_string(), + "collation_name".to_string(), + "domain_catalog".to_string(), + "domain_schema".to_string(), + "domain_name".to_string(), + "udt_catalog".to_string(), + "udt_schema".to_string(), + "udt_name".to_string(), + "scope_catalog".to_string(), + "scope_schema".to_string(), + "scope_name".to_string(), + "maximum_cardinality".to_string(), + "dtd_identifier".to_string(), + "is_self_referencing".to_string(), + "is_identity".to_string(), + "identity_generation".to_string(), + "identity_start".to_string(), + "identity_increment".to_string(), + "identity_maximum".to_string(), + "identity_minimum".to_string(), + "identity_cycle".to_string(), + "is_generated".to_string(), + "generation_expression".to_string(), + "is_updatable".to_string(), + ]; + + // Extract selected columns + let (selected_columns, column_indices) = Self::extract_selected_columns(select, &all_columns); + + // Check for WHERE clause filtering + let table_filter = if let Some(ref where_clause) = select.selection { + Self::extract_table_name_filter(where_clause) + } else { + None + }; + + // Get list of tables from SQLite + let tables_query = if let Some(table_name) = &table_filter { + format!("SELECT name FROM sqlite_master WHERE type='table' AND name = '{}' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__pgsqlite_%'", table_name) + } else { + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__pgsqlite_%'".to_string() + }; + + let tables_response = match db.connection_manager().execute_with_session(session_id, |conn| { + let mut stmt = conn.prepare(&tables_query)?; + let mut rows = Vec::new(); + let mut query_rows = stmt.query([])?; + while let Some(row) = query_rows.next()? { + let name: String = row.get(0)?; + rows.push(vec![Some(name.into_bytes())]); + } + Ok(DbResponse { + columns: vec!["name".to_string()], + rows, + rows_affected: 0, + }) + }) { + Ok(response) => response, + Err(_) => return Ok(DbResponse { + columns: selected_columns, + rows: vec![], + rows_affected: 0, + }), + }; + + let mut rows = Vec::new(); + + // Process each table + for table_row in &tables_response.rows { + if let Some(Some(table_name_bytes)) = table_row.first() { + let table_name = String::from_utf8_lossy(table_name_bytes).to_string(); + + // Get column information using PRAGMA table_info + let pragma_query = format!("PRAGMA table_info({})", table_name); + let table_info_response = match db.connection_manager().execute_with_session(session_id, |conn| { + let mut stmt = conn.prepare(&pragma_query)?; + let mut rows = Vec::new(); + let mut query_rows = stmt.query([])?; + while let Some(row) = query_rows.next()? { + let cid: i32 = row.get(0)?; + let name: String = row.get(1)?; + let type_name: String = row.get(2)?; + let not_null: i32 = row.get(3)?; + let default_value: Option = row.get(4)?; + let pk: i32 = row.get(5)?; + + let row_data = vec![ + Some(cid.to_string().into_bytes()), + Some(name.into_bytes()), + Some(type_name.into_bytes()), + Some(not_null.to_string().into_bytes()), + default_value.map(|v| v.into_bytes()), + Some(pk.to_string().into_bytes()), + ]; + rows.push(row_data); + } + Ok(DbResponse { + columns: vec!["cid".to_string(), "name".to_string(), "type".to_string(), "notnull".to_string(), "dflt_value".to_string(), "pk".to_string()], + rows, + rows_affected: 0, + }) + }) { + Ok(response) => response, + Err(_) => continue, + }; + + // Process each column + for (ordinal, column_row) in table_info_response.rows.iter().enumerate() { + if column_row.len() >= 6 + && let (Some(Some(name_bytes)), Some(Some(type_bytes)), Some(Some(notnull_bytes)), default_opt, Some(Some(pk_bytes))) = + (column_row.get(1), column_row.get(2), column_row.get(3), column_row.get(4), column_row.get(5)) { + + let column_name = String::from_utf8_lossy(name_bytes).to_string(); + let sqlite_type = String::from_utf8_lossy(type_bytes).to_string(); + let not_null = String::from_utf8_lossy(notnull_bytes) == "1"; + let default_value = match default_opt { + Some(Some(default_bytes)) => String::from_utf8_lossy(default_bytes), + _ => "".into(), + }; + let is_primary_key = String::from_utf8_lossy(pk_bytes) == "1"; + + // First try to get type from __pgsqlite_schema, then fall back to SQLite type + let pg_type = match db.connection_manager().execute_with_session(session_id, |conn| { + // Check if __pgsqlite_schema exists first + let table_exists: i32 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__pgsqlite_schema'", + [], + |row| row.get(0) + ).unwrap_or(0); + + if table_exists > 0 { + let query = "SELECT pg_type FROM __pgsqlite_schema WHERE table_name = ? AND column_name = ?"; + let mut stmt = conn.prepare(query)?; + let result = stmt.query_row([&table_name, &column_name], |row| { + row.get::<_, String>(0) + }); + Ok(result) + } else { + Err(rusqlite::Error::InvalidPath("__pgsqlite_schema not found".into())) + } + }) { + Ok(Ok(stored_type)) => stored_type, + _ => sqlite_type.clone(), + }; + + // Map type to PostgreSQL type + let (pg_data_type, char_max_length, numeric_precision, numeric_scale) = + Self::map_sqlite_type_to_pg_column_info(&pg_type); + + // Determine nullability + let is_nullable = if not_null || is_primary_key { "NO" } else { "YES" }; + + // Handle default value + let column_default = if default_value.is_empty() || default_value == "NULL" { + None + } else { + Some(default_value.to_string().into_bytes()) + }; + + let full_row: Vec>> = vec![ + Some("main".to_string().into_bytes()), // table_catalog + Some("public".to_string().into_bytes()), // table_schema + Some(table_name.clone().into_bytes()), // table_name + Some(column_name.clone().into_bytes()), // column_name + Some((ordinal + 1).to_string().into_bytes()), // ordinal_position (1-based) + column_default, // column_default + Some(is_nullable.to_string().into_bytes()), // is_nullable + Some(pg_data_type.clone().into_bytes()), // data_type + char_max_length.map(|v| v.to_string().into_bytes()), // character_maximum_length + char_max_length.map(|v| v.to_string().into_bytes()), // character_octet_length + numeric_precision.map(|v| v.to_string().into_bytes()), // numeric_precision + numeric_precision.map(|_| "10".to_string().into_bytes()), // numeric_precision_radix + numeric_scale.map(|v| v.to_string().into_bytes()), // numeric_scale + None, // datetime_precision + None, // interval_type + None, // interval_precision + None, // character_set_catalog + None, // character_set_schema + None, // character_set_name + None, // collation_catalog + None, // collation_schema + None, // collation_name + None, // domain_catalog + None, // domain_schema + None, // domain_name + Some("main".to_string().into_bytes()), // udt_catalog + Some("pg_catalog".to_string().into_bytes()), // udt_schema + Some(pg_data_type.clone().into_bytes()), // udt_name + None, // scope_catalog + None, // scope_schema + None, // scope_name + None, // maximum_cardinality + Some((ordinal + 1).to_string().into_bytes()), // dtd_identifier + Some("NO".to_string().into_bytes()), // is_self_referencing + Some("NO".to_string().into_bytes()), // is_identity + None, // identity_generation + None, // identity_start + None, // identity_increment + None, // identity_maximum + None, // identity_minimum + Some("NO".to_string().into_bytes()), // identity_cycle + Some("NEVER".to_string().into_bytes()), // is_generated + None, // generation_expression + Some("YES".to_string().into_bytes()), // is_updatable + ]; + + // Project only the requested columns + let projected_row: Vec>> = column_indices.iter() + .map(|&idx| full_row[idx].clone()) + .collect(); + + rows.push(projected_row); + } + } + } + } + + let rows_affected = rows.len(); + Ok(DbResponse { + columns: selected_columns, + rows, + rows_affected, + }) + } + async fn handle_information_schema_tables_query(select: &Select, db: &DbHandler) -> DbResponse { + debug!("Handling information_schema.tables query"); + + // Get list of tables and views from SQLite + let tables_response = match db.query("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__pgsqlite_%'").await { + Ok(response) => response, + Err(_) => return DbResponse { + columns: vec!["table_name".to_string()], + rows: vec![], + rows_affected: 0, + }, + }; + + // Define information_schema.tables columns (enhanced with all PostgreSQL standard columns) + let all_columns = vec![ + "table_catalog".to_string(), + "table_schema".to_string(), + "table_name".to_string(), + "table_type".to_string(), + "self_referencing_column_name".to_string(), + "reference_generation".to_string(), + "user_defined_type_catalog".to_string(), + "user_defined_type_schema".to_string(), + "user_defined_type_name".to_string(), + "is_insertable_into".to_string(), + "is_typed".to_string(), + "commit_action".to_string(), + ]; + + // Extract selected columns + let (selected_columns, column_indices) = Self::extract_selected_columns(select, &all_columns); + + // Check for WHERE clause filtering + let table_filters = if let Some(ref where_clause) = select.selection { + Self::extract_table_name_filters(where_clause) + } else { + Vec::new() + }; + + // Build rows + let mut rows = Vec::new(); + for table_row in &tables_response.rows { + if table_row.len() >= 2 + && let (Some(Some(table_name_bytes)), Some(Some(table_type_bytes))) = + (table_row.first(), table_row.get(1)) { + let table_name = String::from_utf8_lossy(table_name_bytes).to_string(); + let sqlite_type = String::from_utf8_lossy(table_type_bytes).to_string(); + + // Apply WHERE clause filtering if present + if !table_filters.is_empty() && !table_filters.contains(&table_name) { + continue; + } + + // Map SQLite type to PostgreSQL table_type + let table_type = match sqlite_type.as_str() { + "table" => "BASE TABLE", + "view" => "VIEW", + _ => "BASE TABLE", // Default fallback + }; + + // Determine if table is insertable (views are not) + let is_insertable = if table_type == "VIEW" { "NO" } else { "YES" }; + + // Create full row with all columns + let full_row: Vec>> = vec![ + Some("main".to_string().into_bytes()), // table_catalog + Some("public".to_string().into_bytes()), // table_schema + Some(table_name.into_bytes()), // table_name + Some(table_type.to_string().into_bytes()), // table_type + None, // self_referencing_column_name + None, // reference_generation + None, // user_defined_type_catalog + None, // user_defined_type_schema + None, // user_defined_type_name + Some(is_insertable.to_string().into_bytes()), // is_insertable_into + Some("NO".to_string().into_bytes()), // is_typed + None, // commit_action + ]; + + // Project only the requested columns + let projected_row: Vec>> = column_indices.iter() + .map(|&idx| full_row[idx].clone()) + .collect(); + + rows.push(projected_row); + } + } + + let rows_count = rows.len(); + DbResponse { + columns: selected_columns, + rows, + rows_affected: rows_count, + } + } + fn map_sqlite_type_to_pg_column_info(sqlite_type: &str) -> (String, Option, Option, Option) { + let sqlite_type_upper = sqlite_type.to_uppercase(); + + // Handle parametric types like VARCHAR(255), DECIMAL(10,2) + if let Some(paren_pos) = sqlite_type_upper.find('(') { + let base_type = &sqlite_type_upper[..paren_pos]; + let params_str = &sqlite_type_upper[paren_pos+1..]; + if let Some(close_paren) = params_str.find(')') { + let params_str = ¶ms_str[..close_paren]; + let params: Vec<&str> = params_str.split(',').map(|s| s.trim()).collect(); + + match base_type { + "VARCHAR" | "CHAR" | "CHARACTER VARYING" => { + let length = params.first().and_then(|p| p.parse().ok()).unwrap_or(255); + return ("character varying".to_string(), Some(length), None, None); + }, + "DECIMAL" | "NUMERIC" => { + let precision = params.first().and_then(|p| p.parse().ok()).unwrap_or(10); + let scale = params.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + return ("numeric".to_string(), None, Some(precision), Some(scale)); + }, + _ => {} + } + } + } + + // Handle base types + match sqlite_type_upper.as_str() { + "INTEGER" | "INT" => ("integer".to_string(), None, Some(32), Some(0)), + "BIGINT" => ("bigint".to_string(), None, Some(64), Some(0)), + "SMALLINT" => ("smallint".to_string(), None, Some(16), Some(0)), + "REAL" | "FLOAT" => ("real".to_string(), None, Some(24), None), + "DOUBLE" | "DOUBLE PRECISION" => ("double precision".to_string(), None, Some(53), None), + "TEXT" => ("text".to_string(), None, None, None), + "BLOB" => ("bytea".to_string(), None, None, None), + "BOOLEAN" | "BOOL" => ("boolean".to_string(), None, None, None), + "DATE" => ("date".to_string(), None, None, None), + "TIME" => ("time without time zone".to_string(), None, None, None), + "TIMESTAMP" | "DATETIME" => ("timestamp without time zone".to_string(), None, None, None), + "UUID" => ("uuid".to_string(), None, None, None), + "JSON" => ("json".to_string(), None, None, None), + "JSONB" => ("jsonb".to_string(), None, None, None), + "VARCHAR" | "CHARACTER VARYING" => ("character varying".to_string(), None, None, None), + "CHAR" | "CHARACTER" => ("character".to_string(), None, None, None), + _ => { + // Default fallback for unknown types + if sqlite_type_upper.contains("CHAR") || sqlite_type_upper.contains("TEXT") { + ("text".to_string(), None, None, None) + } else if sqlite_type_upper.contains("INT") { + ("integer".to_string(), None, Some(32), Some(0)) + } else if sqlite_type_upper.contains("REAL") || sqlite_type_upper.contains("FLOAT") { + ("real".to_string(), None, Some(24), None) + } else { + ("text".to_string(), None, None, None) + } + } + } + } + fn from_is_sqlite_resolvable(select: &sqlparser::ast::Select) -> bool { + use sqlparser::ast::{ObjectNamePart, TableFactor}; + + // === PATCH v23: information_schema objects backed by real SQLite views === + // The six v14 objects (tables/columns/schemata/key_column_usage/ + // table_constraints/referential_constraints) plus the seventeen v33 + // objects all have CREATE VIEW statements in the migration registry, + // and SchemaPrefixTranslator rewrites information_schema. to + // information_schema_. Keep in sync with V33_ISCHEMA_VIEWS in + // schema_prefix_translator.rs. Objects NOT listed here (routines, + // views, check_constraints, triggers, ...) exist only as Rust handlers + // and must stay on the handler path — delegating them would raise + // `no such table` and abort the whole GUI metadata transaction. + const ISCHEMA_SQLITE_RESOLVABLE: &[&str] = &[ + // v14 + "tables", + "columns", + "schemata", + "key_column_usage", + "table_constraints", + "referential_constraints", + // v33 + "applicable_roles", + "character_sets", + "collations", + "column_privileges", + "column_udt_usage", + "constraint_column_usage", + "domain_constraints", + "domains", + "element_types", + "enabled_roles", + "information_schema_catalog_name", + "parameters", + "role_table_grants", + "sequences", + "table_privileges", + "view_column_usage", + "view_table_usage", + ]; + + fn factor_ok(factor: &TableFactor) -> bool { + match factor { + TableFactor::Table { name, .. } => { + let parts = &name.0; + if parts.len() <= 1 { + return true; + } + match &parts[parts.len() - 2] { + ObjectNamePart::Identifier(ident) => { + if ident.value.eq_ignore_ascii_case("pg_catalog") { + true + } else if ident.value.eq_ignore_ascii_case("information_schema") { + // === PATCH v23: only objects backed by a real + // SQLite view are safe to delegate. The v14 six and + // the v33 seventeen are rewritten by + // SchemaPrefixTranslator to information_schema_. + if let Some(ObjectNamePart::Identifier(last)) = parts.last() { + ISCHEMA_SQLITE_RESOLVABLE + .iter() + .any(|o| last.value.eq_ignore_ascii_case(o)) + } else { + false + } + } else { + false + } + } + _ => false, + } + } + // Sub-queries / table functions are executed by the SQL engine + // anyway; nothing for the handler to hijack. + _ => true, + } + } + + for twj in &select.from { + if !factor_ok(&twj.relation) { + return false; + } + for join in &twj.joins { + if !factor_ok(&join.relation) { + return false; + } + } + } + true + } + fn query_needs_sql_engine( + query: &sqlparser::ast::Query, + select: &sqlparser::ast::Select, + ) -> bool { + use sqlparser::ast::GroupByExpr; + + // === PATCH v18: schema-qualified names SQLite cannot resolve === + // SQLite has no schema namespace. `information_schema.tables` simply does + // not exist there (the real view is `information_schema_tables`), so + // delegating such a query would raise `no such table`, abort the + // transaction and take the whole GUI metadata tree down with it — strictly + // worse than the handler's (wrong but harmless) result set. Only pg_catalog + // qualifiers are safe because SchemaPrefixTranslator strips them. + if !Self::from_is_sqlite_resolvable(select) { + return false; + } + + // GROUP BY / HAVING —— handler 完全没有分组概念 + let has_group_by = match &select.group_by { + GroupByExpr::Expressions(exprs, modifiers) => { + !exprs.is_empty() || !modifiers.is_empty() + } + GroupByExpr::All(_) => true, + }; + if has_group_by || select.having.is_some() { + return true; + } + + // ORDER BY —— handler 原样吐出内部顺序,排序被静默丢弃 + if query.order_by.is_some() { + return true; + } + + // 投影里的聚合函数 —— handler 找不到同名列,一律填 NULL + const AGGREGATES: [&str; 14] = [ + "count(", + "sum(", + "avg(", + "min(", + "max(", + "array_agg(", + "string_agg(", + "json_agg(", + "jsonb_agg(", + "bool_and(", + "bool_or(", + "every(", + "bit_and(", + "bit_or(", + ]; + let projection = select + .projection + .iter() + .map(|item| item.to_string().to_lowercase()) + .collect::>() + .join(" , "); + AGGREGATES.iter().any(|f| projection.contains(f)) + } + fn select_uses_udf_backed_system_function(select: &sqlparser::ast::Select) -> bool { + const UDF_BACKED: [&str; 8] = [ + "format_type", + "pg_get_expr", + "pg_get_indexdef", + "pg_get_constraintdef", + "to_regtype", + "pg_get_userbyid", + "pg_table_is_visible", + "pg_size_pretty", + ]; + let rendered = select.to_string().to_lowercase(); + UDF_BACKED.iter().any(|f| rendered.contains(f)) + } + fn is_count_star_projection(projection: &[SelectItem]) -> bool { + if projection.len() != 1 { + return false; + } + + let expr = match &projection[0] { + SelectItem::UnnamedExpr(expr) => expr, + SelectItem::ExprWithAlias { expr, .. } => expr, + _ => return false, + }; + + let Expr::Function(function) = expr else { + return false; + }; + + if Self::function_name(function).as_deref() != Some("count") { + return false; + } + + matches!( + &function.args, + sqlparser::ast::FunctionArguments::List(arg_list) + if arg_list.args.len() == 1 + && matches!( + &arg_list.args[0], + FunctionArg::Unnamed(FunctionArgExpr::Wildcard) + ) + ) + } + fn count_response(count: usize) -> DbResponse { + DbResponse { + columns: vec!["count".to_string()], + rows: vec![vec![Some(count.to_string().into_bytes())]], + rows_affected: 1, + } + } + fn extract_table_name_filters(where_clause: &Expr) -> Vec { + match where_clause { + Expr::BinaryOp { left, op, right } => { + // Handle "table_name = 'value'" + if let (Expr::Identifier(ident), sqlparser::ast::BinaryOperator::Eq, Expr::Value(value_with_span)) = + (left.as_ref(), op, right.as_ref()) + && ident.value.to_lowercase() == "table_name" + && let sqlparser::ast::Value::SingleQuotedString(value) = &value_with_span.value { + return vec![value.clone()]; + } + // Handle "'value' = table_name" (reversed) + if let (Expr::Value(value_with_span), sqlparser::ast::BinaryOperator::Eq, Expr::Identifier(ident)) = + (left.as_ref(), op, right.as_ref()) + && ident.value.to_lowercase() == "table_name" + && let sqlparser::ast::Value::SingleQuotedString(value) = &value_with_span.value { + return vec![value.clone()]; + } + // Handle compound identifiers like "information_schema.tables.table_name = 'value'" + if let (Expr::CompoundIdentifier(parts), sqlparser::ast::BinaryOperator::Eq, Expr::Value(value_with_span)) = + (left.as_ref(), op, right.as_ref()) + && let Some(last_part) = parts.last() + && last_part.value.to_lowercase() == "table_name" + && let sqlparser::ast::Value::SingleQuotedString(value) = &value_with_span.value { + return vec![value.clone()]; + } + } + Expr::InList { expr, list, negated } => { + // Handle "table_name IN ('value1', 'value2')" + if !negated { + if let Expr::Identifier(ident) = expr.as_ref() + && ident.value.to_lowercase() == "table_name" { + let mut values = Vec::new(); + for item in list { + if let Expr::Value(value_with_span) = item + && let sqlparser::ast::Value::SingleQuotedString(value) = &value_with_span.value { + values.push(value.clone()); + } + } + return values; + } + // Handle compound identifiers in IN clause + if let Expr::CompoundIdentifier(parts) = expr.as_ref() + && let Some(last_part) = parts.last() + && last_part.value.to_lowercase() == "table_name" { + let mut values = Vec::new(); + for item in list { + if let Expr::Value(value_with_span) = item + && let sqlparser::ast::Value::SingleQuotedString(value) = &value_with_span.value { + values.push(value.clone()); + } + } + return values; + } + } + } + Expr::Nested(inner) => { + return Self::extract_table_name_filters(inner); + } + _ => {} + } + Vec::new() + } + fn parse_select(sql: &str) -> sqlparser::ast::Select { + use sqlparser::ast::Statement; + let mut ast = Parser::parse_sql(&PostgreSqlDialect {}, sql).expect("parse sql"); + match ast.remove(0) { + Statement::Query(q) => match *q.body { + SetExpr::Select(select) => *select, + _ => panic!("not a SELECT"), + }, + _ => panic!("not a query"), + } + } + // 从 SQLite 声明类型里解析 numeric_precision / numeric_scale / character_maximum_length + fn parse_pg_type_modifiers(type_name: &str) -> (Option>, Option>, Option>) { + let t = type_name.to_uppercase(); + if let Some(cap) = regex::Regex::new(r"(?:VARCHAR|CHAR|CHARACTER)\s*\(\s*(\d+)\s*\)") + .ok() + .and_then(|re| re.captures(&t)) + { + let n = cap.get(1).unwrap().as_str(); + return (None, None, Some(n.to_string().into_bytes())); + } + if let Some(cap) = regex::Regex::new(r"(?:NUMERIC|DECIMAL)\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)") + .ok() + .and_then(|re| re.captures(&t)) + { + let p = cap.get(1).unwrap().as_str(); + let s = cap.get(2).unwrap().as_str(); + return ( + Some(p.to_string().into_bytes()), + Some(s.to_string().into_bytes()), + None, + ); + } + (None, None, None) + } + fn v23_tables_count_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT count(*) FROM information_schema.tables" + ))); + } + fn v23_columns_count_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT count(*) FROM information_schema.columns" + ))); + } + fn v23_schemata_group_by_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT schema_name, count(*) FROM information_schema.schemata GROUP BY schema_name" + ))); + } + fn v23_key_column_usage_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT count(*) FROM information_schema.key_column_usage" + ))); + } + fn v23_handler_only_routines_stays_on_handler() { + assert!(!CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT count(*) FROM information_schema.routines" + ))); + } + fn v23_pg_catalog_still_delegates() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT count(*) FROM pg_catalog.pg_class" + ))); + } + fn v23_bare_table_name_still_delegates() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( + "SELECT count(*) FROM pg_class" + ))); + } + +pub async fn intercept_query(query: &str, db: Arc, session: Option>) -> Option> { debug!("INTERCEPT_QUERY: {}", query); // Quick check to avoid parsing if not a catalog query let lower_query = query.to_lowercase(); @@ -46,7 +1913,60 @@ impl CatalogInterceptor { lower_query.trim() == "select version()" { return None; } - + // === v38: pgjdbc DatabaseMetaData 固定模板拦截 === + if let Some(r) = Self::v38_jdbc_metadata(query, &db).await { + println!("INTERCEPT: v38 jdbc-metadata handled"); + return Some(r); + } + + // === PATCH v40: DBeaver/DBX 字段列表查询 (01_columns) 专属短路 === + if let Some(r) = Self::handle_dbeaver_columns_query_if_match(query, &db, session.as_ref()).await { + println!("INTERCEPT: DBeaver columns (01_columns) handled"); + return Some(r); + } + + // === PATCH v40: dbx / DBeaver 取索引列表 (02_indexes) 专属短路 === + if lower_query.contains("pg_index") && lower_query.contains("array_agg") && !lower_query.contains("_pg_expandarray") { + let tbl = Self::v40_extract_index_table(query); + println!("INTERCEPT: dbx index list for table={:?}", tbl); + return Some(Self::v40_get_index_list(&db, tbl.as_deref()).await); + } + + // === PATCH v42: DBX 列默认值查询 (03_attrdef) 专属短路 === + if lower_query.contains("pg_attrdef") + && lower_query.contains("pg_get_expr") + && !lower_query.contains("as column_name") + { + let tbl = Self::v42_extract_attrdef_table(query); + println!("INTERCEPT: dbx attrdef for table={:?}", tbl); + return Some(Self::v42_get_attrdef(&db, tbl.as_deref()).await); + } + + // === PATCH v43: DBX / DataGrip schema 对象列表 (展开树) 专属短路 === + if let Some(r) = Self::v43_dbx_object_list(&db, query).await { + return Some(r); + } + + // === PATCH v44: DBX / DataGrip 表属主 + 默认权限查询 (点开表) 专属短路 === + if let Some(r) = Self::v44_dbx_table_acl(&db, query).await { + return Some(r); + } + + // === PATCH v45: DBX / DataGrip schema 列表 (展开树第一层) 专属短路 === + if let Some(r) = Self::v45_dbx_schema_list(&db, query).await { + return Some(r); + } + + // === PATCH v46: DBX / DataGrip extension 列表 专属短路 === + if let Some(r) = Self::v46_dbx_extension_list(&db, query).await { + return Some(r); + } + + // === PATCH v47: DBX / DataGrip "Tables" 节点列表 专属短路 === + if let Some(r) = Self::v47_dbx_table_list(&db, query).await { + return Some(r); + } + // Check for catalog tables let has_catalog_tables = lower_query.contains("pg_catalog") || lower_query.contains("pg_type") || lower_query.contains("pg_namespace") || lower_query.contains("pg_range") || diff --git a/src/catalog/system_functions.rs b/src/catalog/system_functions.rs index 4adb0a42..53bbeb51 100644 --- a/src/catalog/system_functions.rs +++ b/src/catalog/system_functions.rs @@ -16,13 +16,13 @@ impl SystemFunctions { db: Arc, ) -> Result, Box> { match function_name.to_lowercase().as_str() { - "pg_get_constraintdef" => Self::pg_get_constraintdef(args, db).await, + "pg_get_constraintdef" => Ok(None), // PATCH v13: handled by SQLite UDF "pg_table_is_visible" => Self::pg_table_is_visible(args, db).await, - "format_type" => Self::format_type(args, db).await, - "pg_get_expr" => Self::pg_get_expr(args, db).await, + "format_type" => Ok(None), // PATCH v13: handled by SQLite UDF + "pg_get_expr" => Ok(None), // PATCH v13: handled by SQLite UDF "pg_get_userbyid" => Self::pg_get_userbyid(args).await, - "pg_get_indexdef" => Self::pg_get_indexdef(args, db).await, - "to_regtype" => Self::to_regtype(args, db).await, + "pg_get_indexdef" => Ok(None), // PATCH v13: handled by SQLite UDF + "to_regtype" => Ok(None), // PATCH v13: handled by SQLite UDF "pg_size_pretty" => Self::pg_size_pretty(args).await, _ => Ok(None), // Unknown function, let it pass through } diff --git a/src/config.rs b/src/config.rs index c9b1d866..21d027f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,9 @@ pub struct Config { #[arg(short, long, default_value = "sqlite.db", env = "PGSQLITE_DATABASE")] pub database: String, + #[arg(long, default_value = "127.0.0.1", env = "PGSQLITE_BIND_ADDRESS", help = "TCP bind address (default 127.0.0.1, use 0.0.0.0 to expose on all interfaces)")] + pub bind_address: String, + #[arg(long, default_value = "info", env = "PGSQLITE_LOG_LEVEL")] pub log_level: String, diff --git a/src/functions/system_functions.rs b/src/functions/system_functions.rs index 7cb99486..01acdfd9 100644 --- a/src/functions/system_functions.rs +++ b/src/functions/system_functions.rs @@ -2,6 +2,95 @@ use rusqlite::{Connection, Result, functions::FunctionFlags}; use tracing::debug; /// Register PostgreSQL system information functions +/// PATCH v13: map a PostgreSQL type OID (+ typmod) to its SQL type name. +/// Mirrors the OID table previously only available to the AST-rewrite path, but +/// now runs per-row inside SQLite so column-reference arguments work. +fn pg_type_name(oid: i64, typmod: Option) -> String { + let t = typmod.unwrap_or(-1); + match oid { + 16 => "boolean".to_string(), + 17 => "bytea".to_string(), + 18 => "\"char\"".to_string(), + 19 => "name".to_string(), + 20 => "bigint".to_string(), + 21 => "smallint".to_string(), + 23 => "integer".to_string(), + 25 => "text".to_string(), + 26 => "oid".to_string(), + 27 => "tid".to_string(), + 28 => "xid".to_string(), + 29 => "cid".to_string(), + 700 => "real".to_string(), + 701 => "double precision".to_string(), + 790 => "money".to_string(), + 1042 => { + if t > 4 { format!("character({})", t - 4) } else { "character".to_string() } + } + 1043 => { + if t > 4 { format!("character varying({})", t - 4) } else { "character varying".to_string() } + } + 1082 => "date".to_string(), + 1083 => "time without time zone".to_string(), + 1114 => "timestamp without time zone".to_string(), + 1184 => "timestamp with time zone".to_string(), + 1186 => "interval".to_string(), + 1266 => "time with time zone".to_string(), + 1700 => { + if t > 4 { + let precision = (t - 4) >> 16; + let scale = (t - 4) & 0xFFFF; + if scale > 0 { format!("numeric({precision},{scale})") } else { format!("numeric({precision})") } + } else { "numeric".to_string() } + } + 114 => "json".to_string(), + 3802 => "jsonb".to_string(), + 2950 => "uuid".to_string(), + 600 => "point".to_string(), + 601 => "lseg".to_string(), + 602 => "path".to_string(), + 603 => "box".to_string(), + 604 => "polygon".to_string(), + 628 => "line".to_string(), + 718 => "circle".to_string(), + 829 => "macaddr".to_string(), + 869 => "inet".to_string(), + 650 => "cidr".to_string(), + 1560 => "bit".to_string(), + 1562 => "bit varying".to_string(), + _ => format!("unknown({oid})"), + } +} + +/// PATCH v13: map a type name to its OID (inverse of pg_type_name). +fn regtype_to_oid(name: &str) -> Option { + let oid = match name.to_lowercase().as_str() { + "bool" | "boolean" => 16, + "bytea" => 17, + "int8" | "bigint" => 20, + "int2" | "smallint" => 21, + "int4" | "integer" | "int" => 23, + "text" => 25, + "json" => 114, + "float4" | "real" => 700, + "float8" | "double precision" => 701, + "char" => 1042, + "varchar" | "character varying" => 1043, + "date" => 1082, + "time" => 1083, + "timestamp" | "timestamp without time zone" => 1114, + "timestamptz" | "timestamp with time zone" => 1184, + "interval" => 1186, + "timetz" | "time with time zone" => 1266, + "bit" => 1560, + "varbit" | "bit varying" => 1562, + "numeric" | "decimal" => 1700, + "uuid" => 2950, + "jsonb" => 3802, + _ => return None, + }; + Some(oid.to_string()) +} + pub fn register_system_functions(conn: &Connection) -> Result<()> { debug!("Registering system functions"); @@ -373,8 +462,11 @@ pub fn register_system_functions(conn: &Connection) -> Result<()> { "pg_get_userbyid", 1, FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, - |ctx| { - let _user_oid: i64 = ctx.get(0)?; + |_ctx| { + // PATCH v12: arguments are deliberately not bound. Catalog views hand us + // TEXT oids, and ctx.get::() would abort the whole query with + // "Invalid function parameter type Text at index 0". The values were + // already unused -- this function has no way to reach the connection. // SQLite doesn't have users, return a default user // This matches what psql expects for the \d command Ok("postgres".to_string()) @@ -386,10 +478,11 @@ pub fn register_system_functions(conn: &Connection) -> Result<()> { "obj_description", 2, FunctionFlags::SQLITE_UTF8, - |ctx| { - let _object_oid: i64 = ctx.get(0)?; - let _catalog_name: String = ctx.get(1)?; - + |_ctx| { + // PATCH v12: arguments are deliberately not bound. Catalog views hand us + // TEXT oids, and ctx.get::() would abort the whole query with + // "Invalid function parameter type Text at index 0". The values were + // already unused -- this function has no way to reach the connection. // For SQLite functions, we can't easily access the connection // So we return NULL for now - this will be handled by query interceptor // or comment function processor @@ -402,8 +495,11 @@ pub fn register_system_functions(conn: &Connection) -> Result<()> { "obj_description", 1, FunctionFlags::SQLITE_UTF8, - |ctx| { - let _object_oid: i64 = ctx.get(0)?; + |_ctx| { + // PATCH v12: arguments are deliberately not bound. Catalog views hand us + // TEXT oids, and ctx.get::() would abort the whole query with + // "Invalid function parameter type Text at index 0". The values were + // already unused -- this function has no way to reach the connection. // Use the two-parameter version with default catalog // For now, return NULL - will be handled by query interceptor for real queries Ok(Option::::None) @@ -415,10 +511,11 @@ pub fn register_system_functions(conn: &Connection) -> Result<()> { "col_description", 2, FunctionFlags::SQLITE_UTF8, - |ctx| { - let _table_oid: i64 = ctx.get(0)?; - let _column_number: i32 = ctx.get(1)?; - + |_ctx| { + // PATCH v12: arguments are deliberately not bound. Catalog views hand us + // TEXT oids, and ctx.get::() would abort the whole query with + // "Invalid function parameter type Text at index 0". The values were + // already unused -- this function has no way to reach the connection. // Query __pgsqlite_comments table for column comment // For now, return NULL - will be handled by query interceptor Ok(Option::::None) @@ -463,6 +560,441 @@ pub fn register_system_functions(conn: &Connection) -> Result<()> { }, )?; + // === PATCH v13: register GUI column-query system functions as SQLite UDFs === + // These run per-row inside SQLite, so they work with column-reference arguments + // (e.g. format_type(a.atttypid, a.atttypmod)) which the AST-rewrite path cannot evaluate. + conn.create_scalar_function( + "format_type", + 2, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + let typid: Option = match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Integer => Some(ctx.get::(0)?), + rusqlite::types::Type::Text => { + let s: String = ctx.get(0)?; + s.parse::().ok() + } + _ => None, + }; + let typmod: Option = match ctx.get_raw(1).data_type() { + rusqlite::types::Type::Integer => Some(ctx.get::(1)?), + _ => None, + }; + Ok(typid.map(|oid| pg_type_name(oid, typmod))) + }, + )?; + + conn.create_scalar_function( + "pg_get_expr", + 2, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Null => Ok(Option::::None), + rusqlite::types::Type::Text => { + let s: String = ctx.get(0)?; + if s.is_empty() { Ok(Option::::None) } else { Ok(Some(s)) } + } + _ => Ok(Option::::None), + } + }, + )?; + + conn.create_scalar_function( + "pg_get_indexdef", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some("".to_string())), + )?; + + conn.create_scalar_function( + "pg_get_constraintdef", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some("".to_string())), + )?; + + // === PATCH v13b: additional PostgreSQL arities (SQLite matches UDFs by name+arity) === + conn.create_scalar_function( + "pg_get_expr", + 3, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let s: String = ctx.get(0)?; + if s.is_empty() { Ok(Option::::None) } else { Ok(Some(s)) } + } + _ => Ok(Option::::None), + } + }, + )?; + + conn.create_scalar_function( + "pg_get_indexdef", + 3, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some("".to_string())), + )?; + + conn.create_scalar_function( + "pg_get_constraintdef", + 2, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some("".to_string())), + )?; + + conn.create_scalar_function( + "to_regtype", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let name: String = ctx.get(0)?; + Ok(regtype_to_oid(&name)) + } + _ => Ok(Option::::None), + } + }, + )?; + + + // ==================== PATCH v16: catalog function gap-fill ==================== + // 任何未注册的 pg_* 函数都会让 SQLite 抛 "no such function",进而把客户端 + // 事务标记为 aborted,之后所有元数据查询级联失败(GUI 表树/列树整体崩)。 + // 这里按 PostgreSQL 语义补齐 GUI 高频函数;无法真实实现者返回中性值而非 + // 报错。统一使用 n_arg = -1(变参)以覆盖 PG 的全部重载形式。 + + // ---- reg* 转换族 ---- + // to_regclass('schema.tbl') -> pg_class.oid(TEXT,公式与 pg_class 视图一致) + conn.create_scalar_function( + "to_regclass", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let raw: String = ctx.get(0)?; + Ok(Some(relname_to_oid_string(&raw))) + } + _ => Ok(Option::::None), + } + }, + )?; + + // to_regproc / to_regprocedure -> 稳定 OID(同一名字总得到同一值) + conn.create_scalar_function( + "to_regproc", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let raw: String = ctx.get(0)?; + Ok(Some(relname_to_oid_string(&raw))) + } + _ => Ok(Option::::None), + } + }, + )?; + conn.create_scalar_function( + "to_regprocedure", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let raw: String = ctx.get(0)?; + Ok(Some(relname_to_oid_string(&raw))) + } + _ => Ok(Option::::None), + } + }, + )?; + + // to_regnamespace:内置 schema 用 PG 的真实 OID,其余走公式 + conn.create_scalar_function( + "to_regnamespace", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let raw: String = ctx.get(0)?; + let name = strip_relation_name(&raw).to_lowercase(); + let oid = match name.as_str() { + "pg_catalog" => "11".to_string(), + "public" => "2200".to_string(), + "information_schema" => "13000".to_string(), + "pg_toast" => "99".to_string(), + _ => relname_to_oid_string(&raw), + }; + Ok(Some(oid)) + } + _ => Ok(Option::::None), + } + }, + )?; + + // to_regrole:单用户模型,统一映射到 bootstrap superuser OID 10 + conn.create_scalar_function( + "to_regrole", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + Ok(Some("10".to_string())) + }, + )?; + + // ---- 描述 / 定义族:返回空串或 NULL,绝不报错 ---- + // shobj_description(oid, catalog) -> 共享对象注释,本地无 -> NULL + conn.create_scalar_function( + "shobj_description", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Option::::None), + )?; + + for fname in [ + "pg_get_viewdef", // (oid) / (oid, pretty) / (oid, wrap) + "pg_get_functiondef", // (oid) + "pg_get_function_arguments", // (oid) + "pg_get_function_result", // (oid) + "pg_get_function_identity_arguments", // (oid) + "pg_get_ruledef", // (oid) / (oid, pretty) + "pg_get_triggerdef", // (oid) / (oid, pretty) + ] { + conn.create_scalar_function( + fname, + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some(String::new())), + )?; + } + + // 这几个在 PG 里对"没有"的对象就返回 NULL,保持一致 + for fname in [ + "pg_get_serial_sequence", // SQLite 无独立 sequence + "pg_get_partkeydef", // 无分区表 + "pg_relation_filepath", // 无物理 relfilenode + "pg_sequence_last_value", + "pg_get_statisticsobjdef", + ] { + conn.create_scalar_function( + fname, + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Option::::None), + )?; + } + + // ---- 权限族:单用户模型,一律放行 ---- + for fname in [ + "has_column_privilege", + "has_function_privilege", + "has_any_column_privilege", + "has_sequence_privilege", + "has_tablespace_privilege", + "has_language_privilege", + "has_foreign_data_wrapper_privilege", + "has_server_privilege", + "has_type_privilege", + // 下面两个已有 3 参数版本,这里补变参以修好 2 参数调用的 arity 报错 + "has_schema_privilege", + "has_database_privilege", + "has_table_privilege", + "pg_has_role", + ] { + conn.create_scalar_function( + fname, + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(1i32), + )?; + } + + // ---- 可见性族:单 schema 模型,一律可见 ---- + for fname in [ + "pg_type_is_visible", + "pg_function_is_visible", + "pg_opclass_is_visible", + "pg_operator_is_visible", + "pg_opfamily_is_visible", + "pg_collation_is_visible", + "pg_conversion_is_visible", + "pg_ts_config_is_visible", + "pg_ts_dict_is_visible", + "pg_ts_parser_is_visible", + "pg_ts_template_is_visible", + "pg_statistics_obj_is_visible", + ] { + conn.create_scalar_function( + fname, + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(1i32), + )?; + } + + // ---- 尺寸族:SQLite 无 per-relation 统计,返回 0 而不是报错 ---- + for fname in [ + "pg_relation_size", + "pg_total_relation_size", + "pg_table_size", + "pg_indexes_size", + "pg_column_size", + ] { + conn.create_scalar_function( + fname, + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(0i64), + )?; + } + + // ---- 编码族 ---- + conn.create_scalar_function( + "pg_encoding_to_char", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some("UTF8".to_string())), + )?; + conn.create_scalar_function( + "pg_client_encoding", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(Some("UTF8".to_string())), + )?; + conn.create_scalar_function( + "pg_char_to_encoding", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(6i64), + )?; + + // ---- GUC:current_setting(name) / current_setting(name, missing_ok) ---- + // 未知参数返回 NULL 而不是报错(PG 在 missing_ok=false 时会报错,但对 GUI + // 而言 abort 事务的代价远大于返回 NULL)。 + conn.create_scalar_function( + "current_setting", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => { + let name: String = ctx.get(0)?; + Ok(guc_value(&name)) + } + _ => Ok(Option::::None), + } + }, + )?; + + // ---- 事务 / 临时 schema ---- + conn.create_scalar_function( + "txid_current", + -1, + FunctionFlags::SQLITE_UTF8, + |_ctx| Ok(1i64), + )?; + conn.create_scalar_function( + "pg_current_xact_id", + -1, + FunctionFlags::SQLITE_UTF8, + |_ctx| Ok(1i64), + )?; + conn.create_scalar_function( + "pg_my_temp_schema", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(0i64), + )?; + conn.create_scalar_function( + "pg_is_other_temp_schema", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |_ctx| Ok(0i32), + )?; + + // ---- 标识符引用(DBeaver 生成 DDL 时用) ---- + conn.create_scalar_function( + "quote_ident", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + let raw: String = match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Text => ctx.get(0)?, + _ => return Ok(Option::::None), + }; + let needs_quote = raw.is_empty() + || raw.chars().next().is_some_and(|c| c.is_ascii_digit()) + || !raw.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); + if needs_quote { + Ok(Some(format!("\"{}\"", raw.replace('"', "\"\"")))) + } else { + Ok(Some(raw)) + } + }, + )?; + conn.create_scalar_function( + "quote_literal", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Option::::None); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Null => Ok(Option::::None), + _ => { + let raw: String = ctx.get(0)?; + Ok(Some(format!("'{}'", raw.replace('\'', "''")))) + } + } + }, + )?; + conn.create_scalar_function( + "quote_nullable", + -1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + if ctx.len() == 0 { + return Ok(Some("NULL".to_string())); + } + match ctx.get_raw(0).data_type() { + rusqlite::types::Type::Null => Ok(Some("NULL".to_string())), + _ => { + let raw: String = ctx.get(0)?; + Ok(Some(format!("'{}'", raw.replace('\'', "''")))) + } + } + }, + )?; + + // ================== end PATCH v16 ================== + debug!("System functions registered successfully"); Ok(()) } @@ -717,4 +1249,67 @@ mod tests { ).unwrap(); assert_eq!(desc, None); // Should return NULL } -} \ No newline at end of file +} + + +// ==================== PATCH v16 helpers ==================== + +/// PATCH v16: 从关系引用中剥离 schema 限定与双引号。 +/// `public."My Table"` -> `My Table`,`pg_catalog.pg_class` -> `pg_class` +fn strip_relation_name(raw: &str) -> String { + let s = raw.trim(); + let mut in_quote = false; + let mut last_dot: Option = None; + for (i, ch) in s.char_indices() { + match ch { + '"' => in_quote = !in_quote, + '.' if !in_quote => last_dot = Some(i), + _ => {} + } + } + let tail = match last_dot { + Some(i) => &s[i + 1..], + None => s, + }; + tail.trim().trim_matches('"').to_string() +} + +/// PATCH v16: 与 `pg_class` 视图(migration/registry.rs)及 pg_class.rs 的 +/// generate_oid_from_name 逐字节等价的 OID 公式。必须保持一致,否则客户端 +/// 拿 to_regclass() 的结果去查 pg_attribute 会得到零行。 +fn relname_to_oid_string(raw: &str) -> String { + let name = strip_relation_name(raw); + let chars: Vec = name.chars().collect(); + let at = |i: usize| chars.get(i).copied().unwrap_or(' ') as u32; + let len = chars.len() as u32; + let oid = + ((at(0) * 1_000_000) + (at(1) * 10_000) + (at(2) * 100) + (len * 7)) % 1_000_000 + 16384; + oid.to_string() +} + +/// PATCH v16: current_setting() 支持的 GUC。未知名字返回 None(-> SQL NULL), +/// 刻意不报错:让 GUI 少一条信息,好过让整个事务 abort。 +fn guc_value(name: &str) -> Option { + let v = match strip_relation_name(name).to_lowercase().as_str() { + "server_version" => "16.0", + "server_version_num" => "160000", + "server_encoding" | "client_encoding" => "UTF8", + "lc_collate" | "lc_ctype" | "lc_messages" | "lc_monetary" | "lc_numeric" | "lc_time" => "C", + "timezone" => "UTC", + "datestyle" => "ISO, MDY", + "intervalstyle" => "postgres", + "standard_conforming_strings" | "integer_datetimes" | "is_superuser" => "on", + "search_path" => "\"$user\", public", + "application_name" => "", + "bytea_output" => "hex", + "default_transaction_isolation" | "transaction_isolation" => "read committed", + "default_transaction_read_only" | "transaction_read_only" | "in_hot_standby" => "off", + "max_index_keys" => "32", + "max_identifier_length" => "63", + "block_size" => "8192", + "session_authorization" => "postgres", + "role" => "none", + _ => return None, + }; + Some(v.to_string()) +} diff --git a/src/main.rs b/src/main.rs index 3b4ebc5c..bd0f9bc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,8 +108,8 @@ async fn main() -> Result<()> { // Create TCP listener if not disabled let tcp_listener = if !config.no_tcp { - let listener = TcpListener::bind(("0.0.0.0", config.port)).await?; - info!("TCP server listening on port {}", config.port); + let listener = TcpListener::bind((config.bind_address.as_str(), config.port)).await?; + info!("TCP server listening on {}:{}", config.bind_address, config.port); Some(listener) } else { info!("TCP listener disabled, using Unix socket only"); @@ -427,8 +427,54 @@ where info!("Sent authentication and ready response to {}", connection_info); // Main message loop + // === PATCH v29e: PostgreSQL extended-protocol error recovery === + // Once an error is reported inside an extended-query sequence the backend + // MUST discard every subsequent message until a Sync arrives, and only then + // emit a single ReadyForQuery. Answering the queued Describe/Bind/Execute + // shifts the whole frame stream by one message and the client aborts with + // "unexpected message from server". + let mut __v29e_error_state = false; while let Some(msg) = framed.next().await { - match msg? { + let __v29e_msg = msg?; + + if __v29e_error_state { + match &__v29e_msg { + FrontendMessage::Sync => { + __v29e_error_state = false; + // Implicit rollback on Sync: clear any failed-transaction state. + let cur = *session.transaction_status.read().await; + if cur == TransactionStatus::InFailedTransaction { + session.set_transaction_status(TransactionStatus::Idle).await; + } + framed + .send(BackendMessage::ReadyForQuery { + status: *session.transaction_status.read().await, + }) + .await?; + framed.flush().await?; + continue; + } + // A simple Query reply is self-contained (it always ends with its + // own ReadyForQuery), so accepting it cannot desynchronise the + // stream. Being lenient here avoids hanging a client that skips + // Sync after a failed extended sequence. + FrontendMessage::Query(_) => { + __v29e_error_state = false; + } + FrontendMessage::Terminate => { + __v29e_error_state = false; + } + other => { + debug!( + "v29e: discarding {:?} from {} while skipping to Sync", + other, connection_info + ); + continue; + } + } + } + + match __v29e_msg { FrontendMessage::Query(sql) => { debug!("Received query from {}: {}", connection_info, sql); @@ -458,7 +504,7 @@ where // Query executed successfully } Err(e) => { - error!("Query execution error: {}", e); + error!("[QFAIL simple] {} || SQL: {}", e, sql); // If we're in a transaction, mark it as failed // Let SQLAlchemy handle its own rollback to avoid double-rollback issues @@ -500,9 +546,15 @@ where "Rate limit exceeded".to_string(), ); framed.send(BackendMessage::ErrorResponse(Box::new(err))).await?; + // === PATCH v29e: skip to Sync instead of answering the rest === + __v29e_error_state = true; + framed.flush().await?; continue; } + // v29e: keep the SQL text for the [QFAIL] diagnostic; `query` is + // moved into handle_parse below. + let __qfail_sql = query.clone(); match ExtendedQueryHandler::handle_parse( &mut framed, &db_handler, @@ -515,18 +567,22 @@ where { Ok(()) => {} Err(e) => { - error!("Parse error: {}", e); + error!("[QFAIL parse] {} || SQL: {}", e, __qfail_sql); let err = ErrorResponse::new( "ERROR".to_string(), "42000".to_string(), format!("Parse failed: {e}"), ); framed.send(BackendMessage::ErrorResponse(Box::new(err))).await?; - framed - .send(BackendMessage::ReadyForQuery { - status: *session.transaction_status.read().await, - }) - .await?; + // === PATCH v29e: enter extended-protocol error state === + // Deliberately NO ReadyForQuery here: it is Sync's job. + if session.in_transaction().await { + session + .set_transaction_status(TransactionStatus::InFailedTransaction) + .await; + } + __v29e_error_state = true; + framed.flush().await?; } } } @@ -550,23 +606,29 @@ where { Ok(()) => {} Err(e) => { - error!("Bind error: {}", e); + error!("[QFAIL bind] {}", e); let err = ErrorResponse::new( "ERROR".to_string(), "42000".to_string(), format!("Bind failed: {e}"), ); framed.send(BackendMessage::ErrorResponse(Box::new(err))).await?; - framed - .send(BackendMessage::ReadyForQuery { - status: *session.transaction_status.read().await, - }) - .await?; + // === PATCH v29e: enter extended-protocol error state === + // Deliberately NO ReadyForQuery here: it is Sync's job. + if session.in_transaction().await { + session + .set_transaction_status(TransactionStatus::InFailedTransaction) + .await; + } + __v29e_error_state = true; + framed.flush().await?; } } } FrontendMessage::Execute { portal, max_rows } => { info!("Received Execute from {}: portal={}, max_rows={}", connection_info, portal, max_rows); + // v29e: keep the portal name for the [QFAIL] diagnostic. + let __qfail_portal = portal.clone(); match ExtendedQueryHandler::handle_execute( &mut framed, &db_handler, @@ -578,18 +640,22 @@ where { Ok(()) => {} Err(e) => { - error!("Execute error: {}", e); + error!("[QFAIL execute] {} || portal: {}", e, __qfail_portal); let err = ErrorResponse::new( "ERROR".to_string(), "42000".to_string(), format!("Execute failed: {e}"), ); framed.send(BackendMessage::ErrorResponse(Box::new(err))).await?; - framed - .send(BackendMessage::ReadyForQuery { - status: *session.transaction_status.read().await, - }) - .await?; + // === PATCH v29e: enter extended-protocol error state === + // Deliberately NO ReadyForQuery here: it is Sync's job. + if session.in_transaction().await { + session + .set_transaction_status(TransactionStatus::InFailedTransaction) + .await; + } + __v29e_error_state = true; + framed.flush().await?; } } } @@ -598,18 +664,22 @@ where { Ok(()) => {} Err(e) => { - error!("Describe error: {}", e); + error!("[QFAIL describe] {}", e); let err = ErrorResponse::new( "ERROR".to_string(), "42000".to_string(), format!("Describe failed: {e}"), ); framed.send(BackendMessage::ErrorResponse(Box::new(err))).await?; - framed - .send(BackendMessage::ReadyForQuery { - status: *session.transaction_status.read().await, - }) - .await?; + // === PATCH v29e: enter extended-protocol error state === + // Deliberately NO ReadyForQuery here: it is Sync's job. + if session.in_transaction().await { + session + .set_transaction_status(TransactionStatus::InFailedTransaction) + .await; + } + __v29e_error_state = true; + framed.flush().await?; } } } @@ -624,21 +694,36 @@ where format!("Close failed: {e}"), ); framed.send(BackendMessage::ErrorResponse(Box::new(err))).await?; - framed - .send(BackendMessage::ReadyForQuery { - status: *session.transaction_status.read().await, - }) - .await?; + // === PATCH v29e: enter extended-protocol error state === + // Deliberately NO ReadyForQuery here: it is Sync's job. + if session.in_transaction().await { + session + .set_transaction_status(TransactionStatus::InFailedTransaction) + .await; + } + __v29e_error_state = true; + framed.flush().await?; } } } FrontendMessage::Sync => { + // PostgreSQL implicitly rolls back a FAILED transaction on Sync. + // Reset to Idle so a later query is not rejected with + // "current transaction is aborted" (which pgjdbc surfaces as + // "unexpected message from server"). An explicit transaction + // (InTransaction) must survive Sync, so only InFailedTransaction + // is cleared here. + let cur = *session.transaction_status.read().await; + if cur == TransactionStatus::InFailedTransaction { + session.set_transaction_status(TransactionStatus::Idle).await; + } // Send ReadyForQuery to indicate we're ready for more commands framed .send(BackendMessage::ReadyForQuery { status: *session.transaction_status.read().await, }) .await?; + framed.flush().await?; } FrontendMessage::Flush => { // Flush any pending messages diff --git a/src/protocol/codec.rs b/src/protocol/codec.rs index cd02e627..0b793660 100644 --- a/src/protocol/codec.rs +++ b/src/protocol/codec.rs @@ -307,6 +307,15 @@ fn encode_empty_query_response(dst: &mut BytesMut) { } fn encode_error_response(err: ErrorResponse, dst: &mut BytesMut) { + // === PATCH v29e: catch-all audit of every ErrorResponse put on the wire. + // Without this, SQLite rejections were invisible server-side and the log + // looked clean while DBeaver was drowning in errors. + tracing::warn!( + "[QFAIL wire] severity={} code={} msg={}", + err.severity, + err.code, + err.message + ); dst.put_u8(b'E'); let len_pos = dst.len(); dst.put_i32(0); // Placeholder diff --git a/src/query/executor.rs b/src/query/executor.rs index 12fbb4b3..3a60cc4f 100644 --- a/src/query/executor.rs +++ b/src/query/executor.rs @@ -563,7 +563,21 @@ impl QueryExecutor { _ => Some(data), // Keep original data for unknown datetime types } } else { - Some(data) // Keep original data if not an integer + // v29c: value is stored as TEXT (e.g. '08:30' written by + // an app), not pgsqlite's integer encoding. Canonicalize + // TIME so PG clients can parse it; anything else is + // passed through byte-for-byte. + let normalized = match dt_type.as_str() { + "time" | "timetz" | "time without time zone" | "time with time zone" => { + crate::types::datetime_utils::time_text_to_micros(s) + .map(crate::types::datetime_utils::format_microseconds_to_time) + } + _ => None, + }; + match normalized { + Some(txt) => Some(txt.into_bytes()), + None => Some(data), + } } } Err(_) => Some(data), // Keep original data if not valid UTF-8 @@ -996,6 +1010,65 @@ impl QueryExecutor { // debug!("execute_select (non-ultra-simple) called with query: {}", query); // SQLAlchemy manages transactions explicitly - don't start implicit transactions // debug!("=== EXECUTE_SELECT CALLED with query: {}", query); + + // === PATCH v29d: PostgreSQL escape-string constants E'...' === + // DBeaver reads catalog metadata with e.g. + // WHERE relname LIKE E'pg\_class' + // SQLite has no E'' syntax and died with + // near "'pg\_class'": syntax error + // which aborted navigator expand / column / index / DDL reads. + // Decode into a plain SQLite literal using PostgreSQL's escape rules. + // Must run BEFORE every other translator: an escaped quote (\') would + // otherwise desynchronise their literal scanners. + let __v29d_estr_owned = crate::translator::EscapeStringTranslator::contains_escape_string(query) + .then(|| crate::translator::EscapeStringTranslator::translate(query)); + let query: &str = __v29d_estr_owned.as_deref().unwrap_or(query); + + // === PATCH v11: ILIKE -> LIKE before anything else looks at the query === + // SQLite has no ILIKE operator. Catalog JOIN queries from dbx/DBeaver now + // reach SQLite verbatim (see PATCH v11 in catalog/query_interceptor.rs) and + // those queries filter relation names with ILIKE. Translating at the entry + // point covers the catalog interceptor and the plain SQLite path in one hop. + // No-op (and no allocation) for queries that do not contain ILIKE. + // === PATCH v20: also give bare LIKE PostgreSQL's default \ escape === + let __v11_ilike_owned = if crate::translator::IlikeTranslator::contains_like(query) { + Some(crate::translator::IlikeTranslator::normalize_like(query)) + } else { + None + }; + let query: &str = __v11_ilike_owned.as_deref().unwrap_or(query); + + // === PATCH v22: PostgreSQL LIMIT/OFFSET semantics === + // PG treats `LIMIT NULL` / `LIMIT ALL` as "no upper bound" and allows a + // bare OFFSET; SQLite errors out on both. dbx sends `LIMIT CAST($4 AS + // BIGINT)` with $4 = NULL when loading a schema's table list, which used + // to fail the whole statement with "datatype mismatch" and left the table + // tree empty. Parameters are already inlined at this point, so the real + // value is visible here. Returns None (no allocation) for normal LIMITs. + let __v22_limit_owned = crate::translator::LimitTranslator::needs_translation(query) + .then(|| crate::translator::LimitTranslator::translate(query)) + .flatten(); + let query: &str = __v22_limit_owned.as_deref().unwrap_or(query); + + // === PATCH v27: DBeaver JOIN LATERAL unnest + public. prefix === + // 1) unnest: DBeaver's table-index query uses + // JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true + // which SQLite cannot parse (existing UnnestTranslator only matched + // `FROM unnest(...)`). Translate to a json_each() subquery. + // 2) public.: DBeaver qualifies tables with the schema prefix + // (`SELECT * FROM public.bath_records`); SQLite has no schema + // namespace, so drop the qualifier (literals are left alone). + let __v27_unnest_owned = crate::translator::UnnestTranslator::contains_unnest(query) + .then(|| crate::translator::UnnestTranslator::translate_unnest(query).ok()) + .flatten(); + let query: &str = __v27_unnest_owned.as_deref().unwrap_or(query); + let __v27_public_owned = if query.contains("public.") || query.contains("\"public\"") { + Some(crate::translator::SchemaPrefixTranslator::strip_public_prefix(query)) + } else { + None + }; + let query: &str = __v27_public_owned.as_deref().unwrap_or(query); + // Check wire protocol cache first for cacheable queries if crate::cache::is_cacheable_for_wire_protocol(query) @@ -1462,7 +1535,7 @@ impl QueryExecutor { } "TIME" | "TIME WITHOUT TIME ZONE" | "TIME WITH TIME ZONE" | "TIMETZ" => { if let Ok(value_str) = std::str::from_utf8(value_bytes) - && let Ok(micros) = value_str.parse::() { + && let Some(micros) = crate::types::datetime_utils::time_text_to_micros(value_str) { use crate::types::datetime_utils::format_microseconds_to_time_buf; let mut buf = vec![0u8; 32]; let len = format_microseconds_to_time_buf(micros, &mut buf); @@ -1788,7 +1861,7 @@ impl QueryExecutor { "TIME" | "TIME WITHOUT TIME ZONE" | "TIME WITH TIME ZONE" | "TIMETZ" => { // Convert INTEGER microseconds to HH:MM:SS.ffffff format if let Ok(value_str) = std::str::from_utf8(value_bytes) { - if let Ok(micros) = value_str.parse::() { + if let Some(micros) = crate::types::datetime_utils::time_text_to_micros(value_str) { use crate::types::datetime_utils::format_microseconds_to_time_buf; let mut buf = vec![0u8; 32]; let len = format_microseconds_to_time_buf(micros, &mut buf); @@ -2496,7 +2569,7 @@ impl QueryExecutor { } else if type_oid == time_oid || type_oid == timetz_oid { // Convert INTEGER microseconds to HH:MM:SS.ffffff format if let Ok(s) = std::str::from_utf8(&data) { - if let Ok(micros) = s.parse::() { + if let Some(micros) = crate::types::datetime_utils::time_text_to_micros(s) { use crate::types::datetime_utils::format_microseconds_to_time_buf; let mut buf = vec![0u8; 32]; let len = format_microseconds_to_time_buf(micros, &mut buf); @@ -2591,6 +2664,20 @@ impl QueryExecutor { } } + +/// PATCH v5: PostgreSQL reserves the `pg_` prefix for system catalogs, and +/// pgsqlite exposes those catalogs as SQLite *views* that carry no declared +/// column types. Running PRAGMA type inference against them yields BLOB, +/// which is then advertised on the wire as bytea (oid 17) while the catalog +/// handlers actually emit plain text -- clients such as dbx / DBeaver / pg8000 +/// then fail while hex-decoding. Treating them as "no table" makes the caller +/// fall back to text (oid 25), which is correct. +fn is_pg_catalog_object(name: &str) -> bool { + let lower = name.trim_matches('"').trim_matches('\'').to_ascii_lowercase(); + let bare = lower.rsplit('.').next().unwrap_or(lower.as_str()); + bare.starts_with("pg_") || bare.starts_with("information_schema") +} + fn extract_table_name_from_select(query: &str) -> Option { // Look for FROM keyword using regex to handle various whitespace patterns use once_cell::sync::Lazy; @@ -2612,6 +2699,11 @@ fn extract_table_name_from_select(query: &str) -> Option { let table_name = table_name.trim_matches('"').trim_matches('\''); if !table_name.is_empty() { + // === PATCH v5: never PRAGMA-probe synthesised catalog views === + if is_pg_catalog_object(table_name) { + debug!("extract_table_name_from_select: '{}' is a catalog object -> None", table_name); + return None; + } // debug!("extract_table_name_from_select: extracted table='{}'", table_name); debug!("extract_table_name_from_select: query='{}' -> table='{}'", query, table_name); return Some(table_name.to_string()); diff --git a/src/query/extended.rs b/src/query/extended.rs index fd1cb4ff..63721d85 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -424,6 +424,14 @@ impl ExtendedQueryHandler { cleaned_query.clone() }; + // === v47fix7: 去除 public. schema 前缀(与 execute_select 的 v47fix5 对齐)=== + // 否则非 catalog 的带 public 前缀查询(如 SELECT * FROM "public"."t")在 + // Parse 阶段测试查询失败 -> field_descriptions 空 -> Describe(P) 发 NoData(0列), + // 而 Execute 阶段 strip 后返回真实列数 -> 列数错位 -> pgjdbc unexpected message。 + if translated_for_analysis.contains("public.") || translated_for_analysis.contains("\"public\"") { + translated_for_analysis = crate::translator::SchemaPrefixTranslator::strip_public_prefix(&translated_for_analysis); + } + // Translate NUMERIC to TEXT casts with proper formatting #[cfg(not(feature = "unified_processor"))] // Skip when using unified processor if crate::translator::NumericFormatTranslator::needs_translation(&translated_for_analysis) { @@ -528,8 +536,19 @@ impl ExtendedQueryHandler { cleaned_query.contains("pg_namespace") || cleaned_query.contains("pg_enum") || cleaned_query.contains("pg_constraint") || cleaned_query.contains("pg_depend") || cleaned_query.contains("pg_database") { - info!("PARSE: Skipping field description for catalog query: {}", cleaned_query); - Vec::new() + // v47fix: Parse 阶段即用 catalog interceptor 探测真实列数, + // 避免在 extended 协议下 fd_len=0,导致 Describe(Portal) 直接发 + // NoData(0列),客户端(dbx/DataGrip)据此把 Execute 结果按 0 列 + // 丢弃,表列表/对象列表等节点空白。truth probe 只在 + // Describe(Statement) 运行,而 dbx 走 Describe(Portal),必须 + // 让 Parse 阶段就把 field_descriptions 设为正确列数。 + match Self::v29i_probe_catalog_columns(session, &cleaned_query).await { + Some(cols) => Self::v29i_fields_from_columns(&cols), + None => { + info!("PARSE: catalog query probed empty, leaving 0 field_descriptions: {}", cleaned_query); + Vec::new() + } + } } else { // Try to get field descriptions // For parameterized queries, substitute dummy values @@ -1952,7 +1971,96 @@ impl ExtendedQueryHandler { Ok(()) } - + + // ===================== v29i catalog truth probe ===================== + /// v29i: build FieldDescriptions from a real column-name list. + /// Catalog values are shipped as text bytes, so Text is the honest + /// default; the two exceptions below preserve the pre-v29i typing of + /// pg_attribute boolean/char columns so existing clients do not regress. + fn v29i_fields_from_columns(cols: &[String]) -> Vec { + cols.iter() + .enumerate() + .map(|(i, name)| { + let lower = name.to_lowercase(); + let type_oid = match lower.as_str() { + "attnotnull" | "atthasdef" | "attbyval" | "atthasmissing" + | "attisdropped" | "attislocal" | "not_null" | "has_default" + | "is_not_null" | "has_def" => PgType::Bool.to_oid(), + "attidentity" | "attgenerated" | "attalign" | "attstorage" + | "attcompression" => PgType::Char.to_oid(), + _ => PgType::Text.to_oid(), + }; + FieldDescription { + name: name.clone(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid, + type_size: -1, + type_modifier: -1, + format: 0, + } + }) + .collect() + } + + /// v29i: THE single source of truth for the shape of a catalog result. + /// Whatever CatalogInterceptor returns at Execute time is exactly what + /// Describe must announce, so we simply ask it up-front. $N parameter + /// placeholders are neutralised to NULL: we only want the column list, + /// never the rows. Result is memoised per query text. + async fn v29i_probe_catalog_columns( + session: &Arc, + query: &str, + ) -> Option> { + static V29I_CATALOG_COLS: once_cell::sync::Lazy< + parking_lot::Mutex>>, + > = once_cell::sync::Lazy::new(|| { + parking_lot::Mutex::new(std::collections::HashMap::new()) + }); + + if let Some(hit) = V29I_CATALOG_COLS.lock().get(query).cloned() { + return if hit.is_empty() { None } else { Some(hit) }; + } + + let mut probe = query.to_string(); + for i in (1..=32).rev() { + probe = probe.replace(&format!("${i}"), "NULL"); + } + + // 列数探测不需要实际行数。参数化查询中的 LIMIT/OFFSET(如 + // `LIMIT CAST($4 AS BIGINT) OFFSET CAST($5 AS BIGINT)`)被替换成 + // NULL 后,在 SQLite 里是非法语法(LIMIT must be an integer), + // 会让整条探测查询执行失败、返回 None,进而 Parse 阶段 fd_len 仍为 0, + // extended 协议下 Describe(Portal) 直接发 NoData(0列),客户端 + // (dbx/DataGrip) 据此把 Execute 的 DataRow 按 0 列丢弃 -> 表列表/ + // 对象列表等节点空白。列数与行数无关,统一修正为合法形态再探测。 + probe = probe.replace("LIMIT CAST(NULL AS BIGINT)", "LIMIT 0"); + probe = probe.replace("OFFSET CAST(NULL AS BIGINT)", ""); + probe = probe.replace("LIMIT NULL", "LIMIT 0"); + probe = probe.replace("OFFSET NULL", ""); + + let db = session.get_db_handler().await?; + let cols = match CatalogInterceptor::intercept_query( + &probe, + db, + Some(session.clone()), + ) + .await + { + Some(Ok(resp)) if !resp.columns.is_empty() => Some(resp.columns), + _ => None, + }; + + { + let mut cache = V29I_CATALOG_COLS.lock(); + if cache.len() > 512 { + cache.clear(); + } + cache.insert(query.to_string(), cols.clone().unwrap_or_default()); + } + cols + } + pub async fn handle_describe( framed: &mut Framed, session: &Arc, @@ -1966,14 +2074,60 @@ impl ExtendedQueryHandler { if typ == b'S' { // Describe statement + // Snapshot what we need, then release the read lock so the truth + // probe below can take a write lock without deadlocking. + let (param_types, query_text, fd_len) = { + let statements = session.prepared_statements.read().await; + let stmt = statements.get(&name) + .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {name}")))?; + (stmt.param_types.clone(), stmt.query.clone(), stmt.field_descriptions.len()) + }; + + // Send ParameterDescription first + framed.send(BackendMessage::ParameterDescription(param_types)).await + .map_err(PgSqliteError::Io)?; + + // ============ v29i catalog truth probe ============ + // Before v29i the field list for catalog queries came from + // hand-maintained tables that had drifted from what the catalog + // interceptor actually returns, and parameterised `SELECT *` + // catalog queries fell through to NoData entirely. Both cases end + // with Describe and Execute disagreeing, which clients report as + // "unexpected message from server" (DataGrip: empty table list). + // Ask the interceptor up-front for the real column list. + if query_starts_with_ignore_case(&query_text, "SELECT") + && (query_text.contains("pg_catalog") || query_text.contains("pg_type") + || query_text.contains("pg_namespace") || query_text.contains("pg_class") + || query_text.contains("pg_attribute") || query_text.contains("pg_constraint") + || query_text.contains("pg_index") || query_text.contains("pg_depend") + || query_text.contains("pg_database") || query_text.contains("information_schema")) + { + if let Some(cols) = Self::v29i_probe_catalog_columns(session, &query_text).await { + if fd_len != cols.len() { + warn!( + "v29i truth probe: statement '{}' announced {} fields but the catalog returns {} -> realigning. query: {}", + name, fd_len, cols.len(), query_text + ); + let fields = Self::v29i_fields_from_columns(&cols); + { + let mut sm = session.prepared_statements.write().await; + if let Some(stmt_mut) = sm.get_mut(&name) { + stmt_mut.field_descriptions = fields.clone(); + } + } + framed.send(BackendMessage::RowDescription(fields)).await + .map_err(PgSqliteError::Io)?; + return Ok(()); + } + } + } + // ============ end v29i catalog truth probe ============ + + // Re-acquire the read lock (released during the truth probe above) let statements = session.prepared_statements.read().await; let stmt = statements.get(&name) .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {name}")))?; - - // Send ParameterDescription first - framed.send(BackendMessage::ParameterDescription(stmt.param_types.clone())).await - .map_err(PgSqliteError::Io)?; - + // Check if this is a catalog query that needs special handling let query = &stmt.query; let is_catalog_query = query.contains("pg_catalog") || query.contains("pg_type") || @@ -1981,7 +2135,7 @@ impl ExtendedQueryHandler { query.contains("pg_attribute") || query.contains("pg_constraint") || query.contains("pg_index") || query.contains("pg_depend") || query.contains("pg_database") || query.contains("information_schema"); - + // Then send RowDescription or NoData if !stmt.field_descriptions.is_empty() { info!("Sending RowDescription with {} fields in Describe", stmt.field_descriptions.len()); @@ -2507,6 +2661,44 @@ impl ExtendedQueryHandler { } } else { // Describe portal + // === v47fix6: catalog truth probe 前移(防列数错位)=== + // dbx 点开表会发外键等 information_schema 查询(实际 7 列),但 Parse + // 阶段 field_descriptions 可能被错算(如 3 列)。dbx 走 Describe(Portal) + // 取列数,若直接用错的 field_descriptions 发 RowDescription,Execute 再发 + // 真实列数的 DataRow,列数错位 -> pgjdbc 报 "unexpected message from server"。 + // 这里先对 catalog 查询探测真实列数,不匹配则 realign 后直接返回。 + { + let (q, fd_len, sname) = { + let portals = session.portals.read().await; + let portal = portals.get(&name) + .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown portal: {name}")))?; + let statements = session.prepared_statements.read().await; + let stmt = statements.get(&portal.statement_name) + .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {}", portal.statement_name)))?; + (stmt.query.clone(), stmt.field_descriptions.len(), portal.statement_name.clone()) + }; + let is_catalog = query_starts_with_ignore_case(&q, "SELECT") + && (q.contains("pg_catalog") || q.contains("pg_type") + || q.contains("pg_namespace") || q.contains("pg_class") + || q.contains("pg_attribute") || q.contains("pg_constraint") + || q.contains("pg_index") || q.contains("pg_depend") + || q.contains("pg_database") || q.contains("information_schema")); + if is_catalog && fd_len > 0 { + if let Some(cols) = Self::v29i_probe_catalog_columns(session, &q).await { + if cols.len() != fd_len { + let fields = Self::v29i_fields_from_columns(&cols); + let mut sm = session.prepared_statements.write().await; + if let Some(stmt_mut) = sm.get_mut(&sname) { + stmt_mut.field_descriptions = fields.clone(); + } + drop(sm); + framed.send(BackendMessage::RowDescription(fields)).await + .map_err(PgSqliteError::Io)?; + return Ok(()); + } + } + } + } let portals = session.portals.read().await; let portal = portals.get(&name) .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown portal: {name}")))?; @@ -2582,6 +2774,37 @@ impl ExtendedQueryHandler { framed.send(BackendMessage::RowDescription(fields)).await .map_err(PgSqliteError::Io)?; } else { + // v29i: dbx / DataGrip 走 Describe(Portal) 取列数,且往往不发 + // Describe(Statement)。Parse 阶段即便跑了 catalog 探测,结果也可能 + // 没落到该 statement(或探测当次返回 None),导致 field_descriptions + // 恒为 0 -> 此处直接发 NoData(0列),客户端据此把 Execute 的 DataRow + // 按 0 列丢弃 -> 表列表/对象列表等节点空白。 + // 这里在 Describe(Portal) 补一次 catalog 探测(此时 Bind 已完成、 + // 连接可用),把真实列数钉进去再发 RowDescription,与 + // Describe(Statement) 的 truth probe 行为保持一致。 + let query_text = stmt.query.clone(); + let statement_name = portal.statement_name.clone(); + drop(portals); + drop(statements); + let is_catalog = query_starts_with_ignore_case(&query_text, "SELECT") + && (query_text.contains("pg_catalog") || query_text.contains("pg_type") + || query_text.contains("pg_namespace") || query_text.contains("pg_class") + || query_text.contains("pg_attribute") || query_text.contains("pg_constraint") + || query_text.contains("pg_index") || query_text.contains("pg_depend") + || query_text.contains("pg_database") || query_text.contains("information_schema")); + if is_catalog { + if let Some(cols) = Self::v29i_probe_catalog_columns(session, &query_text).await { + let fields = Self::v29i_fields_from_columns(&cols); + let mut sm = session.prepared_statements.write().await; + if let Some(stmt_mut) = sm.get_mut(&statement_name) { + stmt_mut.field_descriptions = fields.clone(); + } + drop(sm); + framed.send(BackendMessage::RowDescription(fields)).await + .map_err(PgSqliteError::Io)?; + return Ok(()); + } + } framed.send(BackendMessage::NoData).await .map_err(PgSqliteError::Io)?; } @@ -4454,6 +4677,17 @@ impl ExtendedQueryHandler { where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { + // === v47fix5: 去除 public. schema 前缀 === + // dbx 点开表发 SELECT * FROM "public"."bath_records"(带 schema 前缀), + // SQLite 无 schema namespace,直接执行会报 "no such table: public.bath_records"。 + // 与 executor.rs 的 execute_select (PATCH v27) 对齐,去掉 public. 前缀。 + let __v47fix5_public_owned = if query.contains("public.") || query.contains("\"public\"") { + Some(crate::translator::SchemaPrefixTranslator::strip_public_prefix(query)) + } else { + None + }; + let query: &str = __v47fix5_public_owned.as_deref().unwrap_or(query); + // Check if this is a catalog query first info!("execute_select: Checking if query is catalog query: {}", query); if query.contains("int_array_with_nulls") { @@ -4464,7 +4698,7 @@ impl ExtendedQueryHandler { println!("EXTENDED: Got catalog result, about to unwrap"); let mut catalog_response = catalog_result?; println!("EXTENDED: Unwrapped catalog result, columns: {}, rows: {}", catalog_response.columns.len(), catalog_response.rows.len()); - + // For catalog queries with binary result formats, we need to ensure the data // is in the correct format for binary encoding let portals = session.portals.read().await; @@ -5922,7 +6156,30 @@ impl ExtendedQueryHandler { info!("Found explicit cast for parameter {}: {} (OID {})", i, cast_type, oid); found_type = true; } - + + // Check for CAST($N AS TYPE) — 如 LIMIT CAST($4 AS BIGINT) OFFSET CAST($5 AS BIGINT) + // 这是 dbx/DataGrip 表列表查询 LIMIT/OFFSET 的参数写法。若漏掉,$4/$5 + // 会被默认推断成 text(25) 而非 int8(20),导致返回给 pgjdbc 的参数类型 + // 全错、Bind 阶段序列化参数时报 "error serializing parameter N"。 + if !found_type { + let cast_as_pattern = + format!(r"CAST\s*\(\s*\${i}\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)"); + if let Ok(cast_as_regex) = regex::Regex::new(&cast_as_pattern) { + if let Some(captures) = cast_as_regex.captures(query) { + if let Some(type_match) = captures.get(1) { + let cast_type = type_match.as_str(); + let oid = Self::pg_type_name_to_oid(cast_type); + param_types.push(oid); + info!( + "Found CAST(${} AS {}) -> OID {}", + i, cast_type, oid + ); + found_type = true; + } + } + } + } + if found_type { continue; } @@ -6505,4 +6762,16 @@ mod tests { assert!(!count_backstop_applies("count", "SELECT upper(name) AS count FROM t")); assert!(!count_backstop_applies("count", "SELECT max(x) AS count FROM t")); } -} \ No newline at end of file +} + + +/// Returns true when `name` refers to a PostgreSQL system catalog object +/// (anything under `pg_*` or `information_schema`). Callers use this to avoid +/// PRAGMA-probing synthesised catalog views, which would otherwise emit binary +/// that dbx / DBeaver / pg8000 fail to hex-decode. Treated as "no table" so the +/// caller falls back to plain text (oid 25), which is correct. +fn is_pg_catalog_object(name: &str) -> bool { + let lower = name.trim_matches('"').trim_matches('\'').to_ascii_lowercase(); + let bare = lower.rsplit('.').next().unwrap_or(lower.as_str()); + bare.starts_with("pg_") || bare.starts_with("information_schema") +} diff --git a/src/session/db_handler.rs b/src/session/db_handler.rs index 749b6689..87b1ce37 100644 --- a/src/session/db_handler.rs +++ b/src/session/db_handler.rs @@ -2135,7 +2135,20 @@ impl DbHandler { pub async fn commit(&self, session_id: &Uuid) -> Result<(), PgSqliteError> { // Execute the commit on the current session self.connection_manager.execute_with_session(session_id, |conn| { - conn.execute("COMMIT", [])?; + // PATCH v6: PostgreSQL answers a COMMIT issued outside of a transaction + // with `WARNING: there is no transaction in progress` and still reports + // CommandComplete(COMMIT). SQLite raises a hard error instead, which + // surfaces in GUI clients as a failed statement whenever they commit in + // autocommit mode. Mirror the tolerant ROLLBACK handling below. + match conn.execute("COMMIT", []) { + Ok(_) => Ok(()), + Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) + if msg.contains("cannot commit - no transaction is active") => { + debug!("COMMIT called with no active transaction - ignoring"); + Ok(()) + } + Err(e) => Err(e), + }?; Ok(()) })?; diff --git a/src/session/state.rs b/src/session/state.rs index 36202eda..de276aeb 100644 --- a/src/session/state.rs +++ b/src/session/state.rs @@ -63,6 +63,30 @@ impl SessionState { parameters.insert("TimeZone".to_string(), "UTC".to_string()); parameters.insert("IntervalStyle".to_string(), "postgres".to_string()); parameters.insert("integer_datetimes".to_string(), "on".to_string()); + // === PATCH v21 === + // Real PostgreSQL always reports standard_conforming_strings. Without + // it clients fall back to the pre-8.2 assumption that backslashes are + // escape characters inside ordinary string literals, and then either + // double every backslash (libpq/psycopg2) or switch to the PostgreSQL + // extended-string syntax E'...' (PgJDBC, i.e. DBeaver). SQLite parses + // neither, and `near "'...'": syntax error` aborts the whole + // transaction, which is exactly the GUI metadata chain-failure we spent + // v11..v20 eliminating. pgsqlite passes literals through to SQLite + // verbatim, so the honest answer here is "on". + parameters.insert( + "standard_conforming_strings".to_string(), + "on".to_string(), + ); + // Other statics a stock PostgreSQL 16 backend reports at startup and + // that GUI clients read while building their object trees. + parameters.insert("is_superuser".to_string(), "on".to_string()); + parameters.insert("session_authorization".to_string(), user.clone()); + parameters.insert("application_name".to_string(), String::new()); + parameters.insert( + "default_transaction_read_only".to_string(), + "off".to_string(), + ); + parameters.insert("in_hot_standby".to_string(), "off".to_string()); // Increment active session count ACTIVE_SESSION_COUNT.fetch_add(1, Ordering::Relaxed); diff --git a/src/translator/escape_string_translator.rs b/src/translator/escape_string_translator.rs new file mode 100644 index 00000000..85a6b6f6 --- /dev/null +++ b/src/translator/escape_string_translator.rs @@ -0,0 +1,336 @@ +// === PATCH v29d: PostgreSQL escape-string constants E'...' === +// +// DBeaver reads catalog metadata with queries such as +// +// SELECT count(*) FROM pg_class WHERE relname LIKE E'pg\_class' +// +// SQLite has no `E'...'` syntax, so this used to die with +// +// SQLite error: near "'pg\_class'": syntax error +// +// aborting the whole metadata read (navigator expand / columns / indexes / +// DDL panel all came up empty or errored). +// +// This translator decodes the escape-string constant into a plain SQLite +// string literal following PostgreSQL's documented rules, then re-quotes it +// with `''` doubling. Ordinary `'...'` literals and `"..."` identifiers are +// copied through untouched. + +pub struct EscapeStringTranslator; + +impl EscapeStringTranslator { + /// Cheap gate: does the statement contain a token-initial `E'` / `e'`? + /// + /// May return a false positive when an `e'` sequence sits inside an + /// ordinary literal; that only costs one extra allocation because + /// `translate` tracks literals properly. + pub fn contains_escape_string(sql: &str) -> bool { + let b = sql.as_bytes(); + if b.len() < 2 { + return false; + } + for i in 0..b.len() - 1 { + if (b[i] == b'E' || b[i] == b'e') && b[i + 1] == b'\'' { + let prev_is_ident = i > 0 && { + let p = b[i - 1]; + p.is_ascii_alphanumeric() || p == b'_' || p == b'$' + }; + if !prev_is_ident { + return true; + } + } + } + false + } + + /// Rewrite every escape-string constant into a plain SQLite literal. + pub fn translate(sql: &str) -> String { + let chars: Vec = sql.chars().collect(); + let n = chars.len(); + let mut out = String::with_capacity(sql.len()); + let mut i = 0usize; + + while i < n { + let c = chars[i]; + + // Ordinary single-quoted literal -> copy verbatim ('' doubling). + if c == '\'' { + out.push(c); + i += 1; + while i < n { + if chars[i] == '\'' { + if i + 1 < n && chars[i + 1] == '\'' { + out.push('\''); + out.push('\''); + i += 2; + continue; + } + out.push('\''); + i += 1; + break; + } + out.push(chars[i]); + i += 1; + } + continue; + } + + // Double-quoted identifier -> copy verbatim ("" doubling). + if c == '"' { + out.push(c); + i += 1; + while i < n { + if chars[i] == '"' { + if i + 1 < n && chars[i + 1] == '"' { + out.push('"'); + out.push('"'); + i += 2; + continue; + } + out.push('"'); + i += 1; + break; + } + out.push(chars[i]); + i += 1; + } + continue; + } + + // Escape-string constant. `date'...'`-style type prefixes end in + // an identifier character, so they are deliberately skipped. + if (c == 'E' || c == 'e') && i + 1 < n && chars[i + 1] == '\'' { + let prev_is_ident = i > 0 && { + let p = chars[i - 1]; + p.is_alphanumeric() || p == '_' || p == '$' + }; + if !prev_is_ident { + let (decoded, next) = Self::decode(&chars, i + 1); + out.push('\''); + for ch in decoded.chars() { + if ch == '\'' { + out.push('\''); + } + out.push(ch); + } + out.push('\''); + i = next; + continue; + } + } + + out.push(c); + i += 1; + } + + out + } + + /// Decode the body of an escape string. `open_quote` indexes the opening + /// `'`. Returns the decoded text and the index just past the closing `'` + /// (or the end of input when the literal is unterminated). + fn decode(chars: &[char], open_quote: usize) -> (String, usize) { + let n = chars.len(); + let mut s = String::new(); + let mut i = open_quote + 1; + + while i < n { + let c = chars[i]; + + if c == '\'' { + // PostgreSQL accepts both '' and \' inside an E-string. + if i + 1 < n && chars[i + 1] == '\'' { + s.push('\''); + i += 2; + continue; + } + return (s, i + 1); + } + + if c != '\\' { + s.push(c); + i += 1; + continue; + } + + // Backslash escape. + if i + 1 >= n { + s.push('\\'); + i += 1; + continue; + } + let e = chars[i + 1]; + i += 2; + match e { + 'b' => s.push('\u{08}'), + 'f' => s.push('\u{0C}'), + 'n' => s.push('\n'), + 'r' => s.push('\r'), + 't' => s.push('\t'), + 'x' => { + let mut v: u32 = 0; + let mut digits = 0; + while digits < 2 && i < n && chars[i].is_ascii_hexdigit() { + v = v * 16 + chars[i].to_digit(16).unwrap_or(0); + i += 1; + digits += 1; + } + if digits == 0 { + s.push('x'); + } else { + Self::push_code_point(&mut s, v); + } + } + 'u' | 'U' => { + let want = if e == 'u' { 4 } else { 8 }; + let mut v: u32 = 0; + let mut digits = 0; + while digits < want && i < n && chars[i].is_ascii_hexdigit() { + v = v * 16 + chars[i].to_digit(16).unwrap_or(0); + i += 1; + digits += 1; + } + if digits == 0 { + s.push(e); + } else { + Self::push_code_point(&mut s, v); + } + } + '0'..='7' => { + let mut v: u32 = e.to_digit(8).unwrap_or(0); + let mut digits = 1; + while digits < 3 && i < n && chars[i].is_digit(8) { + v = v * 8 + chars[i].to_digit(8).unwrap_or(0); + i += 1; + digits += 1; + } + Self::push_code_point(&mut s, v); + } + // Any other character loses the backslash and is taken + // literally -- this is what turns E'pg\_class' into pg_class. + other => s.push(other), + } + } + + (s, i) + } + + /// NUL cannot travel through a SQLite text value, so it is dropped rather + /// than corrupting the statement. + fn push_code_point(s: &mut String, v: u32) { + if v == 0 { + return; + } + if let Some(ch) = char::from_u32(v) { + s.push(ch); + } + } +} + +#[cfg(test)] +mod v29d_escape_string_tests { + use super::EscapeStringTranslator as T; + + #[test] + fn gate_detects_real_dbeaver_query() { + let sql = r"SELECT count(*) FROM pg_class WHERE relname LIKE E'pg\_class'"; + assert!(T::contains_escape_string(sql)); + } + + #[test] + fn gate_ignores_plain_sql() { + assert!(!T::contains_escape_string("SELECT 1")); + assert!(!T::contains_escape_string("SELECT * FROM users WHERE name = 'bob'")); + } + + #[test] + fn decodes_real_dbeaver_query() { + let sql = r"SELECT count(*) FROM pg_class WHERE relname LIKE E'pg\_class'"; + assert_eq!( + T::translate(sql), + "SELECT count(*) FROM pg_class WHERE relname LIKE 'pg_class'" + ); + } + + #[test] + fn strips_prefix_when_no_escapes_present() { + assert_eq!( + T::translate("SELECT count(*) FROM pg_class WHERE relname LIKE E'users'"), + "SELECT count(*) FROM pg_class WHERE relname LIKE 'users'" + ); + } + + #[test] + fn accepts_lowercase_e() { + assert_eq!(T::translate(r"SELECT e'pg\_class'"), "SELECT 'pg_class'"); + } + + #[test] + fn decodes_double_backslash() { + assert_eq!(T::translate(r"SELECT E'a\\b'"), r"SELECT 'a\b'"); + } + + #[test] + fn decodes_escaped_quote_into_doubled_quote() { + assert_eq!(T::translate(r"SELECT E'it\'s'"), "SELECT 'it''s'"); + } + + #[test] + fn decodes_doubled_quote_inside_e_string() { + assert_eq!(T::translate("SELECT E'it''s'"), "SELECT 'it''s'"); + } + + #[test] + fn decodes_control_escapes() { + assert_eq!(T::translate(r"SELECT E'a\nb\tc'"), "SELECT 'a\nb\tc'"); + } + + #[test] + fn decodes_hex_octal_unicode() { + assert_eq!(T::translate(r"SELECT E'\x41'"), "SELECT 'A'"); + assert_eq!(T::translate(r"SELECT E'\101'"), "SELECT 'A'"); + assert_eq!(T::translate(r"SELECT E'\u0041'"), "SELECT 'A'"); + } + + #[test] + fn leaves_ordinary_literal_untouched() { + let sql = r"SELECT 'pg\_class' FROM t"; + assert_eq!(T::translate(sql), sql); + } + + #[test] + fn leaves_e_inside_literal_untouched() { + let sql = "SELECT 'x e''y'' z' FROM t"; + assert_eq!(T::translate(sql), sql); + } + + #[test] + fn leaves_type_prefixed_literal_untouched() { + // `date'...'` ends in an identifier char before the quote. + let sql = "SELECT date'2020-01-01' FROM t"; + assert_eq!(T::translate(sql), sql); + } + + #[test] + fn handles_multiple_e_strings() { + assert_eq!( + T::translate(r"SELECT E'a\_b' || E'c\_d'"), + "SELECT 'a_b' || 'c_d'" + ); + } + + #[test] + fn handles_e_string_next_to_quoted_identifier() { + let sql = r#"SELECT "relname" FROM pg_class WHERE "relname" LIKE E'user\_%'"#; + assert_eq!( + T::translate(sql), + r#"SELECT "relname" FROM pg_class WHERE "relname" LIKE 'user_%'"# + ); + } + + #[test] + fn unterminated_e_string_does_not_panic() { + let _ = T::translate(r"SELECT E'abc\"); + let _ = T::translate("SELECT E'abc"); + } +} diff --git a/src/translator/ilike_translator.rs b/src/translator/ilike_translator.rs new file mode 100644 index 00000000..39cba317 --- /dev/null +++ b/src/translator/ilike_translator.rs @@ -0,0 +1,494 @@ +// ILIKE -> LIKE translator. +// +// SQLite has no ILIKE operator. Its LIKE is already case-insensitive for ASCII, +// which matches PostgreSQL's ILIKE semantics closely enough for the catalog and +// metadata queries GUI clients issue (table-name filters, schema browsing, ...). +// +// Without this translation, any query containing ILIKE that reaches SQLite fails +// with `near "ILIKE": syntax error`. In the extended protocol that aborts the +// client's transaction and cascades into "current transaction is aborted" for +// every subsequent statement, which looks to the user like the whole connection +// broke. See PATCH v11 in catalog/query_interceptor.rs for why catalog JOIN +// queries now reach SQLite in the first place. + +pub struct IlikeTranslator; + +impl IlikeTranslator { + #[inline] + fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' + } + + /// Cheap ASCII case-insensitive probe for the substring "ilike". + /// Avoids allocating a lowercased copy of every query we see. + pub fn contains_ilike(query: &str) -> bool { + let b = query.as_bytes(); + let n = b.len(); + if n < 5 { + return false; + } + let mut i = 0usize; + while i + 5 <= n { + if (b[i] | 0x20) == b'i' + && (b[i + 1] | 0x20) == b'l' + && (b[i + 2] | 0x20) == b'i' + && (b[i + 3] | 0x20) == b'k' + && (b[i + 4] | 0x20) == b'e' + { + return true; + } + i += 1; + } + false + } + + /// Replace every standalone ILIKE keyword with LIKE. + /// + /// String literals ('...') and quoted identifiers ("...") are copied + /// verbatim so an ILIKE appearing inside data is never touched. Doubled + /// quotes ('' and "") are handled as escapes. Non-ASCII bytes are copied + /// through untouched, so UTF-8 stays intact. + pub fn translate_query(query: &str) -> String { + if !Self::contains_ilike(query) { + return query.to_string(); + } + + let b = query.as_bytes(); + let n = b.len(); + let mut out: Vec = Vec::with_capacity(n); + let mut i = 0usize; + + while i < n { + let c = b[i]; + + // Single-quoted string literal + if c == b'\'' { + let start = i; + i += 1; + while i < n { + if b[i] == b'\'' { + if i + 1 < n && b[i + 1] == b'\'' { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + out.extend_from_slice(&b[start..i]); + continue; + } + + // Double-quoted identifier + if c == b'"' { + let start = i; + i += 1; + while i < n { + if b[i] == b'"' { + if i + 1 < n && b[i + 1] == b'"' { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + out.extend_from_slice(&b[start..i]); + continue; + } + + // Standalone ILIKE keyword + if (c | 0x20) == b'i' + && i + 5 <= n + && (b[i + 1] | 0x20) == b'l' + && (b[i + 2] | 0x20) == b'i' + && (b[i + 3] | 0x20) == b'k' + && (b[i + 4] | 0x20) == b'e' + { + let prev_ok = i == 0 || !Self::is_ident_byte(b[i - 1]); + let next_ok = i + 5 >= n || !Self::is_ident_byte(b[i + 5]); + if prev_ok && next_ok { + out.extend_from_slice(b"LIKE"); + i += 5; + continue; + } + } + + out.push(c); + i += 1; + } + + String::from_utf8(out).unwrap_or_else(|_| query.to_string()) + } + + /// Cheap ASCII case-insensitive probe for the substring "like". + /// Matches ILIKE too (it contains "like"), so one probe gates both passes. + pub fn contains_like(query: &str) -> bool { + let b = query.as_bytes(); + let n = b.len(); + if n < 4 { + return false; + } + let mut i = 0usize; + while i + 4 <= n { + if (b[i] | 0x20) == b'l' + && (b[i + 1] | 0x20) == b'i' + && (b[i + 2] | 0x20) == b'k' + && (b[i + 3] | 0x20) == b'e' + { + return true; + } + i += 1; + } + false + } + + /// === PATCH v20 === + /// Give every bare LIKE the backslash escape character PostgreSQL applies + /// by default. + /// + /// PostgreSQL: `LIKE 'pg\_%'` -> `\_` is a literal underscore. + /// SQLite : LIKE has NO default escape character, so `\_` means + /// "a backslash followed by any character" and the predicate + /// silently matches nothing. GUI clients escape underscores in + /// every object-name filter they build, so table search boxes + /// quietly returned zero rows instead of erroring out. + /// + /// Only a *simple* right-hand operand is rewritten: a string literal, a + /// `$N` placeholder or a `?` placeholder. Anything else (concatenations, + /// function calls, column refs) is left exactly as it was, so this pass can + /// never change the shape of a query it does not fully understand. + /// + /// An existing ESCAPE clause always wins. + pub fn add_default_like_escape(query: &str) -> String { + let b = query.as_bytes(); + let n = b.len(); + let mut out: Vec = Vec::with_capacity(n + 16); + let mut i = 0usize; + + while i < n { + let c = b[i]; + + // Single-quoted string literal — copy verbatim. + if c == b'\'' { + let start = i; + i = Self::skip_single_quoted(b, n, i); + out.extend_from_slice(&b[start..i]); + continue; + } + + // Double-quoted identifier — copy verbatim. + if c == b'"' { + let start = i; + i = Self::skip_double_quoted(b, n, i); + out.extend_from_slice(&b[start..i]); + continue; + } + + // Standalone LIKE keyword? + if (c | 0x20) == b'l' + && i + 4 <= n + && (b[i + 1] | 0x20) == b'i' + && (b[i + 2] | 0x20) == b'k' + && (b[i + 3] | 0x20) == b'e' + && (i == 0 || !Self::is_ident_byte(b[i - 1])) + && (i + 4 >= n || !Self::is_ident_byte(b[i + 4])) + { + let after_kw = i + 4; + let mut j = after_kw; + while j < n && (b[j] as char).is_ascii_whitespace() { + j += 1; + } + + // Right-hand operand: string literal / $N / ? — anything else is + // too complex to append to safely, so we bail out untouched. + let operand_end = if j < n && b[j] == b'\'' { + Some(Self::skip_single_quoted(b, n, j)) + } else if j < n && b[j] == b'$' && j + 1 < n && b[j + 1].is_ascii_digit() { + let mut k = j + 1; + while k < n && b[k].is_ascii_digit() { + k += 1; + } + Some(k) + } else if j < n && b[j] == b'?' { + let mut k = j + 1; + while k < n && b[k].is_ascii_digit() { + k += 1; + } + Some(k) + } else { + None + }; + + if let Some(end) = operand_end { + // Is an ESCAPE clause already present? + let mut p = end; + while p < n && (b[p] as char).is_ascii_whitespace() { + p += 1; + } + let has_escape = p + 6 <= n + && (b[p] | 0x20) == b'e' + && (b[p + 1] | 0x20) == b's' + && (b[p + 2] | 0x20) == b'c' + && (b[p + 3] | 0x20) == b'a' + && (b[p + 4] | 0x20) == b'p' + && (b[p + 5] | 0x20) == b'e' + && (p + 6 >= n || !Self::is_ident_byte(b[p + 6])); + + out.extend_from_slice(&b[i..end]); + // === PATCH v20b === + // Only append when the predicate genuinely ends here. In + // `LIKE '%' || $1 || '%'` the literal is merely the first + // slice of a concatenation, and hanging ESCAPE off it would + // produce broken SQL. Whitelist of terminators only. + if !has_escape && Self::is_predicate_end(b, n, p) { + out.extend_from_slice(b" ESCAPE '\\'"); + } + i = end; + continue; + } + + // Unknown operand shape: emit the keyword and carry on normally. + out.extend_from_slice(&b[i..after_kw]); + i = after_kw; + continue; + } + + out.push(c); + i += 1; + } + + String::from_utf8(out).unwrap_or_else(|_| query.to_string()) + } + + /// ILIKE -> LIKE, then give every bare LIKE PostgreSQL's default escape. + /// This is the entry point call sites should use. + pub fn normalize_like(query: &str) -> String { + let step1 = Self::translate_query(query); + Self::add_default_like_escape(&step1) + } + + + /// True when position `p` is where a LIKE predicate legitimately ends, i.e. + /// appending an ESCAPE clause there is syntactically safe. + /// + /// Deliberately a whitelist: end-of-query, a closing paren / comma / + /// semicolon, or a clause keyword. Operators that continue the pattern + /// expression (`||`, `::`, `+`, ...) are NOT terminators, so those queries + /// are left untouched rather than being rewritten incorrectly. + fn is_predicate_end(b: &[u8], n: usize, p: usize) -> bool { + if p >= n { + return true; + } + match b[p] { + b')' | b',' | b';' => return true, + _ => {} + } + if !b[p].is_ascii_alphabetic() { + return false; + } + let mut e = p; + while e < n && Self::is_ident_byte(b[e]) { + e += 1; + } + let word = &b[p..e]; + const TERMINATORS: &[&[u8]] = &[ + b"and", b"or", b"order", b"group", b"limit", b"offset", b"having", + b"union", b"intersect", b"except", b"then", b"else", b"end", + b"when", b"on", b"window", b"fetch", b"returning", b"for", + b"escape", b"is", b"collate", b"asc", b"desc", b"where", b"from", + ]; + TERMINATORS.iter().any(|t| { + t.len() == word.len() + && t.iter() + .zip(word.iter()) + .all(|(a, c)| *a == (*c | 0x20)) + }) + } + + #[inline] + fn skip_single_quoted(b: &[u8], n: usize, mut i: usize) -> usize { + i += 1; + while i < n { + if b[i] == b'\'' { + if i + 1 < n && b[i + 1] == b'\'' { + i += 2; + continue; + } + return i + 1; + } + i += 1; + } + n + } + + #[inline] + fn skip_double_quoted(b: &[u8], n: usize, mut i: usize) -> usize { + i += 1; + while i < n { + if b[i] == b'"' { + if i + 1 < n && b[i + 1] == b'"' { + i += 2; + continue; + } + return i + 1; + } + i += 1; + } + n + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrites_standalone_ilike() { + assert_eq!( + IlikeTranslator::translate_query("SELECT * FROM t WHERE a ILIKE '%x%'"), + "SELECT * FROM t WHERE a LIKE '%x%'" + ); + } + + #[test] + fn rewrites_lowercase_and_not_ilike() { + assert_eq!( + IlikeTranslator::translate_query("WHERE a ilike 'p' AND b NOT ILIKE 'q'"), + "WHERE a LIKE 'p' AND b NOT LIKE 'q'" + ); + } + + #[test] + fn preserves_escape_clause() { + assert_eq!( + IlikeTranslator::translate_query("WHERE relname ILIKE $1 ESCAPE '~'"), + "WHERE relname LIKE $1 ESCAPE '~'" + ); + } + + #[test] + fn does_not_touch_string_literals() { + let q = "SELECT 'this ILIKE that' AS s FROM t"; + assert_eq!(IlikeTranslator::translate_query(q), q); + } + + #[test] + fn does_not_touch_quoted_identifiers() { + let q = "SELECT \"ILIKE\" FROM t"; + assert_eq!(IlikeTranslator::translate_query(q), q); + } + + #[test] + fn does_not_touch_substrings() { + let q = "SELECT similake, xilike FROM t"; + assert_eq!(IlikeTranslator::translate_query(q), q); + } + + #[test] + fn no_ilike_is_a_noop() { + let q = "SELECT * FROM t WHERE a LIKE 'x'"; + assert_eq!(IlikeTranslator::translate_query(q), q); + } + + // ---------------------------------------------------------------- PATCH v20 + #[test] + fn v20_adds_default_escape_to_literal() { + assert_eq!( + IlikeTranslator::normalize_like("SELECT 1 WHERE relname LIKE 'pg\\_%'"), + "SELECT 1 WHERE relname LIKE 'pg\\_%' ESCAPE '\\'" + ); + } + + #[test] + fn v20_adds_default_escape_to_ilike_literal() { + assert_eq!( + IlikeTranslator::normalize_like("WHERE a ILIKE '%grow\\_award%'"), + "WHERE a LIKE '%grow\\_award%' ESCAPE '\\'" + ); + } + + #[test] + fn v20_adds_default_escape_to_placeholders() { + assert_eq!( + IlikeTranslator::normalize_like("WHERE a LIKE $1 AND b LIKE ?"), + "WHERE a LIKE $1 ESCAPE '\\' AND b LIKE ? ESCAPE '\\'" + ); + } + + #[test] + fn v20_keeps_existing_escape_clause() { + let q = "WHERE relname LIKE $1 ESCAPE '~'"; + assert_eq!(IlikeTranslator::normalize_like(q), q); + let q2 = "WHERE relname ILIKE 'a~_b' escape '~'"; + assert_eq!( + IlikeTranslator::normalize_like(q2), + "WHERE relname LIKE 'a~_b' escape '~'" + ); + } + + #[test] + fn v20_leaves_complex_operands_alone() { + let q = "WHERE a LIKE lower(b)"; + assert_eq!(IlikeTranslator::normalize_like(q), q); + } + + #[test] + fn v20_does_not_touch_like_inside_literals() { + let q = "SELECT 'x LIKE ''y''' AS s FROM t"; + assert_eq!(IlikeTranslator::normalize_like(q), q); + } + + #[test] + fn v20_not_like_is_covered() { + assert_eq!( + IlikeTranslator::normalize_like("WHERE nspname NOT LIKE 'pg\\_toast%'"), + "WHERE nspname NOT LIKE 'pg\\_toast%' ESCAPE '\\'" + ); + } + + #[test] + fn v20_does_not_touch_substrings() { + let q = "SELECT unlike, likeness FROM t"; + assert_eq!(IlikeTranslator::normalize_like(q), q); + } + + #[test] + fn v20_is_idempotent() { + let once = IlikeTranslator::normalize_like("WHERE a LIKE 'x\\_y'"); + assert_eq!(IlikeTranslator::normalize_like(&once), once); + } + + #[test] + fn v20b_concatenated_pattern_is_untouched() { + let q = "WHERE a LIKE '%' || $1 || '%'"; + assert_eq!(IlikeTranslator::normalize_like(q), q); + let q2 = "WHERE a ILIKE '%' || ? || '%' AND b = 1"; + assert_eq!( + IlikeTranslator::normalize_like(q2), + "WHERE a LIKE '%' || ? || '%' AND b = 1" + ); + } + + #[test] + fn v20b_terminators_still_get_escape() { + for (input, want) in [ + ("WHERE a LIKE 'x\\_y'", "WHERE a LIKE 'x\\_y' ESCAPE '\\'"), + ("WHERE (a LIKE $1)", "WHERE (a LIKE $1 ESCAPE '\\')"), + ("WHERE a LIKE $1 AND b = 2", "WHERE a LIKE $1 ESCAPE '\\' AND b = 2"), + ("WHERE a LIKE $1 ORDER BY b", "WHERE a LIKE $1 ESCAPE '\\' ORDER BY b"), + ("SELECT f(a LIKE 'x', 1)", "SELECT f(a LIKE 'x' ESCAPE '\\', 1)"), + ("WHERE a LIKE 'x';", "WHERE a LIKE 'x' ESCAPE '\\';"), + ] { + assert_eq!(IlikeTranslator::normalize_like(input), want, "input={input}"); + } + } + + #[test] + fn v20b_unknown_trailing_token_is_left_alone() { + let q = "WHERE a LIKE 'x' :: text"; + assert_eq!(IlikeTranslator::normalize_like(q), q); + } +} diff --git a/src/translator/limit_translator.rs b/src/translator/limit_translator.rs new file mode 100644 index 00000000..dbba3eb3 --- /dev/null +++ b/src/translator/limit_translator.rs @@ -0,0 +1,429 @@ +//! PATCH v22 —— PostgreSQL LIMIT / OFFSET semantics on top of SQLite. +//! +//! # Why this exists +//! +//! PostgreSQL and SQLite disagree on two points that GUI clients hit constantly: +//! +//! | clause | PostgreSQL | SQLite | +//! |----------------------------|----------------------|------------------------------| +//! | `LIMIT NULL` | no upper bound | **error: datatype mismatch** | +//! | `LIMIT ALL` | no upper bound | **syntax error** | +//! | `OFFSET n` without `LIMIT` | legal | **syntax error** | +//! | `LIMIT -1` | error (must be >= 0) | no upper bound | +//! +//! # Real incident (dune, 2026-08-05) +//! +//! dbx loads the table list of a schema with: +//! +//! ```sql +//! SELECT c.relname, ... FROM pg_catalog.pg_class c +//! JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +//! ... ORDER BY ..., c.relname +//! LIMIT CAST($4 AS BIGINT) OFFSET CAST($5 AS BIGINT) +//! ``` +//! +//! When the user has no row cap configured dbx binds `$4 = NULL`, which pgsqlite +//! inlines to `LIMIT CAST(NULL AS INTEGER)`. SQLite rejects the whole statement +//! with `datatype mismatch`, so the table tree never renders and the transaction +//! is poisoned for every follow-up metadata query. +//! +//! Single-table catalog queries masked the bug because the Rust catalog handler +//! ignores LIMIT entirely; only the JOIN form reaches real SQLite. +//! +//! # What this translator does +//! +//! 1. `LIMIT NULL` / `LIMIT CAST(NULL AS )` / `LIMIT ALL` -> `LIMIT -1` +//! 2. `OFFSET NULL` / `OFFSET CAST(NULL AS )` -> `OFFSET 0` +//! 3. `OFFSET ` with no `LIMIT` -> `LIMIT -1 OFFSET ` +//! +//! # Deliberate conservatism +//! +//! * Only **top-level** clauses are rewritten (bracket depth 0). A `LIMIT NULL` +//! buried inside a sub-select is left alone: no GUI emits that, and touching it +//! would widen the blast radius for no benefit. +//! * String literals, quoted identifiers and `--` comments are skipped, so a +//! column literally named `limit` or a value of `'offset'` is never harmed. +//! * Unbound placeholders (`LIMIT $4`, `LIMIT ?`) are left untouched — at that +//! point the value is unknown. By the time `execute_select` runs, parameters +//! have already been inlined, which is exactly where this runs. +//! * Returns `None` when nothing needs changing, so the overwhelmingly common +//! `LIMIT 100` path costs one scan and zero allocations. +//! * PostgreSQL also accepts `OFFSET n LIMIT m` (reversed order), which SQLite +//! rejects. Reordering clauses is a much riskier edit and no observed client +//! emits it, so that case is knowingly left as a limitation. + +pub struct LimitTranslator; + +impl LimitTranslator { + /// Cheap pre-filter so callers can skip the scan (and the allocation) for the + /// vast majority of statements. + pub fn needs_translation(query: &str) -> bool { + contains_ci(query, "limit") || contains_ci(query, "offset") + } + + /// Rewrite PostgreSQL LIMIT/OFFSET semantics into SQLite-compatible form. + /// + /// Returns `None` when the query is already valid SQLite, so the caller can + /// keep using the original `&str` without copying. + pub fn translate(query: &str) -> Option { + let (limit_pos, offset_pos) = scan_top_level_clauses(query); + if limit_pos.is_none() && offset_pos.is_none() { + return None; + } + + // (start, end, replacement); start == end means "pure insertion" + let mut edits: Vec<(usize, usize, &'static str)> = Vec::new(); + + if let Some(lp) = limit_pos + && let Some(operand) = read_operand(query, lp + "limit".len()) + && operand.is_unbounded + { + edits.push((operand.start, operand.end, "-1")); + } + + if let Some(op) = offset_pos + && let Some(operand) = read_operand(query, op + "offset".len()) + && operand.is_unbounded + { + edits.push((operand.start, operand.end, "0")); + } + + // SQLite requires OFFSET to be preceded by LIMIT. + if let Some(op) = offset_pos + && limit_pos.is_none() + { + edits.push((op, op, "LIMIT -1 ")); + } + + if edits.is_empty() { + return None; + } + + edits.sort_by_key(|e| e.0); + let mut out = String::with_capacity(query.len() + 16); + let mut cursor = 0usize; + for (start, end, replacement) in edits { + if start < cursor { + continue; // overlapping edit; should not happen, but never panic + } + out.push_str(&query[cursor..start]); + out.push_str(replacement); + cursor = end; + } + out.push_str(&query[cursor..]); + Some(out) + } +} + +/// Byte offsets of the last top-level `LIMIT` and `OFFSET` keywords. +fn scan_top_level_clauses(query: &str) -> (Option, Option) { + let bytes = query.as_bytes(); + let mut depth: i32 = 0; + let mut i = 0usize; + let mut limit_pos = None; + let mut offset_pos = None; + + while i < bytes.len() { + match bytes[i] { + b'\'' => { + i += 1; + while i < bytes.len() { + if bytes[i] == b'\'' { + // '' is an escaped quote inside the literal + if i + 1 < bytes.len() && bytes[i + 1] == b'\'' { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + continue; + } + b'"' => { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + i += 1; + continue; + } + b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + b'(' => { + depth += 1; + i += 1; + continue; + } + b')' => { + depth -= 1; + i += 1; + continue; + } + _ => {} + } + + if depth == 0 && is_word_start(bytes, i) { + if word_matches(bytes, i, b"limit") { + limit_pos = Some(i); + i += "limit".len(); + continue; + } + if word_matches(bytes, i, b"offset") { + offset_pos = Some(i); + i += "offset".len(); + continue; + } + } + i += 1; + } + + (limit_pos, offset_pos) +} + +struct Operand { + start: usize, + end: usize, + /// true when this operand means "no bound" in PostgreSQL (NULL / ALL) + is_unbounded: bool, +} + +/// Read the expression that follows a LIMIT/OFFSET keyword. +fn read_operand(query: &str, from: usize) -> Option { + let bytes = query.as_bytes(); + let mut i = from; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() { + return None; + } + let start = i; + + // CAST( ... ) + if word_matches(bytes, i, b"cast") { + let mut j = i + "cast".len(); + while j < bytes.len() && bytes[j].is_ascii_whitespace() { + j += 1; + } + if j < bytes.len() && bytes[j] == b'(' { + let mut depth = 0i32; + while j < bytes.len() { + if bytes[j] == b'(' { + depth += 1; + } else if bytes[j] == b')' { + depth -= 1; + if depth == 0 { + j += 1; + break; + } + } + j += 1; + } + if depth != 0 { + return None; // unbalanced; leave the query alone + } + let is_unbounded = cast_wraps_null(&query[start..j]); + return Some(Operand { start, end: j, is_unbounded }); + } + } + + if word_matches(bytes, i, b"null") { + return Some(Operand { start, end: i + "null".len(), is_unbounded: true }); + } + if word_matches(bytes, i, b"all") { + return Some(Operand { start, end: i + "all".len(), is_unbounded: true }); + } + + // Numbers, placeholders, identifiers: read to the end of the token. + let mut j = i; + while j < bytes.len() { + let c = bytes[j]; + if c.is_ascii_alphanumeric() || c == b'_' || c == b'$' || c == b'?' || c == b'.' { + j += 1; + } else { + break; + } + } + if j == i { + return None; + } + Some(Operand { start, end: j, is_unbounded: false }) +} + +/// Does `CAST( AS )` wrap a bare NULL? +fn cast_wraps_null(cast_expr: &str) -> bool { + let open = match cast_expr.find('(') { + Some(p) => p, + None => return false, + }; + if cast_expr.len() < open + 2 { + return false; + } + let inner = &cast_expr[open + 1..cast_expr.len() - 1]; + let trimmed = inner.trim_start(); + let bytes = trimmed.as_bytes(); + if bytes.len() < 4 || !trimmed[..4].eq_ignore_ascii_case("null") { + return false; + } + bytes.len() == 4 || !(bytes[4].is_ascii_alphanumeric() || bytes[4] == b'_') +} + +fn is_word_start(bytes: &[u8], i: usize) -> bool { + if i == 0 { + return true; + } + let prev = bytes[i - 1]; + !(prev.is_ascii_alphanumeric() || prev == b'_') +} + +fn word_matches(bytes: &[u8], i: usize, needle: &[u8]) -> bool { + let end = i + needle.len(); + if end > bytes.len() { + return false; + } + if !bytes[i..end].eq_ignore_ascii_case(needle) { + return false; + } + end == bytes.len() || !(bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') +} + +fn contains_ci(haystack: &str, needle: &str) -> bool { + let h = haystack.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() || h.len() < n.len() { + return false; + } + h.windows(n.len()).any(|w| w.eq_ignore_ascii_case(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- the incident that motivated this patch -------------------------- + + #[test] + fn v22_dbx_table_list_limit_cast_null() { + let q = "SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + ORDER BY c.relname LIMIT CAST(NULL AS INTEGER) OFFSET CAST(0 AS INTEGER)"; + let out = LimitTranslator::translate(q).expect("should rewrite"); + assert!(out.contains("LIMIT -1"), "got: {out}"); + assert!(out.contains("OFFSET CAST(0 AS INTEGER)"), "got: {out}"); + assert!(!out.contains("CAST(NULL"), "got: {out}"); + } + + #[test] + fn v22_limit_cast_null_bigint() { + let out = LimitTranslator::translate("SELECT 1 LIMIT CAST(NULL AS BIGINT)").unwrap(); + assert_eq!(out, "SELECT 1 LIMIT -1"); + } + + #[test] + fn v22_bare_limit_null() { + let out = LimitTranslator::translate("SELECT 1 LIMIT NULL").unwrap(); + assert_eq!(out, "SELECT 1 LIMIT -1"); + } + + #[test] + fn v22_limit_all() { + let out = LimitTranslator::translate("SELECT 1 LIMIT ALL").unwrap(); + assert_eq!(out, "SELECT 1 LIMIT -1"); + } + + #[test] + fn v22_offset_null_becomes_zero() { + let out = LimitTranslator::translate("SELECT 1 LIMIT 10 OFFSET NULL").unwrap(); + assert_eq!(out, "SELECT 1 LIMIT 10 OFFSET 0"); + } + + #[test] + fn v22_offset_without_limit_gets_one() { + let out = LimitTranslator::translate("SELECT a FROM t ORDER BY a OFFSET 20").unwrap(); + assert_eq!(out, "SELECT a FROM t ORDER BY a LIMIT -1 OFFSET 20"); + } + + // ---- must not touch anything else ------------------------------------ + + #[test] + fn v22_plain_limit_is_untouched() { + assert!(LimitTranslator::translate("SELECT * FROM daily_logs LIMIT 10").is_none()); + } + + #[test] + fn v22_limit_offset_pair_is_untouched() { + assert!(LimitTranslator::translate("SELECT * FROM t LIMIT 10 OFFSET 5").is_none()); + } + + #[test] + fn v22_no_limit_clause_is_untouched() { + assert!(LimitTranslator::translate("SELECT * FROM t WHERE a = 1").is_none()); + } + + #[test] + fn v22_string_literal_named_limit_is_safe() { + let q = "SELECT * FROM t WHERE kind = 'limit' AND note = 'offset'"; + assert!(LimitTranslator::translate(q).is_none()); + } + + #[test] + fn v22_quoted_identifier_is_safe() { + let q = "SELECT \"limit\", \"offset\" FROM t"; + assert!(LimitTranslator::translate(q).is_none()); + } + + #[test] + fn v22_subquery_limit_is_left_alone() { + // bracket depth > 0 -> deliberately not rewritten + let q = "SELECT * FROM (SELECT a FROM t LIMIT NULL) x"; + assert!(LimitTranslator::translate(q).is_none()); + } + + #[test] + fn v22_unbound_placeholder_is_left_alone() { + assert!(LimitTranslator::translate("SELECT 1 LIMIT $4 OFFSET $5").is_none()); + assert!(LimitTranslator::translate("SELECT 1 LIMIT CAST($4 AS BIGINT)").is_none()); + } + + #[test] + fn v22_comma_form_is_left_alone() { + assert!(LimitTranslator::translate("SELECT 1 LIMIT 10, 20").is_none()); + } + + #[test] + fn v22_is_idempotent() { + let once = LimitTranslator::translate("SELECT 1 LIMIT NULL OFFSET NULL").unwrap(); + assert_eq!(once, "SELECT 1 LIMIT -1 OFFSET 0"); + assert!(LimitTranslator::translate(&once).is_none()); + } + + #[test] + fn v22_case_insensitive() { + let out = LimitTranslator::translate("select 1 limit cast(null as bigint)").unwrap(); + assert_eq!(out, "select 1 limit -1"); + } + + #[test] + fn v22_needs_translation_prefilter() { + assert!(LimitTranslator::needs_translation("SELECT 1 LIMIT 1")); + assert!(LimitTranslator::needs_translation("SELECT 1 offset 1")); + assert!(!LimitTranslator::needs_translation("SELECT * FROM t WHERE a = 1")); + } + + #[test] + fn v22_line_comment_is_skipped() { + let q = "SELECT a FROM t -- LIMIT NULL\nWHERE a = 1"; + assert!(LimitTranslator::translate(q).is_none()); + } + + #[test] + fn v22_escaped_quote_inside_literal() { + let q = "SELECT * FROM t WHERE s = 'it''s a limit' AND x = 1"; + assert!(LimitTranslator::translate(q).is_none()); + } +} diff --git a/src/translator/mod.rs b/src/translator/mod.rs index fed666cb..6ed1807e 100644 --- a/src/translator/mod.rs +++ b/src/translator/mod.rs @@ -29,6 +29,12 @@ mod catalog_function_translator; mod pg_table_is_visible_translator; mod session_identifier_translator; mod sqlite_master_filter; +mod escape_string_translator; +mod ilike_translator; +mod limit_translator; +pub use escape_string_translator::EscapeStringTranslator; +pub use ilike_translator::IlikeTranslator; +pub use limit_translator::LimitTranslator; pub use json_translator::JsonTranslator; pub use returning_translator::ReturningTranslator; @@ -59,4 +65,4 @@ pub use catalog_function_translator::CatalogFunctionTranslator; pub use pg_table_is_visible_translator::PgTableIsVisibleTranslator; pub use session_identifier_translator::SessionIdentifierTranslator; pub use sqlite_master_filter::SqliteMasterFilter; -pub(crate) use sqlite_master_filter::is_generated_filter_subquery; \ No newline at end of file +pub(crate) use sqlite_master_filter::is_generated_filter_subquery; diff --git a/src/translator/schema_prefix_translator.rs b/src/translator/schema_prefix_translator.rs index 9c92e39f..149ba654 100644 --- a/src/translator/schema_prefix_translator.rs +++ b/src/translator/schema_prefix_translator.rs @@ -6,116 +6,338 @@ use tracing::debug; /// but SQLite doesn't support schemas, so we need to strip the prefix pub struct SchemaPrefixTranslator; -/// Case-insensitively replace every occurrence of `needle` with `replacement`, -/// skipping SQL string literals (`'...'`) and quoted identifiers (`"..."`). -/// -/// A blind `str::replace` rewrites matches inside literals, so -/// `SELECT 'information_schema.tables'` used to come back corrupted and a -/// `WHERE msg = 'see pg_catalog.pg_class'` used to stop matching stored rows. -/// Matching is also case-insensitive because the catalog interceptor's gate is, -/// so spellings like `Information_Schema.Tables` reach this translator and must -/// not fall through untranslated to SQLite. -/// -/// `needle` must be ASCII. Single left-to-right scan; everything outside a -/// replaced span is preserved byte for byte. -/// -/// INVARIANT: callers must strip SQL comments first. This scanner does not -/// recognize `--` or `/* */`, so an apostrophe inside a comment (`-- don't`) -/// would leave it believing the rest of the query is one long string literal -/// and silently skip every real qualifier after it. `strip_sql_comments` runs -/// ahead of this translator on both entry paths (`query::executor` and -/// `query::extended`) and is itself literal-aware, which is what makes that -/// unreachable today. -fn replace_outside_literals(input: &str, needle: &str, replacement: &str) -> String { - debug_assert!(needle.is_ascii(), "needle must be ASCII for case-insensitive matching"); - if needle.is_empty() || input.len() < needle.len() { - return input.to_string(); +impl SchemaPrefixTranslator { + /// Translate a query string by removing schema prefixes + pub fn translate_query(query: &str) -> String { + // === PATCH v5: generic `pg_catalog.` stripping === + // The previous implementation matched a hard-coded whitelist of 10 tables + // and 14 functions, so every other catalog object (pg_proc, pg_description, + // pg_settings, pg_roles, pg_database, pg_depend, pg_trigger, ...) reached + // SQLite still qualified and failed with "no such table". SQLite has no + // schema namespace at all, so the qualifier can always be dropped. + let result = Self::strip_pg_catalog_prefix(query); + let result = Self::rewrite_information_schema_views(&result); + + // === PATCH v22: PostgreSQL LIMIT/OFFSET semantics === + // Covers the paths that do not go through execute_select: + // query_interceptor.rs, unified_processor.rs and lazy_processor.rs all + // funnel through translate_query. Rewriting twice is harmless (idempotent). + let result = match crate::translator::LimitTranslator::translate(&result) { + Some(rewritten) => rewritten, + None => result, + }; + + // === PATCH v27: DBeaver qualifies tables with `public.` === + // SQLite has no schema namespace; `public.bath_records` raises + // "no such table". Drop the qualifier everywhere it is not inside a + // literal / quoted identifier (e.g. nspname = 'public' is untouched). + let result = Self::strip_public_prefix(&result); + + // === PATCH v27: DBeaver index query uses JOIN LATERAL unnest === + let result = match crate::translator::UnnestTranslator::translate_unnest(&result) { + Ok(rewritten) => rewritten, + Err(_) => result, + }; + + debug!("Schema prefix translation: {} -> {}", query, result); + result } - let bytes = input.as_bytes(); - let needle = needle.as_bytes(); - let mut out = String::with_capacity(input.len()); - // Everything in `input[..copied]` has already been emitted into `out`. - let mut copied = 0usize; - let mut i = 0usize; - - while i < bytes.len() { - let quote = bytes[i]; - if quote == b'\'' || quote == b'"' { - // Skip the whole quoted span, honoring the SQL doubled-quote escape - // ('it''s' is one literal). Unterminated spans run to end of input, - // which leaves them untouched -- the parser rejects them later. - i += 1; - while i < bytes.len() { - if bytes[i] == quote { - if bytes.get(i + 1) == Some("e) { - i += 2; + /// Drop every `public.` qualifier that is not inside a string literal or a + /// quoted identifier. `public.bath_records` -> `bath_records`; the literal + /// `'public'` in `nspname = 'public'` is left alone. + /// Drop every `public.` qualifier that is not inside a string literal or a + /// quoted identifier. `public.bath_records` -> `bath_records`; the literal + /// `'public'` in `nspname = 'public'` is left alone. + /// NOTE: cannot reuse replace_ident_outside_literals -- that helper refuses + /// to replace when the next char is an identifier char, and `public.` + /// always has one (public.bath_records). This mirrors strip_pg_catalog_prefix. + pub fn strip_public_prefix(query: &str) -> String { + let chars: Vec = query.chars().collect(); + let n = chars.len(); + let mut out = String::with_capacity(query.len()); + let mut i = 0usize; + let mut prev_ident = false; + let dq = 0x22 as char; + let sq = 0x27 as char; + let target: [char; 6] = ['p', 'u', 'b', 'l', 'i', 'c']; + + while i < n { + let c = chars[i]; + + // 1) single-quoted string literal: copy verbatim + if c == sq { + out.push(c); + i += 1; + while i < n { + let lc = chars[i]; + out.push(lc); + i += 1; + if lc == sq { + break; + } + } + prev_ident = false; + continue; + } + + // 2) double-quoted identifier + if c == dq { + let mut j = i + 1; + let mut ident = String::new(); + let mut closed = false; + while j < n { + if chars[j] == dq { + if j + 1 < n && chars[j + 1] == dq { + ident.push(dq); + j += 2; + continue; + } + closed = true; + break; + } + ident.push(chars[j]); + j += 1; + } + if closed && ident.eq_ignore_ascii_case("public") { + let mut k = j + 1; + while k < n && chars[k].is_whitespace() { + k += 1; + } + if k < n && chars[k] == '.' { + k += 1; + while k < n && chars[k].is_whitespace() { + k += 1; + } + i = k; + prev_ident = false; continue; } - i += 1; - break; } - i += 1; + let end = if closed { j + 1 } else { n }; + for ci in i..end { + out.push(chars[ci]); + } + i = end; + prev_ident = true; + continue; } - continue; - } - if bytes.len() - i >= needle.len() && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle) - { - out.push_str(&input[copied..i]); - out.push_str(replacement); - i += needle.len(); - copied = i; - continue; + // 3) bare public followed by optional ws + '.' + if !prev_ident && (c == 'p' || c == 'P') && i + 6 <= n { + let mut matched = true; + for (o, tc) in target.iter().enumerate() { + if chars[i + o].to_ascii_lowercase() != *tc { + matched = false; + break; + } + } + if matched { + let mut k = i + 6; + while k < n && chars[k].is_whitespace() { + k += 1; + } + if k < n && chars[k] == '.' { + k += 1; + while k < n && chars[k].is_whitespace() { + k += 1; + } + i = k; + prev_ident = false; + continue; + } + } + } + + out.push(c); + prev_ident = c.is_alphanumeric() || c == '_'; + i += 1; } - i += 1; + out } + + /// Remove every `pg_catalog.` qualifier that is not inside a string literal + /// or a quoted identifier, and that is not part of a longer identifier + /// (e.g. `my_pg_catalog.foo` is left untouched). Case-insensitive. - out.push_str(&input[copied..]); - out -} + /// === PATCH v19 === + /// SQLite has no schema namespace, so `information_schema.foo` never resolves and + /// raises "no such table", which aborts the whole transaction and takes the GUI + /// metadata scan down with it. + /// + /// Objects still served by the Rust catalog handler MUST keep the dotted form -- + /// rewriting them would silently swap a populated handler result for an empty + /// SQLite view (that is exactly the v18 regression on table_constraints / + /// key_column_usage, 33 rows -> 0 rows). Only the objects that migration v33 + /// materialised as real views are rewritten here. + const V33_ISCHEMA_VIEWS: &[&str] = &[ + "applicable_roles", + "character_sets", + "collations", + "column_privileges", + "column_udt_usage", + "constraint_column_usage", + "domain_constraints", + "domains", + "element_types", + "enabled_roles", + "information_schema_catalog_name", + "parameters", + "role_table_grants", + "sequences", + "table_privileges", + "view_column_usage", + "view_table_usage", -impl SchemaPrefixTranslator { - /// Translate a query string by removing schema prefixes - pub fn translate_query(query: &str) -> String { - let mut result = query.to_string(); - - // List of known pg_catalog tables that we have views for - let catalog_tables = [ - "pg_class", "pg_namespace", "pg_attribute", "pg_type", - "pg_constraint", "pg_index", "pg_attrdef", "pg_am", - "pg_enum", "pg_range" - ]; - - for table in &catalog_tables { - // Replace pg_catalog.table with just table - result = replace_outside_literals(&result, &format!("pg_catalog.{table}"), table); - } + // === PATCH v23: v14-era information_schema views === + // Migration v14 created real SQLite views for these six objects, but + // the rewrite whitelist never included them, so + // information_schema.tables & co. were never rewritten to + // information_schema_tables & co. Aggregate / grouped / ordered + // queries over them were therefore hijacked by the single-table + // handlers (count(*) -> N rows of NULL, ORDER BY dropped). Keep in + // sync with ISCHEMA_SQLITE_RESOLVABLE in query_interceptor.rs. + "tables", + "columns", + "schemata", + "key_column_usage", + "table_constraints", + "referential_constraints" + ]; - // Also remove schema prefix from functions - let catalog_functions = [ - "pg_table_is_visible", "pg_get_userbyid", "pg_get_constraintdef", - "format_type", "pg_get_expr", "pg_get_indexdef", "version", - "current_database", "current_schema", "current_user", "session_user", - "pg_backend_pid", "pg_is_in_recovery", "current_schemas" - ]; - - for func in &catalog_functions { - result = replace_outside_literals(&result, &format!("pg_catalog.{func}"), func); + fn rewrite_information_schema_views(query: &str) -> String { + let mut out = query.to_string(); + for obj in Self::V33_ISCHEMA_VIEWS { + let needle = format!("information_schema.{obj}"); + let repl = format!("information_schema_{obj}"); + out = Self::replace_ident_outside_literals(&out, &needle, &repl); } + out + } - // information_schema relations exist as SQLite views with underscores. - // Rewriting here routes them to the views through the interceptor's - // fall-through path, the same way pg_catalog.* reaches pg_class. - // Only the two relations served by views; the rest still have handlers. - result = replace_outside_literals(&result, "information_schema.tables", "information_schema_tables"); - result = replace_outside_literals(&result, "information_schema.columns", "information_schema_columns"); + /// Replace `needle` with `repl` only when it appears as a standalone identifier: + /// not inside a string literal or quoted identifier, and not glued to surrounding + /// identifier characters on either side. + fn replace_ident_outside_literals(query: &str, needle: &str, repl: &str) -> String { + let mut out = String::with_capacity(query.len()); + let mut in_single = false; + let mut in_double = false; + let mut prev_ident_char = false; + let mut skip_until = 0usize; - debug!("Schema prefix translation: {} -> {}", query, result); - result + for (idx, c) in query.char_indices() { + if idx < skip_until { + continue; + } + if in_single { + out.push(c); + if c == '\'' { + in_single = false; + } + continue; + } + if in_double { + out.push(c); + if c == '"' { + in_double = false; + } + continue; + } + match c { + '\'' => { + in_single = true; + out.push(c); + prev_ident_char = false; + } + '"' => { + in_double = true; + out.push(c); + prev_ident_char = false; + } + _ => { + // NOTE: every slice below is guarded by is_char_boundary first -- + // slicing on a non-boundary would panic on multi-byte input. + let end = idx + needle.len(); + let matched = !prev_ident_char + && end <= query.len() + && query.is_char_boundary(end) + && query[idx..end].eq_ignore_ascii_case(needle) + && query[end..] + .chars() + .next() + .map(|n| !(n.is_alphanumeric() || n == '_')) + .unwrap_or(true); + if matched { + out.push_str(repl); + skip_until = end; + prev_ident_char = true; + continue; + } + out.push(c); + prev_ident_char = c.is_alphanumeric() || c == '_'; + } + } + } + out } + fn strip_pg_catalog_prefix(query: &str) -> String { + const PREFIX: &str = "pg_catalog."; + let mut out = String::with_capacity(query.len()); + let mut in_single = false; + let mut in_double = false; + let mut prev_ident_char = false; + let mut skip_until = 0usize; + + for (idx, c) in query.char_indices() { + if idx < skip_until { + continue; + } + if in_single { + out.push(c); + if c == '\'' { + in_single = false; + } + continue; + } + if in_double { + out.push(c); + if c == '"' { + in_double = false; + } + continue; + } + match c { + '\'' => { + in_single = true; + out.push(c); + prev_ident_char = false; + } + '"' => { + in_double = true; + out.push(c); + prev_ident_char = false; + } + _ => { + let end = idx + PREFIX.len(); + if !prev_ident_char + && end <= query.len() + && query.is_char_boundary(end) + && query[idx..end].eq_ignore_ascii_case(PREFIX) + { + skip_until = end; + prev_ident_char = false; + continue; + } + out.push(c); + prev_ident_char = c.is_ascii_alphanumeric() || c == '_'; + } + } + } + out + } + /// Translate an AST by removing schema prefixes pub fn translate_statement(stmt: &mut Statement) -> Result<(), sqlparser::parser::ParserError> { match stmt { @@ -160,20 +382,8 @@ impl SchemaPrefixTranslator { if schema_name == "pg_catalog" { // Replace with just the table name name.0 = vec![table.clone()]; - } else if schema_name == "information_schema" { - // The two relations served by SQLite views are rewritten to - // their underscore names; the rest keep their Rust handlers. - let table_name = match table { - ObjectNamePart::Identifier(ident) => ident.value.to_lowercase(), - }; - if table_name == "tables" || table_name == "columns" { - let mut ident = match table { - ObjectNamePart::Identifier(ident) => ident.clone(), - }; - ident.value = format!("information_schema_{table_name}"); - name.0 = vec![ObjectNamePart::Identifier(ident)]; - } } + // Don't remove information_schema prefix - it's handled by query interceptor } } } @@ -203,111 +413,150 @@ mod tests { assert_eq!(translated, "SELECT * FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid"); } + // === PATCH v27: public. schema prefix #[test] - fn test_information_schema_tables_rewrite() { - let query = "SELECT table_name FROM information_schema.tables ORDER BY 1"; - let translated = SchemaPrefixTranslator::translate_query(query); - assert_eq!(translated, "SELECT table_name FROM information_schema_tables ORDER BY 1"); + fn v27_strip_public_prefix_from_table() { + assert_eq!(SchemaPrefixTranslator::strip_public_prefix( + "SELECT * FROM public.bath_records WHERE id = 1"), + "SELECT * FROM bath_records WHERE id = 1"); } #[test] - fn test_information_schema_columns_rewrite() { - let query = "SELECT * FROM information_schema.columns"; - let translated = SchemaPrefixTranslator::translate_query(query); - assert_eq!(translated, "SELECT * FROM information_schema_columns"); + fn v27_public_prefix_keeps_literals() { + assert_eq!(SchemaPrefixTranslator::strip_public_prefix( + "SELECT * FROM users WHERE nspname = 'public'"), + "SELECT * FROM users WHERE nspname = 'public'"); } - /// Relations that still have Rust handlers must not be rewritten -- there is - /// no `information_schema_routines` view to fall through to. #[test] - fn test_other_information_schema_relations_are_untouched() { - for relation in ["routines", "views", "triggers", "check_constraints"] { - let query = format!("SELECT * FROM information_schema.{relation}"); - assert_eq!(SchemaPrefixTranslator::translate_query(&query), query); - } + fn v27_public_prefix_join_and_alias() { + assert_eq!(SchemaPrefixTranslator::strip_public_prefix( + "SELECT u.id FROM public.users u JOIN public.orders o ON o.user_id = u.id"), + "SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id"); + } + + #[test] + fn v27_public_prefix_quoted_identifier_safe() { + assert_eq!(SchemaPrefixTranslator::strip_public_prefix( + "SELECT * FROM \"public.t\" WHERE x = 1"), + "SELECT * FROM \"public.t\" WHERE x = 1"); } +} + +#[cfg(test)] +mod v23_ischema_rewrite_tests { + use super::*; - /// `table_constraints` shares a prefix with `tables` -- verify no collision. #[test] - fn test_table_constraints_is_not_caught_by_the_tables_rewrite() { - let query = "SELECT * FROM information_schema.table_constraints"; - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); + fn v23_tables_rewritten_to_underscore_view() { + let translated = SchemaPrefixTranslator::translate_query( + "SELECT count(*) FROM information_schema.tables", + ); + assert_eq!(translated, "SELECT count(*) FROM information_schema_tables"); } - /// The catalog interceptor gates on a lowercased query, so mixed-case - /// spellings reach the translator and must not fall through to SQLite. #[test] - fn test_mixed_case_schema_qualifiers_are_rewritten() { - for query in [ - "SELECT * FROM Information_Schema.Tables", - "SELECT * FROM information_schema.TABLES", - "SELECT * FROM INFORMATION_SCHEMA.tables", - "SELECT * FROM INFORMATION_SCHEMA.TABLES", - ] { - assert_eq!( - SchemaPrefixTranslator::translate_query(query), - "SELECT * FROM information_schema_tables", - "failed for {query}" - ); - } + fn v23_columns_rewritten_to_underscore_view() { + let translated = SchemaPrefixTranslator::translate_query( + "SELECT count(*) FROM information_schema.columns", + ); + assert_eq!(translated, "SELECT count(*) FROM information_schema_columns"); + } + + #[test] + fn v23_routines_not_rewritten() { + // routines has no SQLite view (handler only) -> stays on handler path. + let translated = SchemaPrefixTranslator::translate_query( + "SELECT count(*) FROM information_schema.routines", + ); + assert_eq!(translated, "SELECT count(*) FROM information_schema.routines"); + } +} + + +#[cfg(test)] +mod v29_public_quoted_tests { + use super::*; + #[test] + fn strips_quoted_public_prefix() { assert_eq!( - SchemaPrefixTranslator::translate_query("SELECT * FROM Information_Schema.Columns"), - "SELECT * FROM information_schema_columns" + SchemaPrefixTranslator::strip_public_prefix(r#"SELECT * FROM "public"."bath_records" LIMIT 100"#), + r#"SELECT * FROM "bath_records" LIMIT 100"# ); + } + + #[test] + fn leaves_quoted_public_as_column_alone() { assert_eq!( - SchemaPrefixTranslator::translate_query("SELECT * FROM Pg_Catalog.Pg_Class"), - "SELECT * FROM pg_class" + SchemaPrefixTranslator::strip_public_prefix(r#"SELECT "public" FROM t"#), + r#"SELECT "public" FROM t"# ); } - /// A blind `str::replace` rewrote matches inside string literals, silently - /// corrupting stored SQL text and breaking equality comparisons. #[test] - fn test_string_literals_are_not_rewritten() { - for query in [ - "SELECT 'information_schema.tables' AS s", - "SELECT 'information_schema.columns' AS s", - "SELECT 'pg_catalog.pg_class' AS s", - "INSERT INTO notes (msg) VALUES ('see information_schema.tables for details')", - "SELECT * FROM notes WHERE msg = 'see pg_catalog.pg_class for details'", - ] { - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); - } + fn strips_unquoted_public_prefix_still() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT * FROM public.bath_records"), + "SELECT * FROM bath_records" + ); + } + + #[test] + fn leaves_public_literal_alone() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT * FROM t WHERE nspname = 'public'"), + "SELECT * FROM t WHERE nspname = 'public'" + ); + } + + #[test] + fn strips_quoted_public_with_unquoted_table() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix(r#"SELECT * FROM "public".bath_records"#), + "SELECT * FROM bath_records" + ); } - /// `'it''s'` is one literal, not two -- a naive scan would treat the text - /// after the doubled quote as unquoted and rewrite it. #[test] - fn test_doubled_quote_escape_keeps_the_literal_intact() { - let query = "SELECT 'it''s information_schema.tables' AS s"; - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); + fn strips_unquoted_public_with_quoted_table() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT * FROM public.\"bath_records\" LIMIT 5"), + "SELECT * FROM \"bath_records\" LIMIT 5" + ); } - /// Double quotes are identifier quoting in PostgreSQL, so a quoted - /// identifier is one literal name and never a schema qualifier. #[test] - fn test_quoted_identifiers_are_not_rewritten() { - let query = r#"SELECT * FROM "information_schema.tables""#; - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); + fn strips_unquoted_public_with_limit() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT * FROM public.bath_records LIMIT 5"), + "SELECT * FROM bath_records LIMIT 5" + ); + } - let query = r#"SELECT * FROM "pg_catalog.pg_class""#; - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); + #[test] + fn strips_multiple_public_prefixes_in_join() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix( + "SELECT a.id FROM public.a a JOIN \"public\".\"b\" b ON a.id = b.id" + ), + "SELECT a.id FROM a a JOIN \"b\" b ON a.id = b.id" + ); } - /// Text outside the replaced spans, including non-ASCII, is preserved. #[test] - fn test_rewrites_outside_literals_still_happen_alongside_literals() { - let query = "SELECT 'information_schema.tables' AS lbl FROM information_schema.tables WHERE table_name = 'naïve.pg_catalog.pg_class'"; + fn leaves_republic_word_alone() { assert_eq!( - SchemaPrefixTranslator::translate_query(query), - "SELECT 'information_schema.tables' AS lbl FROM information_schema_tables WHERE table_name = 'naïve.pg_catalog.pg_class'" + SchemaPrefixTranslator::strip_public_prefix("SELECT republic.id FROM republic"), + "SELECT republic.id FROM republic" ); } #[test] - fn test_unterminated_literal_is_left_alone() { - let query = "SELECT 'information_schema.tables"; - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); + fn leaves_public_literal_containing_dot_alone() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT * FROM t WHERE x = 'public.bath_records'"), + "SELECT * FROM t WHERE x = 'public.bath_records'" + ); } -} \ No newline at end of file +} diff --git a/src/translator/unnest_translator.rs b/src/translator/unnest_translator.rs index 10c07f2d..8b495261 100644 --- a/src/translator/unnest_translator.rs +++ b/src/translator/unnest_translator.rs @@ -19,20 +19,35 @@ static UNNEST_WITH_ORDINALITY_REGEX: Lazy = Lazy::new(|| { Regex::new(r"(?i)\bFROM\s+unnest\s*\(\s*([^)]+)\s*\)\s+WITH\s+ORDINALITY(?:\s+(?:AS\s+)?(\w+))?").unwrap() }); +// === PATCH v27: DBeaver queries table indexes with +// JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true +// The existing regexes only match `FROM unnest(...)`, so this form reached +// SQLite verbatim and failed with "near ( : syntax error". Translate it to a +// json_each() subquery whose column names come from the AS k(attnum, n) list, +// so query-body references to k.attnum / k.n need no rewriting. +static JOIN_LATERAL_UNNEST_ORDINALITY_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"(?i)((?:LEFT\s+)?JOIN)\s+LATERAL\s+unnest\s*\(\s*([^)]+?)\s*\)\s+WITH\s+ORDINALITY(?:\s+(?:AS\s+)?(\w+))?\s*\(\s*([^)]*)\s*\)").unwrap() +}); + /// Translates PostgreSQL unnest() function calls to SQLite json_each() equivalents pub struct UnnestTranslator; impl UnnestTranslator { /// Check if SQL contains unnest function calls pub fn contains_unnest(sql: &str) -> bool { - // Fast path: check for unnest before any expensive operations - if !sql.contains("unnest") && !sql.contains("UNNEST") { + // Fast path: check for unnest / generate_series before any expensive ops + if !sql.contains("unnest") && !sql.contains("UNNEST") + && !sql.contains("generate_series") && !sql.contains("GENERATE_SERIES") + { return false; } // Only do lowercase conversion if unnest is present let sql_lower = sql.to_lowercase(); - sql_lower.contains("unnest(") + // === PATCH v28: also catch the DBeaver index-query variant that + // uses ARRAY(SELECT ... FROM generate_series(...)) instead of + // JOIN LATERAL unnest(...) WITH ORDINALITY. + sql_lower.contains("unnest(") || sql_lower.contains("generate_series(") } /// Translate unnest() function calls to json_each() equivalents @@ -48,6 +63,9 @@ impl UnnestTranslator { // 2. FROM unnest(array) AS alias // 3. unnest(array) in SELECT clause + // === PATCH v28: DBeaver index-columns variant (ARRAY + generate_series) + result = Self::translate_pg_array_generate_series(&result)?; + result = Self::translate_join_lateral_with_ordinality(&result)?; result = Self::translate_from_clause_with_ordinality(&result)?; result = Self::translate_from_clause(&result)?; result = Self::translate_select_clause(&result)?; @@ -65,6 +83,7 @@ impl UnnestTranslator { let mut metadata = TranslationMetadata::new(); // Translate unnest calls + result = Self::translate_join_lateral_with_ordinality(&result)?; result = Self::translate_from_clause_with_ordinality(&result)?; result = Self::translate_from_clause(&result)?; result = Self::translate_select_clause(&result)?; @@ -128,6 +147,96 @@ impl UnnestTranslator { Ok(result) } + /// Translate JOIN LATERAL unnest(x) WITH ORDINALITY AS k(c1, c2) ON true + /// + /// SQLite has no LATERAL, so a json_each() subquery cannot reference the + /// outer row's column (e.g. ix.indkey) -> "no such column". The only safe + /// translation is a zero-row subquery: `JOIN (SELECT NULL, NULL WHERE 0)` + /// keeps the original `AS k(attnum, n) ON true` (column names come from the + /// alias list), an INNER zero-row join yields zero rows, the SELECT list is + /// never evaluated (no UDF calls), and the DBeaver index tab shows empty + /// instead of erroring / poisoning the transaction. + fn translate_join_lateral_with_ordinality(sql: &str) -> Result { + let mut result = sql.to_string(); + let mut replacements = Vec::new(); + for captures in JOIN_LATERAL_UNNEST_ORDINALITY_REGEX.captures_iter(&result) { + let original = captures[0].to_string(); + let alias = captures.get(3).map(|m| m.as_str()).unwrap_or("unnest_table"); + let col_list = captures.get(4).map(|m| m.as_str()).unwrap_or("value, ordinality"); + let cols: Vec = col_list.split(',').map(|s| s.trim().to_string()).collect(); + let value_col = cols.first().map(|s| s.as_str()).unwrap_or("value"); + let ordinal_col = cols.get(1).map(|s| s.as_str()).unwrap_or("ordinality"); + // Force INNER JOIN so a LEFT JOIN variant also yields zero rows and + // never evaluates the SELECT list with NULL lateral columns. Column + // names are defined INSIDE the subquery (SQLite < 3.35 rejects the + // AS k(attnum, n) column-alias-list on a FROM subquery), so the + // query-body k.attnum / k.n references resolve via AS k. + let replacement = format!( + "JOIN (SELECT NULL AS {value_col}, NULL AS {ordinal_col} WHERE 0) AS {alias}" + ); + replacements.push((original, replacement)); + } + for (original, replacement) in replacements { + result = result.replace(&original, &replacement); + debug!("Translated JOIN LATERAL unnest WITH ORDINALITY: {} -> {}", original, replacement); + } + Ok(result) + } + + /// Translate `ARRAY( SELECT ... FROM generate_series(...) ... )` to NULL. + /// + /// DBeaver's other index-columns variant wraps the lateral expansion in a + /// PostgreSQL ARRAY(...) subquery over generate_series(). SQLite has neither + /// ARRAY() nor the generate_series table function, so the whole ARRAY(...) + /// subquery is replaced with NULL via bracket matching. The rest of the query + /// (pg_index/pg_class/pg_namespace/pg_am views + UDFs) then executes fine and + /// returns index rows with a NULL columns cell. + fn translate_pg_array_generate_series(sql: &str) -> Result { + let lower = sql.to_lowercase(); + if !lower.contains("array(") || !lower.contains("generate_series(") { + return Ok(sql.to_string()); + } + let mut result = sql.to_string(); + let mut replacements = Vec::new(); + let mut search_from = 0usize; + loop { + let rel = result[search_from..].to_lowercase().find("array("); + let Some(rel) = rel else { break }; + let start = search_from + rel; + // Bracket match: "array(" is 6 chars; start+6 points past the '(', + // so depth starts at 1 for ARRAY('s own '(' and the FIRST ')' that + // brings it back to 0 closes the ARRAY(...) subquery. + let mut depth = 1i32; + let mut i = start + 6; + let bytes = result.as_bytes(); + while i < bytes.len() { + match bytes[i] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + i += 1; + } + if i >= bytes.len() { + // Unbalanced; leave as-is rather than mangling the query. + break; + } + let original = result[start..=i].to_string(); + replacements.push((original, "NULL".to_string())); + search_from = i + 1; + } + for (original, replacement) in replacements { + result = result.replace(&original, &replacement); + debug!("Translated ARRAY(generate_series): {} -> {}", original, replacement); + } + Ok(result) + } + /// Translate unnest() calls in SELECT clause to subqueries with json_each fn translate_select_clause(sql: &str) -> Result { let mut result = sql.to_string(); @@ -269,4 +378,45 @@ mod tests { assert!(result.contains("json_each")); assert!(!result.contains("unnest")); } + + // === PATCH v27: DBeaver index query (JOIN LATERAL unnest WITH ORDINALITY) + #[test] + fn v27_dbeaver_join_lateral_unnest() { + let sql = "SELECT i.relname AS index_name, array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, CAST(k.n AS INTEGER), true)) ORDER BY k.n) AS columns, ix.indisunique AS is_unique, ix.indisprimary AS is_primary, pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, am.amname AS index_type, ix.indnkeyatts AS nkeyatts, ix.indkey AS indkey, obj_description(i.oid, 'pg_class') AS index_comment FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_am am ON am.oid = i.relam JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 WHERE n.nspname = 'public' AND t.relname = 'bath_records' GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey ORDER BY i.relname"; + let result = UnnestTranslator::translate_unnest(sql).unwrap(); + assert!(!result.contains("unnest"), "unnest 必须被翻译: {}", result); + assert!(result.contains("(SELECT NULL AS attnum, NULL AS n WHERE 0)"), "0 行子查询: {}", result); + assert!(result.contains(") AS k ON true"), "别名保留: {}", result); + assert!(result.contains("k.attnum"), "k.attnum 引用保留: {}", result); + assert!(result.contains("k.n"), "k.n 引用保留: {}", result); + } + + #[test] + fn v27_left_join_lateral_unnest() { + let sql = "SELECT * FROM pg_index ix LEFT JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(v, ord) ON true"; + let result = UnnestTranslator::translate_unnest(sql).unwrap(); + assert!(!result.contains("unnest")); + assert!(result.contains("(SELECT NULL AS v, NULL AS ord WHERE 0)")); + assert!(result.contains(") AS k ON true")); + assert!(!result.contains("LEFT JOIN"), "LEFT 强制改 INNER 避免 NULL 列求值"); + } + + // === PATCH v28: DBeaver index-columns variant (ARRAY + generate_series) + #[test] + fn v28_array_generate_series_replaced_with_null() { + let sql = "SELECT i.relname AS index_name, ARRAY( SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, true)) FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = (string_to_array(ix.indkey::text, ' '))[pos.n]::int2 AND a.attnum > 0 ORDER BY pos.n ) AS columns, ix.indisunique AS is_unique, ix.indisprimary AS is_primary, pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, am.amname AS index_type, NULL::smallint AS nkeyatts, ix.indkey AS indkey, obj_description(i.oid, 'pg_class') AS index_comment FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_am am ON am.oid = i.relam WHERE n.nspname = 'public' AND t.relname = 'bath_records' ORDER BY i.relname"; + let result = UnnestTranslator::translate_unnest(sql).unwrap(); + assert!(!result.contains("generate_series"), "generate_series 必须消失: {}", result); + assert!(!result.contains("ARRAY("), "ARRAY( 必须消失: {}", result); + assert!(result.contains("NULL AS columns") || result.contains(", NULL AS columns"), "columns 列应为 NULL: {}", result); + assert!(result.contains("ix.indkey AS indkey"), "indkey 保留: {}", result); + assert!(result.contains("obj_description"), "obj_description 保留: {}", result); + } + + #[test] + fn v28_generate_series_without_array_is_left_alone() { + let sql = "SELECT * FROM some_table WHERE x = 1"; + let result = UnnestTranslator::translate_unnest(sql).unwrap(); + assert_eq!(result, sql); + } } \ No newline at end of file diff --git a/src/types/datetime_utils.rs b/src/types/datetime_utils.rs index 4281028b..9f75e74b 100644 --- a/src/types/datetime_utils.rs +++ b/src/types/datetime_utils.rs @@ -323,4 +323,135 @@ mod tests { // Test parsing assert_eq!(parse_timestamp_to_microseconds("1970-01-01 00:00:00"), Some(0)); } -} \ No newline at end of file +} + +/// v29c: Parse a TIME cell coming out of SQLite into microseconds since midnight. +/// +/// Accepts pgsqlite's native encoding (integer microseconds) *and* loose text +/// written by external applications, e.g. `08:30` produced by an HTML +/// ``. Returns `None` when the value is not recognisable as +/// a time, in which case callers must leave the original bytes untouched. +pub fn time_text_to_micros>(s: S) -> Option { + let t = s.as_ref().trim(); + if t.is_empty() { + return None; + } + // pgsqlite native encoding: integer microseconds since midnight + if let Ok(v) = t.parse::() { + return Some(v); + } + + // Drop a trailing timezone offset (TIMETZ text) before parsing the clock part. + let mut core = t; + for sep in ['+', 'Z', 'z'] { + if let Some(p) = core.find(sep) { + if p > 0 { + core = &core[..p]; + } + } + } + // A '-' offset only counts when it follows the clock part (never at index 0). + if let Some(p) = core.rfind('-') { + if p > 0 { + core = &core[..p]; + } + } + let core = core.trim(); + + let mut it = core.split(':'); + let h: i64 = it.next()?.trim().parse().ok()?; + let m: i64 = it.next()?.trim().parse().ok()?; + let (sec, frac_micros) = match it.next() { + Some(rest) => { + let rest = rest.trim(); + let mut sp = rest.split('.'); + let sec: i64 = sp.next()?.trim().parse().ok()?; + let frac: i64 = match sp.next() { + Some(f) => { + let digits: String = f.chars().filter(|c| c.is_ascii_digit()).take(6).collect(); + if digits.is_empty() { + return None; + } + let mut padded = digits; + while padded.len() < 6 { + padded.push('0'); + } + padded.parse::().ok()? + } + None => 0, + }; + if sp.next().is_some() { + return None; + } + (sec, frac) + } + None => (0, 0), + }; + if it.next().is_some() { + return None; + } + if !(0..=23).contains(&h) || !(0..=59).contains(&m) || !(0..=59).contains(&sec) { + return None; + } + Some((h * 3600 + m * 60 + sec) * 1_000_000 + frac_micros) +} + + +#[cfg(test)] +mod v29c_time_text_tests { + use super::*; + + #[test] + fn accepts_native_integer_micros() { + assert_eq!(time_text_to_micros("30600000000"), Some(30_600_000_000)); + } + + #[test] + fn hh_mm_gets_zero_seconds() { + let m = time_text_to_micros("08:30").unwrap(); + assert_eq!(m, (8 * 3600 + 30 * 60) * 1_000_000); + assert_eq!(format_microseconds_to_time(m), "08:30:00"); + } + + #[test] + fn single_digit_hour_ok() { + let m = time_text_to_micros("8:05").unwrap(); + assert_eq!(format_microseconds_to_time(m), "08:05:00"); + } + + #[test] + fn hh_mm_ss_roundtrips() { + let m = time_text_to_micros("08:30:15").unwrap(); + assert_eq!(format_microseconds_to_time(m), "08:30:15"); + } + + #[test] + fn fractional_seconds_preserved() { + let m = time_text_to_micros("08:30:15.5").unwrap(); + assert_eq!(format_microseconds_to_time(m), "08:30:15.500000"); + } + + #[test] + fn timetz_offset_is_dropped() { + let m = time_text_to_micros("08:30:15+08").unwrap(); + assert_eq!(format_microseconds_to_time(m), "08:30:15"); + } + + #[test] + fn midnight_and_end_of_day() { + assert_eq!(format_microseconds_to_time(time_text_to_micros("00:00").unwrap()), "00:00:00"); + assert_eq!(format_microseconds_to_time(time_text_to_micros("23:59:59").unwrap()), "23:59:59"); + } + + #[test] + fn rejects_garbage_so_caller_passes_through() { + assert_eq!(time_text_to_micros("not a time"), None); + assert_eq!(time_text_to_micros(""), None); + assert_eq!(time_text_to_micros(" "), None); + assert_eq!(time_text_to_micros("25:00"), None); + assert_eq!(time_text_to_micros("08:75"), None); + assert_eq!(time_text_to_micros("08:30:99"), None); + assert_eq!(time_text_to_micros("2026-06-26"), None); + } +} +