From 156a93be4ca7ddc3b56d51d403936d1086d17539 Mon Sep 17 00:00:00 2001 From: pgsqlite-local Date: Wed, 12 Aug 2026 15:23:06 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix(catalog):=20=E4=BF=AE=E5=A4=8D=20dbx/?= =?UTF-8?q?DBeaver=20=E5=AD=97=E6=AE=B5=E5=88=97=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E5=88=97=E8=A1=A8=E7=A9=BA=E7=99=BD=20(v40+v?= =?UTF-8?q?41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 字段列表(01_columns): 新增 handle_dbeaver_columns_query_if_match, 从 attrelid=quote_ident(sch)||\x27.\x27||quote_ident(tbl) 提取表名, 用 PRAGMA table_info 生成 11 列; 原逻辑落到 verbatim SQLite 执行, pg_attribute.attrelid 是数字 OID 串与 sch.tbl 永远不匹配 -> 0 行 - 索引列表(02_indexes): 新增 v40_get_index_list + v40_extract_index_table, 指纹 pg_index&&array_agg&&!_pg_expandarray, 用 PRAGMA index_list/index_info 生成 9 列; v38 getIndexInfo 要求 _pg_expandarray 不命中此形态 - table=None 时返回 9 列空结构, 供 extended Describe 阶段宣告列数(v29i truth probe 不触发 realigning 短路), 提取到字面量表名时返回真实数据 - 部署验证: simple + extended(参数化 =public =users) 双路径均返回 1 行 9 列, Describe==Execute 列数一致 --- src/catalog/constraint_populator.rs | 79 +- src/catalog/pg_settings.rs | 13 + src/catalog/query_interceptor.rs | 1523 +++++++++++++++++++- src/catalog/system_functions.rs | 10 +- src/config.rs | 3 + src/functions/system_functions.rs | 621 +++++++- src/main.rs | 151 +- src/protocol/codec.rs | 9 + src/query/executor.rs | 100 +- src/query/extended.rs | 1462 +++++++++++++++++-- src/session/db_handler.rs | 15 +- src/session/state.rs | 24 + src/translator/escape_string_translator.rs | 336 +++++ src/translator/ilike_translator.rs | 494 +++++++ src/translator/limit_translator.rs | 429 ++++++ src/translator/mod.rs | 2 +- src/translator/schema_prefix_translator.rs | 2 +- src/translator/unnest_translator.rs | 156 +- src/types/datetime_utils.rs | 133 +- 19 files changed, 5391 insertions(+), 171 deletions(-) create mode 100644 src/translator/escape_string_translator.rs create mode 100644 src/translator/ilike_translator.rs create mode 100644 src/translator/limit_translator.rs 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..14df114b 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -23,7 +23,1503 @@ 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 }) + } + 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"), + } + } + fn v23_tables_count_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&parse_select( + "SELECT count(*) FROM information_schema.tables" + ))); + } + fn v23_columns_count_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&parse_select( + "SELECT count(*) FROM information_schema.columns" + ))); + } + fn v23_schemata_group_by_delegates_to_sqlite() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&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(&parse_select( + "SELECT count(*) FROM information_schema.key_column_usage" + ))); + } + fn v23_handler_only_routines_stays_on_handler() { + assert!(!CatalogInterceptor::from_is_sqlite_resolvable(&parse_select( + "SELECT count(*) FROM information_schema.routines" + ))); + } + fn v23_pg_catalog_still_delegates() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&parse_select( + "SELECT count(*) FROM pg_catalog.pg_class" + ))); + } + fn v23_bare_table_name_still_delegates() { + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&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,6 +1542,31 @@ impl CatalogInterceptor { lower_query.trim() == "select version()" { return None; } +if let Some(r) = Self::v38_jdbc_metadata(query, &db).await { + println!("INTERCEPT: v38 jdbc-metadata handled"); + return Some(r); + } +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); + } +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); + } +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); + } +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); + } +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); + } + // Check for catalog tables let has_catalog_tables = lower_query.contains("pg_catalog") || lower_query.contains("pg_type") || 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..b57d5b1f 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -1381,7 +1381,27 @@ impl ExtendedQueryHandler { false }; - if query_starts_with_ignore_case(&query, "SELECT") && + // === PATCH v29j: catalog queries must never take a fast path === + // The fast paths below answer straight from SQLite, where pg_class / + // pg_attribute / pg_type exist only as reduced legacy VIEWs (25 / 22 / 8 + // columns). Describe, and the simple-query protocol, are answered by + // CatalogInterceptor (33 / 26 / 7 columns). Letting Execute pick the + // other engine desynchronises the wire: the client is told 33 fields and + // then handed 25-wide DataRows, which tokio-postgres reports as + // 'unexpected message from server'. Force every catalog query down the + // execute_select path so Describe and Execute share one engine. + let __v29j_catalog_fastpath = std::env::var("PGSQLITE_V29J_CATALOG_FASTPATH") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let __v29j_is_catalog = !__v29j_catalog_fastpath + && (CatalogInterceptor::is_catalog_query(&query) + || CatalogInterceptor::is_catalog_query(effective_query)); + if __v29j_is_catalog { + info!("v29j: catalog query -> fast paths disabled, routing through CatalogInterceptor: {}", query); + } + + if !__v29j_is_catalog + && query_starts_with_ignore_case(&query, "SELECT") && !query.contains("JOIN") && !query.contains("GROUP BY") && !query.contains("HAVING") && @@ -1694,7 +1714,7 @@ impl ExtendedQueryHandler { } // Try optimized extended fast path first for parameterized queries - if !bound_values.is_empty() && effective_query.contains('$') { + if !__v29j_is_catalog && !bound_values.is_empty() && effective_query.contains('$') { let query_type = super::extended_fast_path::QueryType::from_query(effective_query); // Early check: Skip fast path for SELECT with binary results @@ -1748,7 +1768,11 @@ impl ExtendedQueryHandler { } // Try existing fast path as second option - if let Some(fast_query) = crate::query::can_use_fast_path_enhanced(&query) { + if let Some(fast_query) = if __v29j_is_catalog { + None + } else { + crate::query::can_use_fast_path_enhanced(&query) + } { // Only use fast path for queries that actually have parameters in the extended protocol if !bound_values.is_empty() && query.contains('$') && let Ok(Some(result)) = Self::try_execute_fast_path_with_params( @@ -1912,7 +1936,9 @@ impl ExtendedQueryHandler { } // Execute based on query type - if query_starts_with_ignore_case(&final_query, "SELECT") { + // === PATCH v29m: route on "does it return rows", not on the + // literal SELECT prefix. WITH / VALUES / TABLE return rows too. + if Self::v29m_is_query_route(&final_query) { Self::execute_select(framed, db, session, &portal, &final_query, max_rows).await?; } else if query_starts_with_ignore_case(&final_query, "INSERT") || query_starts_with_ignore_case(&final_query, "UPDATE") @@ -1966,98 +1992,13 @@ impl ExtendedQueryHandler { if typ == b'S' { // Describe statement - 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") || - query.contains("pg_namespace") || query.contains("pg_class") || - 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()); - - // Fix field types for catalog queries before sending RowDescription - let mut corrected_fields = stmt.field_descriptions.clone(); - if is_catalog_query || query.contains("pg_attribute") || query.contains("a.attnotnull") || query.contains("a.atthasdef") { - for fd in &mut corrected_fields { - let col_lower = fd.name.to_lowercase(); - match col_lower.as_str() { - // Direct pg_attribute boolean columns - "attnotnull" | "atthasdef" | "attbyval" | "atthasmissing" | "attisdropped" | "attislocal" | - // Common aliases for these columns in JOIN queries - "not_null" | "has_default" | "is_not_null" | "has_def" => { - info!("Correcting field '{}' from type_oid {} to Bool type_oid {}", fd.name, fd.type_oid, PgType::Bool.to_oid()); - fd.type_oid = PgType::Bool.to_oid(); - } - "attidentity" | "attgenerated" | "attalign" | "attstorage" | "attcompression" => { - info!("Correcting field '{}' from type_oid {} to Char type_oid {}", fd.name, fd.type_oid, PgType::Char.to_oid()); - fd.type_oid = PgType::Char.to_oid(); - } - _ => {} - } - } - } - - for (i, fd) in corrected_fields.iter().enumerate() { - info!("Field {}: name='{}', type_oid={}, table_oid={}", i, fd.name, fd.type_oid, fd.table_oid); - } - framed.send(BackendMessage::RowDescription(corrected_fields)).await + // Send ParameterDescription first (read param types under lock) + { + let st = session.prepared_statements.read().await; + let pstmt = st.get(&name).ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {name}")))?; + framed.send(BackendMessage::ParameterDescription(pstmt.param_types.clone())).await .map_err(PgSqliteError::Io)?; - } else if is_catalog_query && query_starts_with_ignore_case(query, "SELECT") { - // For catalog SELECT queries, we need to provide field descriptions - // even though we skipped them during Parse - info!("Catalog query detected in Describe, generating field descriptions for: {}", query); - - // Parse the query to extract the selected columns (keep JSON path placeholders for now) - let field_descriptions = if let Ok(parsed) = sqlparser::parser::Parser::parse_sql( - &sqlparser::dialect::PostgreSqlDialect {}, - query - ) { - if let Some(sqlparser::ast::Statement::Query(query_stmt)) = parsed.first() { - if let sqlparser::ast::SetExpr::Select(select) = &*query_stmt.body { - let mut fields = Vec::new(); - - // Check if it's SELECT * - let is_select_star = select.projection.len() == 1 && - matches!(&select.projection[0], sqlparser::ast::SelectItem::Wildcard(_)); - - if is_select_star { - // For SELECT *, we need to determine which catalog table is being queried - // and return all its columns - if query.contains("pg_database") { - info!("DESCRIBE: Generating field descriptions for pg_database SELECT *"); - println!("DEBUG: pg_database field descriptions being generated"); - // Return all pg_database columns - let all_columns = vec![ - ("oid", PgType::Int4.to_oid()), - ("datname", PgType::Text.to_oid()), - ("datdba", PgType::Int4.to_oid()), - ("encoding", PgType::Int4.to_oid()), - ("datlocprovider", PgType::Text.to_oid()), - ("datistemplate", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' - ("datallowconn", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' - ("dathasloginevt", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' - ("datconnlimit", PgType::Int4.to_oid()), - ("datfrozenxid", PgType::Text.to_oid()), - ("datminmxid", PgType::Text.to_oid()), - ("dattablespace", PgType::Int4.to_oid()), - ("datcollate", PgType::Text.to_oid()), - ("datctype", PgType::Text.to_oid()), - ("datlocale", PgType::Text.to_oid()), - ("daticurules", PgType::Text.to_oid()), - ("datcollversion", PgType::Text.to_oid()), - ("datacl", PgType::Text.to_oid()), - ]; + } for (i, (name, oid)) in all_columns.into_iter().enumerate() { if i == 5 { @@ -2505,6 +2446,7 @@ impl ExtendedQueryHandler { framed.send(BackendMessage::NoData).await .map_err(PgSqliteError::Io)?; } + } else { // Describe portal let portals = session.portals.read().await; @@ -2582,14 +2524,1092 @@ impl ExtendedQueryHandler { framed.send(BackendMessage::RowDescription(fields)).await .map_err(PgSqliteError::Io)?; } else { - framed.send(BackendMessage::NoData).await - .map_err(PgSqliteError::Io)?; + // v29g: catalog SELECT without pre-computed fields -> generate them + // so Describe(portal) returns RowDescription instead of NoData + // (NoData + DataRow on Execute makes pgjdbc throw "Received + // resultset tuples, but no field structure for them" -> DBeaver + // "unexpected message from server"). + // NB: release the portal/statement read locks BEFORE calling + // describe_statement_fields -- it takes the write lock to update + // stmt.field_descriptions, and holding the read lock here would + // deadlock (read lock waits for write lock, write lock waits for us). + let portal_stmt_name = portal.statement_name.clone(); + drop(portals); + drop(statements); + if !Self::describe_statement_fields(framed, session, &portal_stmt_name).await? { + framed.send(BackendMessage::NoData).await + .map_err(PgSqliteError::Io)?; + } } } Ok(()) } + /// v29g: generate & send RowDescription for a prepared statement. + /// Returns Ok(true) if RowDescription was sent; Ok(false) if the caller + /// must send NoData instead. Shared by Describe(statement) and + /// Describe(portal). Before v29g the portal path blindly sent NoData when + /// `stmt.field_descriptions` was empty, so pgjdbc's executeQuery then saw + /// DataRows without field structure and threw "Received resultset tuples, + /// but no field structure for them" (DBeaver: "unexpected message from + /// server"). + /// 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. + /// v29l: every rewrite that must happen before a statement reaches the + /// engine. `execute_select` used to inline this; the Describe shape probe + /// did not, so the two stages ran different SQL and disagreed about the + /// result shape. Single definition -- never inline a copy of it again. + /// + /// Returns `None` (and allocates nothing) when the query needs no rewrite. + /// + /// * v29d PostgreSQL escape-string constants `E'...'` -- SQLite has no E'' + /// syntax; must run first or an escaped quote desynchronises every + /// later literal scanner. + /// * v11 `ILIKE` -> `LIKE` (SQLite has no ILIKE); v20 also gives bare + /// `LIKE` PostgreSQL's default backslash escape. + /// * v22 `LIMIT NULL` / `LIMIT ALL` / bare `OFFSET` -- PG treats these as + /// "no upper bound", SQLite errors out. + /// * v27 `unnest(...)` -> `json_each(...)` subquery, and drop the + /// `public.` schema qualifier SQLite has no namespace for. + fn v29l_engine_sql(query: &str) -> Option { + let mut cur: Option = None; + + { + let q: &str = cur.as_deref().unwrap_or(query); + if crate::translator::EscapeStringTranslator::contains_escape_string(q) { + cur = Some(crate::translator::EscapeStringTranslator::translate(q)); + } + } + { + let q: &str = cur.as_deref().unwrap_or(query); + if crate::translator::IlikeTranslator::contains_like(q) { + cur = Some(crate::translator::IlikeTranslator::normalize_like(q)); + } + } + { + let q: &str = cur.as_deref().unwrap_or(query); + if crate::translator::LimitTranslator::needs_translation(q) { + if let Some(t) = crate::translator::LimitTranslator::translate(q) { + cur = Some(t); + } + } + } + { + let q: &str = cur.as_deref().unwrap_or(query); + if crate::translator::UnnestTranslator::contains_unnest(q) { + if let Ok(t) = crate::translator::UnnestTranslator::translate_unnest(q) { + cur = Some(t); + } + } + } + { + let q: &str = cur.as_deref().unwrap_or(query); + if q.contains("public.") || q.contains("\"public\"") { + cur = Some(crate::translator::SchemaPrefixTranslator::strip_public_prefix(q)); + } + } + + cur + } + + /// v29m: does this statement return rows, and therefore have to be routed + /// to the SQLite *query* API rather than the *execute* API? + /// + /// `handle_execute` used to test for a literal "SELECT" prefix, which sent + /// every `WITH ...`, `VALUES ...` and `TABLE ...` statement down to + /// execute_generic. rusqlite's `execute()` refuses to run a statement + /// that yields rows ("Execute returned results - did you mean to call + /// query?"), so a perfectly valid CTE failed at Execute time even though + /// Describe (v29k's shape probe) had already announced its columns. + /// Describe and Execute must agree on what "returns rows" means. + /// + /// Data-modifying CTEs (`WITH x AS (...) INSERT ...`) keep the DML route: + /// the top-level statement after the CTE list decides. + fn v29m_is_query_route(query: &str) -> bool { + if query_starts_with_ignore_case(query, "SELECT") + || query_starts_with_ignore_case(query, "VALUES") + || query_starts_with_ignore_case(query, "TABLE") + { + return true; + } + if !query_starts_with_ignore_case(query, "WITH") { + return false; + } + // The CTE bodies live inside parentheses; the statement that actually + // runs is the first keyword found at nesting depth zero. + match Self::v29m_top_level_kind(query) { + Some(kind) => kind == "SELECT" || kind == "VALUES" || kind == "TABLE", + // Unparseable or unknown tail: keep the pre-v29m behaviour. + None => false, + } + } + + /// v29m: first top-level (depth-0) statement keyword. String literals, + /// quoted identifiers and comments are skipped so that an 'INSERT' inside + /// a literal cannot flip the routing decision. + fn v29m_top_level_kind(query: &str) -> Option<&'static str> { + const KEYWORDS: [&str; 6] = ["SELECT", "INSERT", "UPDATE", "DELETE", "VALUES", "TABLE"]; + let bytes = query.as_bytes(); + let mut depth: i32 = 0; + let mut i: usize = 0; + while i < bytes.len() { + let c = bytes[i]; + match c { + b'\'' => { + i += 1; + while i < bytes.len() { + if bytes[i] == b'\'' { + 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'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + continue; + } + b'(' => { + depth += 1; + i += 1; + continue; + } + b')' => { + depth -= 1; + i += 1; + continue; + } + _ => {} + } + if !(c.is_ascii_alphabetic() || c == b'_') { + i += 1; + continue; + } + // Start of a word: measure it once, then decide. + let start = i; + let mut j = i; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + if depth == 0 { + let word = &query[start..j]; + for kw in KEYWORDS.iter() { + if word.eq_ignore_ascii_case(kw) { + return Some(kw); + } + } + } + i = j; + } + None + } + + /// v29k: guard for the universal shape probe. Only statements that can + /// return rows and cannot mutate anything are ever probed -- running a + /// probe must never have a side effect. + fn v29k_is_row_returning(query: &str) -> bool { + let trimmed = query.trim_start().trim_start_matches('(').trim_start(); + let head: String = trimmed.chars().take(8).collect::().to_uppercase(); + let ok = head.starts_with("SELECT") + || head.starts_with("VALUES") + || head.starts_with("TABLE") + || head.starts_with("WITH"); + if !ok { + return false; + } + // === PATCH v29n: a data-modifying CTE writes, and it starts with WITH === + // `WITH x AS (...) INSERT INTO t SELECT ... FROM x` passes the prefix + // test above, so the probe used to RUN it. Proven with Parse+Describe + // +Sync alone (no Bind, no Execute): the table grew by one row, and + // Execute then wrote a second one. Describe must never mutate -- the + // top-level statement after the CTE list is what decides. + if head.starts_with("WITH") && !Self::v29m_is_query_route(trimmed) { + return false; + } + !query.to_uppercase().contains("RETURNING") + } + + /// v29k: THE shape of a result as the execution engine sees it. + /// + /// Order matters and mirrors `handle_execute` exactly: the catalog + /// interceptor owns catalog queries (v29j makes every fast path yield for + /// them), plain SQLite owns everything else. Probing with a different + /// engine than Execute uses is precisely the bug v29j had to fix, so the + /// two orders must never drift apart. + /// + /// `$N` placeholders are neutralised to NULL -- we want the column list, + /// never the rows. A `LIMIT 0` wrapper keeps the probe free even when the + /// real result is huge; the bare query is only used if the wrapper is + /// rejected. Results are memoised per query text. + async fn v29k_probe_result_columns( + session: &Arc, + query: &str, + ) -> Option> { + static V29K_SHAPES: once_cell::sync::Lazy< + parking_lot::Mutex>>, + > = once_cell::sync::Lazy::new(|| { + parking_lot::Mutex::new(std::collections::HashMap::new()) + }); + + if let Some(hit) = V29K_SHAPES.lock().get(query).cloned() { + return if hit.is_empty() { None } else { Some(hit) }; + } + + // `$N` placeholders are neutralised -- we want the column list, never + // the rows, and a bound value can never change the shape. + let mut probe = query.to_string(); + for i in (1..=32).rev() { + probe = probe.replace(&format!("${i}"), "NULL"); + } + + // v29l: run the engine-facing rewrites Execute runs. Without this the + // probe hands raw PostgreSQL syntax (unnest(...), `AS t(a,b)`, E'...') + // to SQLite, prepare() fails, the probe reports "no columns" and + // Describe answers NoData for a statement that will happily stream rows. + let mut candidates: Vec = Vec::with_capacity(2); + if let Some(rewritten) = Self::v29l_engine_sql(&probe) { + if rewritten != probe { + candidates.push(rewritten); + } + } + candidates.push(probe); + + let db = session.get_db_handler().await?; + let mut cols: Option> = None; + + 'candidates: for cand in &candidates { + // Order mirrors handle_execute exactly: the catalog interceptor owns + // catalog queries (v29j makes every fast path yield for them), plain + // SQLite owns everything else. + if let Some(Ok(resp)) = CatalogInterceptor::intercept_query( + cand, + db.clone(), + Some(session.clone()), + ) + .await + { + if !resp.columns.is_empty() { + cols = Some(resp.columns); + break 'candidates; + } + } + + // LIMIT 0 keeps the probe free even when the real result is huge; + // column metadata comes from prepare(), not from the rows. + let wrapped = format!("SELECT * FROM ({cand}) AS __v29k_probe LIMIT 0"); + if let Ok(resp) = db.query_with_session(&wrapped, &session.id).await { + if !resp.columns.is_empty() { + cols = Some(resp.columns); + break 'candidates; + } + } + + // Some shapes refuse to be wrapped (bare VALUES, set-returning + // functions in the target list); ask them directly. + if let Ok(resp) = db.query_with_session(cand, &session.id).await { + if !resp.columns.is_empty() { + cols = Some(resp.columns); + break 'candidates; + } + } + } + + { + let mut cache = V29K_SHAPES.lock(); + if cache.len() > 512 { + cache.clear(); + } + cache.insert(query.to_string(), cols.clone().unwrap_or_default()); + } + cols + } + + 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"); + } + + 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 + } + + async fn describe_statement_fields( + framed: &mut Framed, + session: &Arc, + stmt_name: &str, + ) -> Result + where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + // ================= 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". Ask the interceptor instead. + { + let (probe_query, fd_len) = { + let statements = session.prepared_statements.read().await; + let stmt = statements.get(stmt_name).ok_or_else(|| { + PgSqliteError::Protocol(format!("Unknown statement: {stmt_name}")) + })?; + (stmt.query.clone(), stmt.field_descriptions.len()) + }; + let is_cat = probe_query.contains("pg_catalog") + || probe_query.contains("pg_type") + || probe_query.contains("pg_namespace") + || probe_query.contains("pg_class") + || probe_query.contains("pg_attribute") + || probe_query.contains("pg_constraint") + || probe_query.contains("pg_index") + || probe_query.contains("pg_depend") + || probe_query.contains("pg_database") + || probe_query.contains("information_schema"); + if is_cat && query_starts_with_ignore_case(&probe_query, "SELECT") { + if let Some(cols) = + Self::v29i_probe_catalog_columns(session, &probe_query).await + { + // Only override when the announced shape is missing or + // disagrees with reality -- identical shapes keep their + // richer parse-time types, so nothing regresses. + if fd_len != cols.len() { + warn!( + "v29i truth probe: statement '{}' announced {} fields but the catalog returns {} -> realigning. query: {}", + stmt_name, fd_len, cols.len(), probe_query + ); + let fields = Self::v29i_fields_from_columns(&cols); + { + let mut statements_mut = + session.prepared_statements.write().await; + if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { + stmt_mut.field_descriptions = fields.clone(); + } + } + framed + .send(BackendMessage::RowDescription(fields)) + .await + .map_err(PgSqliteError::Io)?; + return Ok(true); + } + } + } + } + // =============== end v29i catalog truth probe =============== + // ================= v29k universal shape probe ================= + // A statement whose Describe answers NoData but whose Execute streams + // DataRows leaves the client with rows it has no field structure for. + // tokio-postgres/dbx calls that "error parsing response from server"; + // pgjdbc calls it "Received resultset tuples, but no field structure". + // Real PostgreSQL never gets into that state because it always knows + // the shape at Describe time -- so when our parser could not infer it, + // ask the engine that will actually run the query. + { + // v29l: Execute runs stmt.translated_query (handle_parse already + // rewrote Cast/Array/JsonEach/DateTime/... into SQLite dialect), so + // that -- not the user's original text -- is what the probe must ask + // about. The original is kept as a second candidate for the rare + // statement whose translation loses the shape. + let (probe_query, probe_alt, fd_len) = { + let statements = session.prepared_statements.read().await; + let stmt = statements.get(stmt_name).ok_or_else(|| { + PgSqliteError::Protocol(format!("Unknown statement: {stmt_name}")) + })?; + let translated = stmt + .translated_query + .clone() + .unwrap_or_else(|| stmt.query.clone()); + let alt = if translated == stmt.query { + None + } else { + Some(stmt.query.clone()) + }; + (translated, alt, stmt.field_descriptions.len()) + }; + let probe_disabled = std::env::var("PGSQLITE_V29K_SHAPE_PROBE") + .map(|v| v == "0") + .unwrap_or(false); + if fd_len == 0 && !probe_disabled && Self::v29k_is_row_returning(&probe_query) { + let mut probed = Self::v29k_probe_result_columns(session, &probe_query).await; + if probed.is_none() { + if let Some(ref alt) = probe_alt { + if Self::v29k_is_row_returning(alt) { + probed = Self::v29k_probe_result_columns(session, alt).await; + } + } + } + if let Some(cols) = probed { + if !cols.is_empty() { + warn!( + "v29k shape probe: statement '{}' was about to answer NoData; the engine returns {} column(s) -> announcing them. query: {}", + stmt_name, cols.len(), probe_query + ); + let fields = Self::v29i_fields_from_columns(&cols); + { + let mut statements_mut = + session.prepared_statements.write().await; + if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { + stmt_mut.field_descriptions = fields.clone(); + } + } + framed + .send(BackendMessage::RowDescription(fields)) + .await + .map_err(PgSqliteError::Io)?; + return Ok(true); + } + } + } + } + // =============== end v29k universal shape probe =============== + let statements = session.prepared_statements.read().await; + let stmt = statements + .get(stmt_name) + .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {stmt_name}")))?; + + // 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") || + query.contains("pg_namespace") || query.contains("pg_class") || + 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()); + + // Fix field types for catalog queries before sending RowDescription + let mut corrected_fields = stmt.field_descriptions.clone(); + if is_catalog_query || query.contains("pg_attribute") || query.contains("a.attnotnull") || query.contains("a.atthasdef") { + for fd in &mut corrected_fields { + let col_lower = fd.name.to_lowercase(); + match col_lower.as_str() { + // Direct pg_attribute boolean columns + "attnotnull" | "atthasdef" | "attbyval" | "atthasmissing" | "attisdropped" | "attislocal" | + // Common aliases for these columns in JOIN queries + "not_null" | "has_default" | "is_not_null" | "has_def" => { + info!("Correcting field '{}' from type_oid {} to Bool type_oid {}", fd.name, fd.type_oid, PgType::Bool.to_oid()); + fd.type_oid = PgType::Bool.to_oid(); + } + "attidentity" | "attgenerated" | "attalign" | "attstorage" | "attcompression" => { + info!("Correcting field '{}' from type_oid {} to Char type_oid {}", fd.name, fd.type_oid, PgType::Char.to_oid()); + fd.type_oid = PgType::Char.to_oid(); + } + _ => {} + } + } + } + + for (i, fd) in corrected_fields.iter().enumerate() { + info!("Field {}: name='{}', type_oid={}, table_oid={}", i, fd.name, fd.type_oid, fd.table_oid); + } + framed.send(BackendMessage::RowDescription(corrected_fields)).await + .map_err(PgSqliteError::Io)?; + Ok(true) + } else if is_catalog_query && query_starts_with_ignore_case(query, "SELECT") { + // For catalog SELECT queries, we need to provide field descriptions + // even though we skipped them during Parse + info!("Catalog query detected in Describe, generating field descriptions for: {}", query); + // v29h: strip $N placeholders so sqlparser can extract columns. + // stmt.query keeps them for parameter binding, but sqlparser 0.57 + // chokes on them and returns an empty projection -> NoData -> + // Execute sends RowDescription+DataRow -> frame misalignment -> + // "unexpected message from server". + let clean_query = query + .replace("$1", "1") + .replace("$2", "2") + .replace("$3", "3") + .replace("$4", "4") + .replace("$5", "5") + .replace("$6", "6") + .replace("$7", "7") + .replace("$8", "8") + .replace("$9", "9"); + + // Parse the query to extract the selected columns (keep JSON path placeholders for now) + let field_descriptions = if let Ok(parsed) = sqlparser::parser::Parser::parse_sql( + &sqlparser::dialect::PostgreSqlDialect {}, + &clean_query + ) { + if let Some(sqlparser::ast::Statement::Query(query_stmt)) = parsed.first() { + if let sqlparser::ast::SetExpr::Select(select) = &*query_stmt.body { + let mut fields = Vec::new(); + + // Check if it's SELECT * + let is_select_star = select.projection.len() == 1 && + matches!(&select.projection[0], sqlparser::ast::SelectItem::Wildcard(_)); + + if is_select_star { + // For SELECT *, we need to determine which catalog table is being queried + // and return all its columns + if query.contains("pg_database") { + info!("DESCRIBE: Generating field descriptions for pg_database SELECT *"); + println!("DEBUG: pg_database field descriptions being generated"); + // Return all pg_database columns + let all_columns = vec![ + ("oid", PgType::Int4.to_oid()), + ("datname", PgType::Text.to_oid()), + ("datdba", PgType::Int4.to_oid()), + ("encoding", PgType::Int4.to_oid()), + ("datlocprovider", PgType::Text.to_oid()), + ("datistemplate", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' + ("datallowconn", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' + ("dathasloginevt", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' + ("datconnlimit", PgType::Int4.to_oid()), + ("datfrozenxid", PgType::Text.to_oid()), + ("datminmxid", PgType::Text.to_oid()), + ("dattablespace", PgType::Int4.to_oid()), + ("datcollate", PgType::Text.to_oid()), + ("datctype", PgType::Text.to_oid()), + ("datlocale", PgType::Text.to_oid()), + ("daticurules", PgType::Text.to_oid()), + ("datcollversion", PgType::Text.to_oid()), + ("datacl", PgType::Text.to_oid()), + ]; + + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + if i == 5 { + println!("DEBUG: pg_database column 5 ({}): type_oid = {}", name, oid); + } + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("pg_class") { + // Return all pg_class columns (33 total in current PostgreSQL) + const OID_TYPE: i32 = 26; + const XID_TYPE: i32 = 28; + const ACLITEM_ARRAY_TYPE: i32 = 1034; + const TEXT_ARRAY_TYPE: i32 = 1009; + const PG_NODE_TREE_TYPE: i32 = 194; + + let all_columns = vec![ + ("oid", OID_TYPE), + ("relname", PgType::Text.to_oid()), + ("relnamespace", OID_TYPE), + ("reltype", OID_TYPE), + ("reloftype", OID_TYPE), + ("relowner", OID_TYPE), + ("relam", OID_TYPE), + ("relfilenode", OID_TYPE), + ("reltablespace", OID_TYPE), + ("relpages", PgType::Int4.to_oid()), + ("reltuples", PgType::Float4.to_oid()), + ("relallvisible", PgType::Int4.to_oid()), + ("reltoastrelid", OID_TYPE), + ("relhasindex", PgType::Bool.to_oid()), + ("relisshared", PgType::Bool.to_oid()), + ("relpersistence", PgType::Char.to_oid()), + ("relkind", PgType::Char.to_oid()), + ("relnatts", PgType::Int2.to_oid()), + ("relchecks", PgType::Int2.to_oid()), + ("relhasrules", PgType::Bool.to_oid()), + ("relhastriggers", PgType::Bool.to_oid()), + ("relhassubclass", PgType::Bool.to_oid()), + ("relrowsecurity", PgType::Bool.to_oid()), + ("relforcerowsecurity", PgType::Bool.to_oid()), + ("relispopulated", PgType::Bool.to_oid()), + ("relreplident", PgType::Char.to_oid()), + ("relispartition", PgType::Bool.to_oid()), + ("relrewrite", OID_TYPE), + ("relfrozenxid", XID_TYPE), + ("relminmxid", XID_TYPE), + ("relacl", ACLITEM_ARRAY_TYPE), + ("reloptions", TEXT_ARRAY_TYPE), + ("relpartbound", PG_NODE_TREE_TYPE), + ]; + + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("pg_attribute") { + // Return all pg_attribute columns + const OID_TYPE: i32 = 26; + + let all_columns = vec![ + ("attrelid", OID_TYPE), + ("attname", PgType::Text.to_oid()), + ("atttypid", OID_TYPE), + ("attstattarget", PgType::Int4.to_oid()), + ("attlen", PgType::Int2.to_oid()), + ("attnum", PgType::Int2.to_oid()), + ("attndims", PgType::Int4.to_oid()), + ("attcacheoff", PgType::Int4.to_oid()), + ("atttypmod", PgType::Int4.to_oid()), + ("attbyval", PgType::Bool.to_oid()), + ("attalign", PgType::Char.to_oid()), + ("attstorage", PgType::Char.to_oid()), + ("attcompression", PgType::Char.to_oid()), + ("attnotnull", PgType::Bool.to_oid()), + ("atthasdef", PgType::Bool.to_oid()), + ("atthasmissing", PgType::Bool.to_oid()), + ("attidentity", PgType::Char.to_oid()), + ("attgenerated", PgType::Char.to_oid()), + ("attisdropped", PgType::Bool.to_oid()), + ("attislocal", PgType::Bool.to_oid()), + ("attinhcount", PgType::Int4.to_oid()), + ("attcollation", OID_TYPE), + ("attacl", PgType::Text.to_oid()), // Simplified - actually aclitem[] + ("attoptions", PgType::Text.to_oid()), // Simplified - actually text[] + ("attfdwoptions", PgType::Text.to_oid()), // Simplified - actually text[] + ("attmissingval", PgType::Text.to_oid()), // Simplified + ]; + + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("pg_constraint") { + // Return all pg_constraint columns + let all_columns = vec![ + ("oid", PgType::Text.to_oid()), // Returned as text for now + ("conname", PgType::Text.to_oid()), + ("connamespace", PgType::Text.to_oid()), // Returned as text for now + ("contype", PgType::Char.to_oid()), + ("condeferrable", PgType::Bool.to_oid()), + ("condeferred", PgType::Bool.to_oid()), + ("convalidated", PgType::Bool.to_oid()), + ("conrelid", PgType::Text.to_oid()), // Returned as text for now + ("contypid", PgType::Text.to_oid()), // Returned as text for now + ("conindid", PgType::Text.to_oid()), // Returned as text for now + ("conparentid", PgType::Text.to_oid()), // Returned as text for now + ("confrelid", PgType::Text.to_oid()), // Returned as text for now + ("confupdtype", PgType::Char.to_oid()), + ("confdeltype", PgType::Char.to_oid()), + ("confmatchtype", PgType::Char.to_oid()), + ("conislocal", PgType::Bool.to_oid()), + ("coninhcount", PgType::Int4.to_oid()), + ("connoinherit", PgType::Bool.to_oid()), + ("conkey", PgType::Text.to_oid()), // Simplified - actually int2[] + ("confkey", PgType::Text.to_oid()), // Simplified - actually int2[] + ("conpfeqop", PgType::Text.to_oid()), // Simplified - actually oid[] + ("conppeqop", PgType::Text.to_oid()), // Simplified - actually oid[] + ("conffeqop", PgType::Text.to_oid()), // Simplified - actually oid[] + ("confdelsetcols", PgType::Text.to_oid()), // Simplified - actually int2[] + ("conexclop", PgType::Text.to_oid()), // Simplified - actually oid[] + ("conbin", PgType::Text.to_oid()), // Simplified - actually pg_node_tree + ]; + + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("pg_depend") { + // Return all pg_depend columns + let all_columns = vec![ + ("classid", PgType::Text.to_oid()), // Returned as text for now + ("objid", PgType::Text.to_oid()), // Returned as text for now + ("objsubid", PgType::Int4.to_oid()), + ("refclassid", PgType::Text.to_oid()), // Returned as text for now + ("refobjid", PgType::Text.to_oid()), // Returned as text for now + ("refobjsubid", PgType::Int4.to_oid()), + ("deptype", PgType::Char.to_oid()), + ]; + + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("information_schema.schemata") { + // Return all information_schema.schemata columns + let all_columns = vec![ + ("catalog_name", PgType::Text.to_oid()), + ("schema_name", PgType::Text.to_oid()), + ("schema_owner", PgType::Text.to_oid()), + ("default_character_set_catalog", PgType::Text.to_oid()), + ("default_character_set_schema", PgType::Text.to_oid()), + ("default_character_set_name", PgType::Text.to_oid()), + ("sql_path", PgType::Text.to_oid()), + ]; + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("information_schema.tables") { + // Return all information_schema.tables columns + let all_columns = vec![ + ("table_catalog", PgType::Text.to_oid()), + ("table_schema", PgType::Text.to_oid()), + ("table_name", PgType::Text.to_oid()), + ("table_type", PgType::Text.to_oid()), + ("self_referencing_column_name", PgType::Text.to_oid()), + ("reference_generation", PgType::Text.to_oid()), + ("user_defined_type_catalog", PgType::Text.to_oid()), + ("user_defined_type_schema", PgType::Text.to_oid()), + ("user_defined_type_name", PgType::Text.to_oid()), + ("is_insertable_into", PgType::Text.to_oid()), + ("is_typed", PgType::Text.to_oid()), + ("commit_action", PgType::Text.to_oid()), + ]; + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("information_schema.columns") { + // Return all information_schema.columns columns (44 total) + let all_columns = vec![ + ("table_catalog", PgType::Text.to_oid()), + ("table_schema", PgType::Text.to_oid()), + ("table_name", PgType::Text.to_oid()), + ("column_name", PgType::Text.to_oid()), + ("ordinal_position", PgType::Int4.to_oid()), + ("column_default", PgType::Text.to_oid()), + ("is_nullable", PgType::Text.to_oid()), + ("data_type", PgType::Text.to_oid()), + ("character_maximum_length", PgType::Int4.to_oid()), + ("character_octet_length", PgType::Int4.to_oid()), + ("numeric_precision", PgType::Int4.to_oid()), + ("numeric_precision_radix", PgType::Int4.to_oid()), + ("numeric_scale", PgType::Int4.to_oid()), + ("datetime_precision", PgType::Int4.to_oid()), + ("interval_type", PgType::Text.to_oid()), + ("interval_precision", PgType::Int4.to_oid()), + ("character_set_catalog", PgType::Text.to_oid()), + ("character_set_schema", PgType::Text.to_oid()), + ("character_set_name", PgType::Text.to_oid()), + ("collation_catalog", PgType::Text.to_oid()), + ("collation_schema", PgType::Text.to_oid()), + ("collation_name", PgType::Text.to_oid()), + ("domain_catalog", PgType::Text.to_oid()), + ("domain_schema", PgType::Text.to_oid()), + ("domain_name", PgType::Text.to_oid()), + ("udt_catalog", PgType::Text.to_oid()), + ("udt_schema", PgType::Text.to_oid()), + ("udt_name", PgType::Text.to_oid()), + ("scope_catalog", PgType::Text.to_oid()), + ("scope_schema", PgType::Text.to_oid()), + ("scope_name", PgType::Text.to_oid()), + ("maximum_cardinality", PgType::Int4.to_oid()), + ("dtd_identifier", PgType::Text.to_oid()), + ("is_self_referencing", PgType::Text.to_oid()), + ("is_identity", PgType::Text.to_oid()), + ("identity_generation", PgType::Text.to_oid()), + ("identity_start", PgType::Text.to_oid()), + ("identity_increment", PgType::Text.to_oid()), + ("identity_maximum", PgType::Text.to_oid()), + ("identity_minimum", PgType::Text.to_oid()), + ("identity_cycle", PgType::Text.to_oid()), + ("is_generated", PgType::Text.to_oid()), + ("generation_expression", PgType::Text.to_oid()), + ("is_updatable", PgType::Text.to_oid()), + ]; + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("information_schema.key_column_usage") { + // Return all information_schema.key_column_usage columns (9 total) + let all_columns = vec![ + ("constraint_catalog", PgType::Text.to_oid()), + ("constraint_schema", PgType::Text.to_oid()), + ("constraint_name", PgType::Text.to_oid()), + ("table_catalog", PgType::Text.to_oid()), + ("table_schema", PgType::Text.to_oid()), + ("table_name", PgType::Text.to_oid()), + ("column_name", PgType::Text.to_oid()), + ("ordinal_position", PgType::Int4.to_oid()), + ("position_in_unique_constraint", PgType::Int4.to_oid()), + ]; + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } else if query.contains("information_schema.table_constraints") { + // Return all information_schema.table_constraints columns + let all_columns = vec![ + ("constraint_catalog", PgType::Text.to_oid()), + ("constraint_schema", PgType::Text.to_oid()), + ("constraint_name", PgType::Text.to_oid()), + ("table_catalog", PgType::Text.to_oid()), + ("table_schema", PgType::Text.to_oid()), + ("table_name", PgType::Text.to_oid()), + ("constraint_type", PgType::Text.to_oid()), + ("is_deferrable", PgType::Text.to_oid()), + ("initially_deferred", PgType::Text.to_oid()), + ("enforced", PgType::Text.to_oid()), + ("nulls_distinct", PgType::Text.to_oid()), + ]; + for (i, (name, oid)) in all_columns.into_iter().enumerate() { + fields.push(FieldDescription { + name: name.to_string(), + table_oid: 0, + column_id: (i + 1) as i16, + type_oid: oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } + } else { + // Parse the projection to get column names and types + for (i, proj) in select.projection.iter().enumerate() { + let (col_name, type_oid) = match proj { + sqlparser::ast::SelectItem::UnnamedExpr(expr) => { + match expr { + sqlparser::ast::Expr::Identifier(ident) => { + let name = ident.value.to_lowercase(); + let type_oid = Self::get_catalog_column_type(stmt_name, query); + (name, type_oid) + } + sqlparser::ast::Expr::CompoundIdentifier(parts) => { + let name = parts.last().map(|p| p.value.to_lowercase()).unwrap_or_else(|| "?column?".to_string()); + let type_oid = Self::get_catalog_column_type(stmt_name, query); + (name, type_oid) + } + _ => ("?column?".to_string(), PgType::Text.to_oid()), + } + } + sqlparser::ast::SelectItem::ExprWithAlias { alias, expr } => { + let type_oid = match expr { + sqlparser::ast::Expr::Identifier(ident) => { + Self::get_catalog_column_type(&ident.value.to_lowercase(), query) + } + sqlparser::ast::Expr::CompoundIdentifier(parts) => { + let name = parts.last().map(|p| p.value.to_lowercase()).unwrap_or_else(|| "?column?".to_string()); + Self::get_catalog_column_type(stmt_name, query) + } + _ => PgType::Text.to_oid(), + }; + (alias.value.clone(), type_oid) + } + _ => ("?column?".to_string(), PgType::Text.to_oid()), + }; + + fields.push(FieldDescription { + name: col_name, + table_oid: 0, + column_id: (i + 1) as i16, + type_oid, + type_size: -1, + type_modifier: -1, + format: 0, + }); + } + } + + fields + } else { + Vec::new() + } + } else { + Vec::new() + } + } else { + Vec::new() + }; + + if !field_descriptions.is_empty() { + info!("Sending RowDescription with {} catalog fields in Describe", field_descriptions.len()); + + // Update the prepared statement with these field descriptions + // so they're available during Execute + drop(statements); + let mut statements_mut = session.prepared_statements.write().await; + if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { + stmt_mut.field_descriptions = field_descriptions.clone(); + info!("Updated statement '{}' with {} catalog field descriptions", stmt_name, field_descriptions.len()); + } + drop(statements_mut); + + framed.send(BackendMessage::RowDescription(field_descriptions)).await + .map_err(PgSqliteError::Io)?; + Ok(true) + } else { + // v29h: sqlparser failed (e.g. LATERAL unnest WITH ORDINALITY). + // Fallback: extract column aliases from SQL via regex. + if let Ok(re) = regex::Regex::new(r"(?i)AS\s+([a-zA-Z_]\w*)") { + let mut fallback_fields: Vec = Vec::new(); + for cap in re.captures_iter(query) { + if let Some(m) = cap.get(1) { + let col = m.as_str().to_string(); + if col.len() > 64 || col.contains('(') { continue; } + fallback_fields.push(FieldDescription { + name: col, + table_oid: 0, + column_id: (fallback_fields.len() + 1) as i16, + type_oid: PgType::Text.to_oid(), + type_size: -1, + type_modifier: -1, + format: 0, + }); + if fallback_fields.len() >= 100 { break; } + } + } + if !fallback_fields.is_empty() { + info!("v29h fallback: extracted {} columns via regex for {}", fallback_fields.len(), stmt_name); + drop(statements); + let mut statements_mut = session.prepared_statements.write().await; + if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { + stmt_mut.field_descriptions = fallback_fields.clone(); + } + drop(statements_mut); + framed.send(BackendMessage::RowDescription(fallback_fields)).await + .map_err(PgSqliteError::Io)?; + return Ok(true); + } + } + // Fallback: cannot determine fields -> caller sends NoData + info!("Could not determine catalog fields, Describe will send NoData"); + return Ok(false); + } + } else { + Ok(false) + } + } pub async fn handle_close( framed: &mut Framed, session: &Arc, @@ -4365,7 +5385,7 @@ impl ExtendedQueryHandler { t if t == PgType::Time.to_oid() || t == PgType::Timetz.to_oid() => { if let Ok(s) = String::from_utf8(bytes.clone()) { // Check if this is an integer (microseconds since midnight) - if let Ok(micros) = s.parse::() { + if let Some(micros) = crate::types::datetime_utils::time_text_to_micros(&s) { // Convert microseconds to formatted time use crate::types::datetime_utils::format_microseconds_to_time; let formatted = format_microseconds_to_time(micros); @@ -4454,6 +5474,15 @@ impl ExtendedQueryHandler { where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { + // === PATCH v29l: the engine-facing rewrites live in ONE place === + // Describe's shape probe has to run the *identical* chain, otherwise + // Describe and Execute ask two different engines the same question -- + // that drift is exactly what produced the NoData-but-rows protocol + // errors (v29j fixed the catalog half of it, this fixes the rest). + // See Self::v29l_engine_sql for the individual rewrites and why. + let __v29l_owned = Self::v29l_engine_sql(query); + let query: &str = __v29l_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") { @@ -4501,6 +5530,36 @@ impl ExtendedQueryHandler { } } + // PATCH(FIX B): align the catalog result with the field descriptions that + // Describe already announced to the client. pgsqlite's catalog JOIN handler + // materialises only the main table's columns (e.g. 1 column for a query + // projecting "n.nspname, d.description"), while Describe promised the full + // projection. The mismatch breaks pgx/JDBC clients. Pad with NULLs. + { + let stmt_name_opt = { + let portals = session.portals.read().await; + portals.get(portal_name).map(|p| p.statement_name.clone()) + }; + if let Some(stmt_name) = stmt_name_opt { + let statements = session.prepared_statements.read().await; + if let Some(stmt) = statements.get(&stmt_name) { + let want = stmt.field_descriptions.len(); + let have = catalog_response.columns.len(); + if want > have { + info!("CATALOG ALIGN: padding catalog result from {} to {} columns", have, want); + for fd in stmt.field_descriptions.iter().skip(have) { + catalog_response.columns.push(fd.name.clone()); + } + for row in &mut catalog_response.rows { + while row.len() < want { + row.push(None); + } + } + } + } + } + } + catalog_response } else { info!("Query not intercepted, executing normally"); @@ -4526,7 +5585,50 @@ impl ExtendedQueryHandler { // - AND we have columns to describe // Note: We do NOT send RowDescription if switching to binary format because // Describe(Portal) would have already sent it with the correct format - let needs_row_desc = stmt.field_descriptions.is_empty() && !response.columns.is_empty(); + // PATCH(FIX A): only send RowDescription at Execute when Describe did NOT + // already send one. Sending a second, contradictory RowDescription makes + // pgx (dbx) and JDBC (DBeaver) abort while parsing the response. + // PATCH(v29i): the extended query protocol forbids RowDescription + // in response to Execute -- real PostgreSQL only ever emits it for + // Describe. Emitting it here (which happened on every statement + // whose Describe answered NoData) is exactly what makes + // tokio-postgres/dbx abort with "unexpected message from server" + // and pgjdbc/DBeaver with "Received resultset tuples, but no field + // structure". The v29i truth probe makes Describe answer properly, + // so this illegal fallback is dead weight. Env escape hatch kept. + let legacy_exec_rowdesc = std::env::var("PGSQLITE_LEGACY_EXEC_ROWDESC") + .map(|v| v == "1") + .unwrap_or(false); + let would_have_sent = + stmt.field_descriptions.is_empty() && !response.columns.is_empty(); + if would_have_sent && !legacy_exec_rowdesc { + warn!( + "v29i: suppressing illegal Execute-stage RowDescription ({} cols); Describe never announced a shape for: {}", + response.columns.len(), query + ); + // v29l: suppressing the RowDescription alone is not enough. If + // we now stream DataRows the client holds rows it has no field + // structure for -- tokio-postgres/dbx aborts the connection with + // "error parsing response from server", pgjdbc with "Received + // resultset tuples, but no field structure". A clean + // ErrorResponse is strictly better: the client shows a real + // message and the connection stays usable (Sync resets it). + // Zero-row results are harmless (NoData + CommandComplete is a + // perfectly legal exchange), so they are let through. + let shape_error_enabled = std::env::var("PGSQLITE_V29L_SHAPE_ERROR") + .map(|v| v != "0") + .unwrap_or(true); + if shape_error_enabled && !response.rows.is_empty() { + let ncols = response.columns.len(); + let nrows = response.rows.len(); + drop(statements); + drop(portals); + return Err(PgSqliteError::NotSupported(format!( + "pgsqlite could not determine this statement's result shape at Describe time, so its {nrows} row(s) x {ncols} column(s) cannot be sent without corrupting the protocol stream. Rewrite the statement, or set PGSQLITE_V29L_SHAPE_ERROR=0 to fall back to the (lossy) old behaviour." + ))); + } + } + let needs_row_desc = would_have_sent && legacy_exec_rowdesc; drop(statements); drop(portals); @@ -4809,6 +5911,26 @@ impl ExtendedQueryHandler { corrected_field_types[i] = 18; // PgType::Char.to_oid() info!("EXECUTE_SELECT: Corrected column '{}' type from {} to CHAR (18)", col_name, old_type); } + } + "attname" | "attacl" | "attoptions" | "attfdwoptions" | "attqual" => { + if i < corrected_field_types.len() { + corrected_field_types[i] = 25; // PgType::Text + } + } + "atttypid" | "attrelid" | "attindkey" | "attcollation" | "attarraytypid" => { + if i < corrected_field_types.len() { + corrected_field_types[i] = 26; // PgType::Oid + } + } + "attnum" | "attlen" | "attndims" | "attcacheoff" | "attmaxalignedlen" | "attstattarget" => { + if i < corrected_field_types.len() { + corrected_field_types[i] = 21; // PgType::Int2 + } + } + "atttypmod" => { + if i < corrected_field_types.len() { + corrected_field_types[i] = 23; // PgType::Int4 + } } _ => {} } @@ -4988,6 +6110,26 @@ impl ExtendedQueryHandler { if i < field_types.len() { field_types[i] = PgType::Char.to_oid(); } + } + "attname" | "attacl" | "attoptions" | "attfdwoptions" | "attqual" => { + if i < field_types.len() { + field_types[i] = 25; // PgType::Text + } + } + "atttypid" | "attrelid" | "attindkey" | "attcollation" | "attarraytypid" => { + if i < field_types.len() { + field_types[i] = 26; // PgType::Oid + } + } + "attnum" | "attlen" | "attndims" | "attcacheoff" | "attmaxalignedlen" | "attstattarget" => { + if i < field_types.len() { + field_types[i] = 21; // PgType::Int2 + } + } + "atttypmod" => { + if i < field_types.len() { + field_types[i] = 23; // PgType::Int4 + } } _ => {} } @@ -5897,6 +7039,100 @@ impl ExtendedQueryHandler { } } + + /// PATCH v15: infer a parameter's type from its *syntactic* context. + /// + /// The schema-based heuristic in `analyze_select_params` can only type a + /// parameter that is compared against a known column (`col = $n`). It is + /// blind to three very common shapes, all of which GUI clients emit: + /// + /// 1. `CAST($n AS BIGINT)` — the SQL-standard cast. The old code only + /// understood the PostgreSQL shorthand `$n::bigint`. + /// 2. `$n::double precision` — multi-word type names (the old regex + /// captured a single `\w+`, so it stopped at `double`). + /// 3. `LIMIT $n` / `OFFSET $n` — PostgreSQL types these as int8. + /// + /// Returning `None` means "no opinion", and the caller falls through to + /// the existing schema-based inference, so this is strictly additive. + fn infer_param_type_from_syntax(query: &str, idx: usize) -> Option { + let param = regex::escape(&format!("${idx}")); + + // Resolve a captured type name to an OID. `pg_type_name_to_oid` + // silently falls back to TEXT for names it does not know, which would + // otherwise make an unknown cast look like a confident "text" answer. + // Only trust a TEXT result when the name really is a text type. + let resolve = |raw: &str| -> Option { + let name = raw.trim().to_lowercase(); + let name = name.split_whitespace().collect::>().join(" "); + if name.is_empty() { + return None; + } + let oid = Self::pg_type_name_to_oid(&name); + if oid != PgType::Text.to_oid() + || matches!(name.as_str(), "text" | "varchar" | "character varying") + { + Some(oid) + } else { + None + } + }; + + // -- 1. CAST($n AS ) / CAST($n AS (len[,scale])) --------- + // The type-name character class excludes '(' and ')', so the greedy + // match stops cleanly before an optional length specifier. + let cast_pat = format!( + r"(?i)\bcast\s*\(\s*{param}\s+as\s+([a-zA-Z][a-zA-Z0-9_ ]*)\s*(?:\(\s*\d+\s*(?:,\s*\d+\s*)?\))?\s*\)" + ); + if let Ok(re) = regex::Regex::new(&cast_pat) + && let Some(c) = re.captures(query) + && let Some(m) = c.get(1) + && let Some(oid) = resolve(m.as_str()) + { + info!( + "Inferred parameter {} type from CAST(...) syntax: {} (OID {})", + idx, + m.as_str().trim(), + oid + ); + return Some(oid); + } + + // -- 2. $n :: (multi-word type names) ----------------------- + // Note `$1::` cannot match inside `$10::` because the character right + // after `$1` would be `0`, not `:` — so no lookahead is needed. + let colon_pat = format!(r"(?i){param}\s*::\s*([a-zA-Z][a-zA-Z0-9_ ]*)"); + if let Ok(re) = regex::Regex::new(&colon_pat) + && let Some(c) = re.captures(query) + && let Some(m) = c.get(1) + && let Some(oid) = resolve(m.as_str()) + { + info!( + "Inferred parameter {} type from :: cast syntax: {} (OID {})", + idx, + m.as_str().trim(), + oid + ); + return Some(oid); + } + + // -- 3. LIMIT $n / OFFSET $n ---------------------------------------- + // PostgreSQL declares both as int8. The trailing \b keeps `$1` from + // matching the `$1` prefix of `$10`. + let lim_pat = format!(r"(?i)\b(?:limit|offset)\s+{param}\b"); + if let Ok(re) = regex::Regex::new(&lim_pat) + && re.is_match(query) + { + let oid = PgType::Int8.to_oid(); + info!( + "Inferred parameter {} type from LIMIT/OFFSET position: int8 (OID {})", + idx, oid + ); + return Some(oid); + } + + None + } + /// Analyze SELECT query to determine parameter types from WHERE clause async fn analyze_select_params(query: &str, db: &Arc, session: &Arc) -> Result, PgSqliteError> { // First, check for explicit parameter casts like $1::int4 @@ -5926,7 +7162,15 @@ impl ExtendedQueryHandler { if found_type { continue; } - + + // PATCH v15: before falling back to schema inference (and ultimately + // to text), try the syntactic shapes the column heuristic cannot see: + // CAST($n AS T), multi-word $n::T, and LIMIT/OFFSET $n. + if let Some(oid) = Self::infer_param_type_from_syntax(query, i) { + param_types.push(oid); + continue; + } + // If no explicit cast, try to infer from column comparisons // Extract table name from SELECT query (only if needed) let table_name = if let Some(name) = extract_table_name_from_select(query) { @@ -6298,6 +7542,20 @@ impl ExtendedQueryHandler { } + +/// 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") +} + /// Extract table name from SELECT query fn extract_table_name_from_select(query: &str) -> Option { // Look for FROM clause using case-insensitive search @@ -6315,6 +7573,10 @@ 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) { + return None; + } Some(table_name.to_string()) } else { None 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..29b0da3e 100644 --- a/src/translator/mod.rs +++ b/src/translator/mod.rs @@ -59,4 +59,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..2c919151 100644 --- a/src/translator/schema_prefix_translator.rs +++ b/src/translator/schema_prefix_translator.rs @@ -310,4 +310,4 @@ mod tests { let query = "SELECT 'information_schema.tables"; assert_eq!(SchemaPrefixTranslator::translate_query(query), query); } -} \ 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); + } +} + From 902ecedf41f121d224ddf88f99974b3dcf9c848f Mon Sep 17 00:00:00 2001 From: pgsqlite-local Date: Wed, 12 Aug 2026 15:34:40 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(catalog):=20=E4=BF=AE=E5=A4=8D=20dbx?= =?UTF-8?q?=20=E5=88=97=E9=BB=98=E8=AE=A4=E5=80=BC(attrdef)=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=A9=BA=E7=99=BD=20(v42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/catalog/query_interceptor.rs | 89 ++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 14df114b..5ef814ed 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -689,6 +689,58 @@ impl CatalogInterceptor { 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 }) + } + + // 从 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) { @@ -1542,32 +1594,35 @@ pub async fn intercept_query(query: &str, db: Arc, session: Option Date: Thu, 13 Aug 2026 12:05:22 +0800 Subject: [PATCH 03/13] fix(rebase): revert extended.rs catalog hardcoding to erans base; keep is_pg_catalog_object The v40+v41 rebase onto erans #87/#88 left extended.rs structurally broken (cherry-pick shifted a 520->21 line hunk, orphaning an if/else chain). erans now serves catalog columns natively from SQLite views, so the user's hardcoded catalog columns are redundant. Revert extended.rs to erans base and re-add only the self-contained is_pg_catalog_object helper that src/query/executor.rs depends on. --- src/query/extended.rs | 1476 ++++------------------------------------- 1 file changed, 113 insertions(+), 1363 deletions(-) diff --git a/src/query/extended.rs b/src/query/extended.rs index b57d5b1f..763c43a9 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -1381,27 +1381,7 @@ impl ExtendedQueryHandler { false }; - // === PATCH v29j: catalog queries must never take a fast path === - // The fast paths below answer straight from SQLite, where pg_class / - // pg_attribute / pg_type exist only as reduced legacy VIEWs (25 / 22 / 8 - // columns). Describe, and the simple-query protocol, are answered by - // CatalogInterceptor (33 / 26 / 7 columns). Letting Execute pick the - // other engine desynchronises the wire: the client is told 33 fields and - // then handed 25-wide DataRows, which tokio-postgres reports as - // 'unexpected message from server'. Force every catalog query down the - // execute_select path so Describe and Execute share one engine. - let __v29j_catalog_fastpath = std::env::var("PGSQLITE_V29J_CATALOG_FASTPATH") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let __v29j_is_catalog = !__v29j_catalog_fastpath - && (CatalogInterceptor::is_catalog_query(&query) - || CatalogInterceptor::is_catalog_query(effective_query)); - if __v29j_is_catalog { - info!("v29j: catalog query -> fast paths disabled, routing through CatalogInterceptor: {}", query); - } - - if !__v29j_is_catalog - && query_starts_with_ignore_case(&query, "SELECT") && + if query_starts_with_ignore_case(&query, "SELECT") && !query.contains("JOIN") && !query.contains("GROUP BY") && !query.contains("HAVING") && @@ -1714,7 +1694,7 @@ impl ExtendedQueryHandler { } // Try optimized extended fast path first for parameterized queries - if !__v29j_is_catalog && !bound_values.is_empty() && effective_query.contains('$') { + if !bound_values.is_empty() && effective_query.contains('$') { let query_type = super::extended_fast_path::QueryType::from_query(effective_query); // Early check: Skip fast path for SELECT with binary results @@ -1768,11 +1748,7 @@ impl ExtendedQueryHandler { } // Try existing fast path as second option - if let Some(fast_query) = if __v29j_is_catalog { - None - } else { - crate::query::can_use_fast_path_enhanced(&query) - } { + if let Some(fast_query) = crate::query::can_use_fast_path_enhanced(&query) { // Only use fast path for queries that actually have parameters in the extended protocol if !bound_values.is_empty() && query.contains('$') && let Ok(Some(result)) = Self::try_execute_fast_path_with_params( @@ -1936,9 +1912,7 @@ impl ExtendedQueryHandler { } // Execute based on query type - // === PATCH v29m: route on "does it return rows", not on the - // literal SELECT prefix. WITH / VALUES / TABLE return rows too. - if Self::v29m_is_query_route(&final_query) { + if query_starts_with_ignore_case(&final_query, "SELECT") { Self::execute_select(framed, db, session, &portal, &final_query, max_rows).await?; } else if query_starts_with_ignore_case(&final_query, "INSERT") || query_starts_with_ignore_case(&final_query, "UPDATE") @@ -1992,13 +1966,98 @@ impl ExtendedQueryHandler { if typ == b'S' { // Describe statement - // Send ParameterDescription first (read param types under lock) - { - let st = session.prepared_statements.read().await; - let pstmt = st.get(&name).ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {name}")))?; - framed.send(BackendMessage::ParameterDescription(pstmt.param_types.clone())).await + 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") || + query.contains("pg_namespace") || query.contains("pg_class") || + 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()); + + // Fix field types for catalog queries before sending RowDescription + let mut corrected_fields = stmt.field_descriptions.clone(); + if is_catalog_query || query.contains("pg_attribute") || query.contains("a.attnotnull") || query.contains("a.atthasdef") { + for fd in &mut corrected_fields { + let col_lower = fd.name.to_lowercase(); + match col_lower.as_str() { + // Direct pg_attribute boolean columns + "attnotnull" | "atthasdef" | "attbyval" | "atthasmissing" | "attisdropped" | "attislocal" | + // Common aliases for these columns in JOIN queries + "not_null" | "has_default" | "is_not_null" | "has_def" => { + info!("Correcting field '{}' from type_oid {} to Bool type_oid {}", fd.name, fd.type_oid, PgType::Bool.to_oid()); + fd.type_oid = PgType::Bool.to_oid(); + } + "attidentity" | "attgenerated" | "attalign" | "attstorage" | "attcompression" => { + info!("Correcting field '{}' from type_oid {} to Char type_oid {}", fd.name, fd.type_oid, PgType::Char.to_oid()); + fd.type_oid = PgType::Char.to_oid(); + } + _ => {} + } + } + } + + for (i, fd) in corrected_fields.iter().enumerate() { + info!("Field {}: name='{}', type_oid={}, table_oid={}", i, fd.name, fd.type_oid, fd.table_oid); + } + framed.send(BackendMessage::RowDescription(corrected_fields)).await .map_err(PgSqliteError::Io)?; - } + } else if is_catalog_query && query_starts_with_ignore_case(query, "SELECT") { + // For catalog SELECT queries, we need to provide field descriptions + // even though we skipped them during Parse + info!("Catalog query detected in Describe, generating field descriptions for: {}", query); + + // Parse the query to extract the selected columns (keep JSON path placeholders for now) + let field_descriptions = if let Ok(parsed) = sqlparser::parser::Parser::parse_sql( + &sqlparser::dialect::PostgreSqlDialect {}, + query + ) { + if let Some(sqlparser::ast::Statement::Query(query_stmt)) = parsed.first() { + if let sqlparser::ast::SetExpr::Select(select) = &*query_stmt.body { + let mut fields = Vec::new(); + + // Check if it's SELECT * + let is_select_star = select.projection.len() == 1 && + matches!(&select.projection[0], sqlparser::ast::SelectItem::Wildcard(_)); + + if is_select_star { + // For SELECT *, we need to determine which catalog table is being queried + // and return all its columns + if query.contains("pg_database") { + info!("DESCRIBE: Generating field descriptions for pg_database SELECT *"); + println!("DEBUG: pg_database field descriptions being generated"); + // Return all pg_database columns + let all_columns = vec![ + ("oid", PgType::Int4.to_oid()), + ("datname", PgType::Text.to_oid()), + ("datdba", PgType::Int4.to_oid()), + ("encoding", PgType::Int4.to_oid()), + ("datlocprovider", PgType::Text.to_oid()), + ("datistemplate", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' + ("datallowconn", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' + ("dathasloginevt", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' + ("datconnlimit", PgType::Int4.to_oid()), + ("datfrozenxid", PgType::Text.to_oid()), + ("datminmxid", PgType::Text.to_oid()), + ("dattablespace", PgType::Int4.to_oid()), + ("datcollate", PgType::Text.to_oid()), + ("datctype", PgType::Text.to_oid()), + ("datlocale", PgType::Text.to_oid()), + ("daticurules", PgType::Text.to_oid()), + ("datcollversion", PgType::Text.to_oid()), + ("datacl", PgType::Text.to_oid()), + ]; for (i, (name, oid)) in all_columns.into_iter().enumerate() { if i == 5 { @@ -2446,7 +2505,6 @@ impl ExtendedQueryHandler { framed.send(BackendMessage::NoData).await .map_err(PgSqliteError::Io)?; } - } else { // Describe portal let portals = session.portals.read().await; @@ -2524,1092 +2582,14 @@ impl ExtendedQueryHandler { framed.send(BackendMessage::RowDescription(fields)).await .map_err(PgSqliteError::Io)?; } else { - // v29g: catalog SELECT without pre-computed fields -> generate them - // so Describe(portal) returns RowDescription instead of NoData - // (NoData + DataRow on Execute makes pgjdbc throw "Received - // resultset tuples, but no field structure for them" -> DBeaver - // "unexpected message from server"). - // NB: release the portal/statement read locks BEFORE calling - // describe_statement_fields -- it takes the write lock to update - // stmt.field_descriptions, and holding the read lock here would - // deadlock (read lock waits for write lock, write lock waits for us). - let portal_stmt_name = portal.statement_name.clone(); - drop(portals); - drop(statements); - if !Self::describe_statement_fields(framed, session, &portal_stmt_name).await? { - framed.send(BackendMessage::NoData).await - .map_err(PgSqliteError::Io)?; - } + framed.send(BackendMessage::NoData).await + .map_err(PgSqliteError::Io)?; } } Ok(()) } - /// v29g: generate & send RowDescription for a prepared statement. - /// Returns Ok(true) if RowDescription was sent; Ok(false) if the caller - /// must send NoData instead. Shared by Describe(statement) and - /// Describe(portal). Before v29g the portal path blindly sent NoData when - /// `stmt.field_descriptions` was empty, so pgjdbc's executeQuery then saw - /// DataRows without field structure and threw "Received resultset tuples, - /// but no field structure for them" (DBeaver: "unexpected message from - /// server"). - /// 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. - /// v29l: every rewrite that must happen before a statement reaches the - /// engine. `execute_select` used to inline this; the Describe shape probe - /// did not, so the two stages ran different SQL and disagreed about the - /// result shape. Single definition -- never inline a copy of it again. - /// - /// Returns `None` (and allocates nothing) when the query needs no rewrite. - /// - /// * v29d PostgreSQL escape-string constants `E'...'` -- SQLite has no E'' - /// syntax; must run first or an escaped quote desynchronises every - /// later literal scanner. - /// * v11 `ILIKE` -> `LIKE` (SQLite has no ILIKE); v20 also gives bare - /// `LIKE` PostgreSQL's default backslash escape. - /// * v22 `LIMIT NULL` / `LIMIT ALL` / bare `OFFSET` -- PG treats these as - /// "no upper bound", SQLite errors out. - /// * v27 `unnest(...)` -> `json_each(...)` subquery, and drop the - /// `public.` schema qualifier SQLite has no namespace for. - fn v29l_engine_sql(query: &str) -> Option { - let mut cur: Option = None; - - { - let q: &str = cur.as_deref().unwrap_or(query); - if crate::translator::EscapeStringTranslator::contains_escape_string(q) { - cur = Some(crate::translator::EscapeStringTranslator::translate(q)); - } - } - { - let q: &str = cur.as_deref().unwrap_or(query); - if crate::translator::IlikeTranslator::contains_like(q) { - cur = Some(crate::translator::IlikeTranslator::normalize_like(q)); - } - } - { - let q: &str = cur.as_deref().unwrap_or(query); - if crate::translator::LimitTranslator::needs_translation(q) { - if let Some(t) = crate::translator::LimitTranslator::translate(q) { - cur = Some(t); - } - } - } - { - let q: &str = cur.as_deref().unwrap_or(query); - if crate::translator::UnnestTranslator::contains_unnest(q) { - if let Ok(t) = crate::translator::UnnestTranslator::translate_unnest(q) { - cur = Some(t); - } - } - } - { - let q: &str = cur.as_deref().unwrap_or(query); - if q.contains("public.") || q.contains("\"public\"") { - cur = Some(crate::translator::SchemaPrefixTranslator::strip_public_prefix(q)); - } - } - - cur - } - - /// v29m: does this statement return rows, and therefore have to be routed - /// to the SQLite *query* API rather than the *execute* API? - /// - /// `handle_execute` used to test for a literal "SELECT" prefix, which sent - /// every `WITH ...`, `VALUES ...` and `TABLE ...` statement down to - /// execute_generic. rusqlite's `execute()` refuses to run a statement - /// that yields rows ("Execute returned results - did you mean to call - /// query?"), so a perfectly valid CTE failed at Execute time even though - /// Describe (v29k's shape probe) had already announced its columns. - /// Describe and Execute must agree on what "returns rows" means. - /// - /// Data-modifying CTEs (`WITH x AS (...) INSERT ...`) keep the DML route: - /// the top-level statement after the CTE list decides. - fn v29m_is_query_route(query: &str) -> bool { - if query_starts_with_ignore_case(query, "SELECT") - || query_starts_with_ignore_case(query, "VALUES") - || query_starts_with_ignore_case(query, "TABLE") - { - return true; - } - if !query_starts_with_ignore_case(query, "WITH") { - return false; - } - // The CTE bodies live inside parentheses; the statement that actually - // runs is the first keyword found at nesting depth zero. - match Self::v29m_top_level_kind(query) { - Some(kind) => kind == "SELECT" || kind == "VALUES" || kind == "TABLE", - // Unparseable or unknown tail: keep the pre-v29m behaviour. - None => false, - } - } - - /// v29m: first top-level (depth-0) statement keyword. String literals, - /// quoted identifiers and comments are skipped so that an 'INSERT' inside - /// a literal cannot flip the routing decision. - fn v29m_top_level_kind(query: &str) -> Option<&'static str> { - const KEYWORDS: [&str; 6] = ["SELECT", "INSERT", "UPDATE", "DELETE", "VALUES", "TABLE"]; - let bytes = query.as_bytes(); - let mut depth: i32 = 0; - let mut i: usize = 0; - while i < bytes.len() { - let c = bytes[i]; - match c { - b'\'' => { - i += 1; - while i < bytes.len() { - if bytes[i] == b'\'' { - 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'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - i += 2; - continue; - } - b'(' => { - depth += 1; - i += 1; - continue; - } - b')' => { - depth -= 1; - i += 1; - continue; - } - _ => {} - } - if !(c.is_ascii_alphabetic() || c == b'_') { - i += 1; - continue; - } - // Start of a word: measure it once, then decide. - let start = i; - let mut j = i; - while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { - j += 1; - } - if depth == 0 { - let word = &query[start..j]; - for kw in KEYWORDS.iter() { - if word.eq_ignore_ascii_case(kw) { - return Some(kw); - } - } - } - i = j; - } - None - } - - /// v29k: guard for the universal shape probe. Only statements that can - /// return rows and cannot mutate anything are ever probed -- running a - /// probe must never have a side effect. - fn v29k_is_row_returning(query: &str) -> bool { - let trimmed = query.trim_start().trim_start_matches('(').trim_start(); - let head: String = trimmed.chars().take(8).collect::().to_uppercase(); - let ok = head.starts_with("SELECT") - || head.starts_with("VALUES") - || head.starts_with("TABLE") - || head.starts_with("WITH"); - if !ok { - return false; - } - // === PATCH v29n: a data-modifying CTE writes, and it starts with WITH === - // `WITH x AS (...) INSERT INTO t SELECT ... FROM x` passes the prefix - // test above, so the probe used to RUN it. Proven with Parse+Describe - // +Sync alone (no Bind, no Execute): the table grew by one row, and - // Execute then wrote a second one. Describe must never mutate -- the - // top-level statement after the CTE list is what decides. - if head.starts_with("WITH") && !Self::v29m_is_query_route(trimmed) { - return false; - } - !query.to_uppercase().contains("RETURNING") - } - - /// v29k: THE shape of a result as the execution engine sees it. - /// - /// Order matters and mirrors `handle_execute` exactly: the catalog - /// interceptor owns catalog queries (v29j makes every fast path yield for - /// them), plain SQLite owns everything else. Probing with a different - /// engine than Execute uses is precisely the bug v29j had to fix, so the - /// two orders must never drift apart. - /// - /// `$N` placeholders are neutralised to NULL -- we want the column list, - /// never the rows. A `LIMIT 0` wrapper keeps the probe free even when the - /// real result is huge; the bare query is only used if the wrapper is - /// rejected. Results are memoised per query text. - async fn v29k_probe_result_columns( - session: &Arc, - query: &str, - ) -> Option> { - static V29K_SHAPES: once_cell::sync::Lazy< - parking_lot::Mutex>>, - > = once_cell::sync::Lazy::new(|| { - parking_lot::Mutex::new(std::collections::HashMap::new()) - }); - - if let Some(hit) = V29K_SHAPES.lock().get(query).cloned() { - return if hit.is_empty() { None } else { Some(hit) }; - } - - // `$N` placeholders are neutralised -- we want the column list, never - // the rows, and a bound value can never change the shape. - let mut probe = query.to_string(); - for i in (1..=32).rev() { - probe = probe.replace(&format!("${i}"), "NULL"); - } - - // v29l: run the engine-facing rewrites Execute runs. Without this the - // probe hands raw PostgreSQL syntax (unnest(...), `AS t(a,b)`, E'...') - // to SQLite, prepare() fails, the probe reports "no columns" and - // Describe answers NoData for a statement that will happily stream rows. - let mut candidates: Vec = Vec::with_capacity(2); - if let Some(rewritten) = Self::v29l_engine_sql(&probe) { - if rewritten != probe { - candidates.push(rewritten); - } - } - candidates.push(probe); - - let db = session.get_db_handler().await?; - let mut cols: Option> = None; - - 'candidates: for cand in &candidates { - // Order mirrors handle_execute exactly: the catalog interceptor owns - // catalog queries (v29j makes every fast path yield for them), plain - // SQLite owns everything else. - if let Some(Ok(resp)) = CatalogInterceptor::intercept_query( - cand, - db.clone(), - Some(session.clone()), - ) - .await - { - if !resp.columns.is_empty() { - cols = Some(resp.columns); - break 'candidates; - } - } - - // LIMIT 0 keeps the probe free even when the real result is huge; - // column metadata comes from prepare(), not from the rows. - let wrapped = format!("SELECT * FROM ({cand}) AS __v29k_probe LIMIT 0"); - if let Ok(resp) = db.query_with_session(&wrapped, &session.id).await { - if !resp.columns.is_empty() { - cols = Some(resp.columns); - break 'candidates; - } - } - - // Some shapes refuse to be wrapped (bare VALUES, set-returning - // functions in the target list); ask them directly. - if let Ok(resp) = db.query_with_session(cand, &session.id).await { - if !resp.columns.is_empty() { - cols = Some(resp.columns); - break 'candidates; - } - } - } - - { - let mut cache = V29K_SHAPES.lock(); - if cache.len() > 512 { - cache.clear(); - } - cache.insert(query.to_string(), cols.clone().unwrap_or_default()); - } - cols - } - - 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"); - } - - 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 - } - - async fn describe_statement_fields( - framed: &mut Framed, - session: &Arc, - stmt_name: &str, - ) -> Result - where - T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - { - // ================= 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". Ask the interceptor instead. - { - let (probe_query, fd_len) = { - let statements = session.prepared_statements.read().await; - let stmt = statements.get(stmt_name).ok_or_else(|| { - PgSqliteError::Protocol(format!("Unknown statement: {stmt_name}")) - })?; - (stmt.query.clone(), stmt.field_descriptions.len()) - }; - let is_cat = probe_query.contains("pg_catalog") - || probe_query.contains("pg_type") - || probe_query.contains("pg_namespace") - || probe_query.contains("pg_class") - || probe_query.contains("pg_attribute") - || probe_query.contains("pg_constraint") - || probe_query.contains("pg_index") - || probe_query.contains("pg_depend") - || probe_query.contains("pg_database") - || probe_query.contains("information_schema"); - if is_cat && query_starts_with_ignore_case(&probe_query, "SELECT") { - if let Some(cols) = - Self::v29i_probe_catalog_columns(session, &probe_query).await - { - // Only override when the announced shape is missing or - // disagrees with reality -- identical shapes keep their - // richer parse-time types, so nothing regresses. - if fd_len != cols.len() { - warn!( - "v29i truth probe: statement '{}' announced {} fields but the catalog returns {} -> realigning. query: {}", - stmt_name, fd_len, cols.len(), probe_query - ); - let fields = Self::v29i_fields_from_columns(&cols); - { - let mut statements_mut = - session.prepared_statements.write().await; - if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { - stmt_mut.field_descriptions = fields.clone(); - } - } - framed - .send(BackendMessage::RowDescription(fields)) - .await - .map_err(PgSqliteError::Io)?; - return Ok(true); - } - } - } - } - // =============== end v29i catalog truth probe =============== - // ================= v29k universal shape probe ================= - // A statement whose Describe answers NoData but whose Execute streams - // DataRows leaves the client with rows it has no field structure for. - // tokio-postgres/dbx calls that "error parsing response from server"; - // pgjdbc calls it "Received resultset tuples, but no field structure". - // Real PostgreSQL never gets into that state because it always knows - // the shape at Describe time -- so when our parser could not infer it, - // ask the engine that will actually run the query. - { - // v29l: Execute runs stmt.translated_query (handle_parse already - // rewrote Cast/Array/JsonEach/DateTime/... into SQLite dialect), so - // that -- not the user's original text -- is what the probe must ask - // about. The original is kept as a second candidate for the rare - // statement whose translation loses the shape. - let (probe_query, probe_alt, fd_len) = { - let statements = session.prepared_statements.read().await; - let stmt = statements.get(stmt_name).ok_or_else(|| { - PgSqliteError::Protocol(format!("Unknown statement: {stmt_name}")) - })?; - let translated = stmt - .translated_query - .clone() - .unwrap_or_else(|| stmt.query.clone()); - let alt = if translated == stmt.query { - None - } else { - Some(stmt.query.clone()) - }; - (translated, alt, stmt.field_descriptions.len()) - }; - let probe_disabled = std::env::var("PGSQLITE_V29K_SHAPE_PROBE") - .map(|v| v == "0") - .unwrap_or(false); - if fd_len == 0 && !probe_disabled && Self::v29k_is_row_returning(&probe_query) { - let mut probed = Self::v29k_probe_result_columns(session, &probe_query).await; - if probed.is_none() { - if let Some(ref alt) = probe_alt { - if Self::v29k_is_row_returning(alt) { - probed = Self::v29k_probe_result_columns(session, alt).await; - } - } - } - if let Some(cols) = probed { - if !cols.is_empty() { - warn!( - "v29k shape probe: statement '{}' was about to answer NoData; the engine returns {} column(s) -> announcing them. query: {}", - stmt_name, cols.len(), probe_query - ); - let fields = Self::v29i_fields_from_columns(&cols); - { - let mut statements_mut = - session.prepared_statements.write().await; - if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { - stmt_mut.field_descriptions = fields.clone(); - } - } - framed - .send(BackendMessage::RowDescription(fields)) - .await - .map_err(PgSqliteError::Io)?; - return Ok(true); - } - } - } - } - // =============== end v29k universal shape probe =============== - let statements = session.prepared_statements.read().await; - let stmt = statements - .get(stmt_name) - .ok_or_else(|| PgSqliteError::Protocol(format!("Unknown statement: {stmt_name}")))?; - - // 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") || - query.contains("pg_namespace") || query.contains("pg_class") || - 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()); - - // Fix field types for catalog queries before sending RowDescription - let mut corrected_fields = stmt.field_descriptions.clone(); - if is_catalog_query || query.contains("pg_attribute") || query.contains("a.attnotnull") || query.contains("a.atthasdef") { - for fd in &mut corrected_fields { - let col_lower = fd.name.to_lowercase(); - match col_lower.as_str() { - // Direct pg_attribute boolean columns - "attnotnull" | "atthasdef" | "attbyval" | "atthasmissing" | "attisdropped" | "attislocal" | - // Common aliases for these columns in JOIN queries - "not_null" | "has_default" | "is_not_null" | "has_def" => { - info!("Correcting field '{}' from type_oid {} to Bool type_oid {}", fd.name, fd.type_oid, PgType::Bool.to_oid()); - fd.type_oid = PgType::Bool.to_oid(); - } - "attidentity" | "attgenerated" | "attalign" | "attstorage" | "attcompression" => { - info!("Correcting field '{}' from type_oid {} to Char type_oid {}", fd.name, fd.type_oid, PgType::Char.to_oid()); - fd.type_oid = PgType::Char.to_oid(); - } - _ => {} - } - } - } - - for (i, fd) in corrected_fields.iter().enumerate() { - info!("Field {}: name='{}', type_oid={}, table_oid={}", i, fd.name, fd.type_oid, fd.table_oid); - } - framed.send(BackendMessage::RowDescription(corrected_fields)).await - .map_err(PgSqliteError::Io)?; - Ok(true) - } else if is_catalog_query && query_starts_with_ignore_case(query, "SELECT") { - // For catalog SELECT queries, we need to provide field descriptions - // even though we skipped them during Parse - info!("Catalog query detected in Describe, generating field descriptions for: {}", query); - // v29h: strip $N placeholders so sqlparser can extract columns. - // stmt.query keeps them for parameter binding, but sqlparser 0.57 - // chokes on them and returns an empty projection -> NoData -> - // Execute sends RowDescription+DataRow -> frame misalignment -> - // "unexpected message from server". - let clean_query = query - .replace("$1", "1") - .replace("$2", "2") - .replace("$3", "3") - .replace("$4", "4") - .replace("$5", "5") - .replace("$6", "6") - .replace("$7", "7") - .replace("$8", "8") - .replace("$9", "9"); - - // Parse the query to extract the selected columns (keep JSON path placeholders for now) - let field_descriptions = if let Ok(parsed) = sqlparser::parser::Parser::parse_sql( - &sqlparser::dialect::PostgreSqlDialect {}, - &clean_query - ) { - if let Some(sqlparser::ast::Statement::Query(query_stmt)) = parsed.first() { - if let sqlparser::ast::SetExpr::Select(select) = &*query_stmt.body { - let mut fields = Vec::new(); - - // Check if it's SELECT * - let is_select_star = select.projection.len() == 1 && - matches!(&select.projection[0], sqlparser::ast::SelectItem::Wildcard(_)); - - if is_select_star { - // For SELECT *, we need to determine which catalog table is being queried - // and return all its columns - if query.contains("pg_database") { - info!("DESCRIBE: Generating field descriptions for pg_database SELECT *"); - println!("DEBUG: pg_database field descriptions being generated"); - // Return all pg_database columns - let all_columns = vec![ - ("oid", PgType::Int4.to_oid()), - ("datname", PgType::Text.to_oid()), - ("datdba", PgType::Int4.to_oid()), - ("encoding", PgType::Int4.to_oid()), - ("datlocprovider", PgType::Text.to_oid()), - ("datistemplate", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' - ("datallowconn", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' - ("dathasloginevt", PgType::Text.to_oid()), // Using Text since we return 'f'/'t' - ("datconnlimit", PgType::Int4.to_oid()), - ("datfrozenxid", PgType::Text.to_oid()), - ("datminmxid", PgType::Text.to_oid()), - ("dattablespace", PgType::Int4.to_oid()), - ("datcollate", PgType::Text.to_oid()), - ("datctype", PgType::Text.to_oid()), - ("datlocale", PgType::Text.to_oid()), - ("daticurules", PgType::Text.to_oid()), - ("datcollversion", PgType::Text.to_oid()), - ("datacl", PgType::Text.to_oid()), - ]; - - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - if i == 5 { - println!("DEBUG: pg_database column 5 ({}): type_oid = {}", name, oid); - } - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("pg_class") { - // Return all pg_class columns (33 total in current PostgreSQL) - const OID_TYPE: i32 = 26; - const XID_TYPE: i32 = 28; - const ACLITEM_ARRAY_TYPE: i32 = 1034; - const TEXT_ARRAY_TYPE: i32 = 1009; - const PG_NODE_TREE_TYPE: i32 = 194; - - let all_columns = vec![ - ("oid", OID_TYPE), - ("relname", PgType::Text.to_oid()), - ("relnamespace", OID_TYPE), - ("reltype", OID_TYPE), - ("reloftype", OID_TYPE), - ("relowner", OID_TYPE), - ("relam", OID_TYPE), - ("relfilenode", OID_TYPE), - ("reltablespace", OID_TYPE), - ("relpages", PgType::Int4.to_oid()), - ("reltuples", PgType::Float4.to_oid()), - ("relallvisible", PgType::Int4.to_oid()), - ("reltoastrelid", OID_TYPE), - ("relhasindex", PgType::Bool.to_oid()), - ("relisshared", PgType::Bool.to_oid()), - ("relpersistence", PgType::Char.to_oid()), - ("relkind", PgType::Char.to_oid()), - ("relnatts", PgType::Int2.to_oid()), - ("relchecks", PgType::Int2.to_oid()), - ("relhasrules", PgType::Bool.to_oid()), - ("relhastriggers", PgType::Bool.to_oid()), - ("relhassubclass", PgType::Bool.to_oid()), - ("relrowsecurity", PgType::Bool.to_oid()), - ("relforcerowsecurity", PgType::Bool.to_oid()), - ("relispopulated", PgType::Bool.to_oid()), - ("relreplident", PgType::Char.to_oid()), - ("relispartition", PgType::Bool.to_oid()), - ("relrewrite", OID_TYPE), - ("relfrozenxid", XID_TYPE), - ("relminmxid", XID_TYPE), - ("relacl", ACLITEM_ARRAY_TYPE), - ("reloptions", TEXT_ARRAY_TYPE), - ("relpartbound", PG_NODE_TREE_TYPE), - ]; - - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("pg_attribute") { - // Return all pg_attribute columns - const OID_TYPE: i32 = 26; - - let all_columns = vec![ - ("attrelid", OID_TYPE), - ("attname", PgType::Text.to_oid()), - ("atttypid", OID_TYPE), - ("attstattarget", PgType::Int4.to_oid()), - ("attlen", PgType::Int2.to_oid()), - ("attnum", PgType::Int2.to_oid()), - ("attndims", PgType::Int4.to_oid()), - ("attcacheoff", PgType::Int4.to_oid()), - ("atttypmod", PgType::Int4.to_oid()), - ("attbyval", PgType::Bool.to_oid()), - ("attalign", PgType::Char.to_oid()), - ("attstorage", PgType::Char.to_oid()), - ("attcompression", PgType::Char.to_oid()), - ("attnotnull", PgType::Bool.to_oid()), - ("atthasdef", PgType::Bool.to_oid()), - ("atthasmissing", PgType::Bool.to_oid()), - ("attidentity", PgType::Char.to_oid()), - ("attgenerated", PgType::Char.to_oid()), - ("attisdropped", PgType::Bool.to_oid()), - ("attislocal", PgType::Bool.to_oid()), - ("attinhcount", PgType::Int4.to_oid()), - ("attcollation", OID_TYPE), - ("attacl", PgType::Text.to_oid()), // Simplified - actually aclitem[] - ("attoptions", PgType::Text.to_oid()), // Simplified - actually text[] - ("attfdwoptions", PgType::Text.to_oid()), // Simplified - actually text[] - ("attmissingval", PgType::Text.to_oid()), // Simplified - ]; - - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("pg_constraint") { - // Return all pg_constraint columns - let all_columns = vec![ - ("oid", PgType::Text.to_oid()), // Returned as text for now - ("conname", PgType::Text.to_oid()), - ("connamespace", PgType::Text.to_oid()), // Returned as text for now - ("contype", PgType::Char.to_oid()), - ("condeferrable", PgType::Bool.to_oid()), - ("condeferred", PgType::Bool.to_oid()), - ("convalidated", PgType::Bool.to_oid()), - ("conrelid", PgType::Text.to_oid()), // Returned as text for now - ("contypid", PgType::Text.to_oid()), // Returned as text for now - ("conindid", PgType::Text.to_oid()), // Returned as text for now - ("conparentid", PgType::Text.to_oid()), // Returned as text for now - ("confrelid", PgType::Text.to_oid()), // Returned as text for now - ("confupdtype", PgType::Char.to_oid()), - ("confdeltype", PgType::Char.to_oid()), - ("confmatchtype", PgType::Char.to_oid()), - ("conislocal", PgType::Bool.to_oid()), - ("coninhcount", PgType::Int4.to_oid()), - ("connoinherit", PgType::Bool.to_oid()), - ("conkey", PgType::Text.to_oid()), // Simplified - actually int2[] - ("confkey", PgType::Text.to_oid()), // Simplified - actually int2[] - ("conpfeqop", PgType::Text.to_oid()), // Simplified - actually oid[] - ("conppeqop", PgType::Text.to_oid()), // Simplified - actually oid[] - ("conffeqop", PgType::Text.to_oid()), // Simplified - actually oid[] - ("confdelsetcols", PgType::Text.to_oid()), // Simplified - actually int2[] - ("conexclop", PgType::Text.to_oid()), // Simplified - actually oid[] - ("conbin", PgType::Text.to_oid()), // Simplified - actually pg_node_tree - ]; - - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("pg_depend") { - // Return all pg_depend columns - let all_columns = vec![ - ("classid", PgType::Text.to_oid()), // Returned as text for now - ("objid", PgType::Text.to_oid()), // Returned as text for now - ("objsubid", PgType::Int4.to_oid()), - ("refclassid", PgType::Text.to_oid()), // Returned as text for now - ("refobjid", PgType::Text.to_oid()), // Returned as text for now - ("refobjsubid", PgType::Int4.to_oid()), - ("deptype", PgType::Char.to_oid()), - ]; - - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("information_schema.schemata") { - // Return all information_schema.schemata columns - let all_columns = vec![ - ("catalog_name", PgType::Text.to_oid()), - ("schema_name", PgType::Text.to_oid()), - ("schema_owner", PgType::Text.to_oid()), - ("default_character_set_catalog", PgType::Text.to_oid()), - ("default_character_set_schema", PgType::Text.to_oid()), - ("default_character_set_name", PgType::Text.to_oid()), - ("sql_path", PgType::Text.to_oid()), - ]; - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("information_schema.tables") { - // Return all information_schema.tables columns - let all_columns = vec![ - ("table_catalog", PgType::Text.to_oid()), - ("table_schema", PgType::Text.to_oid()), - ("table_name", PgType::Text.to_oid()), - ("table_type", PgType::Text.to_oid()), - ("self_referencing_column_name", PgType::Text.to_oid()), - ("reference_generation", PgType::Text.to_oid()), - ("user_defined_type_catalog", PgType::Text.to_oid()), - ("user_defined_type_schema", PgType::Text.to_oid()), - ("user_defined_type_name", PgType::Text.to_oid()), - ("is_insertable_into", PgType::Text.to_oid()), - ("is_typed", PgType::Text.to_oid()), - ("commit_action", PgType::Text.to_oid()), - ]; - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("information_schema.columns") { - // Return all information_schema.columns columns (44 total) - let all_columns = vec![ - ("table_catalog", PgType::Text.to_oid()), - ("table_schema", PgType::Text.to_oid()), - ("table_name", PgType::Text.to_oid()), - ("column_name", PgType::Text.to_oid()), - ("ordinal_position", PgType::Int4.to_oid()), - ("column_default", PgType::Text.to_oid()), - ("is_nullable", PgType::Text.to_oid()), - ("data_type", PgType::Text.to_oid()), - ("character_maximum_length", PgType::Int4.to_oid()), - ("character_octet_length", PgType::Int4.to_oid()), - ("numeric_precision", PgType::Int4.to_oid()), - ("numeric_precision_radix", PgType::Int4.to_oid()), - ("numeric_scale", PgType::Int4.to_oid()), - ("datetime_precision", PgType::Int4.to_oid()), - ("interval_type", PgType::Text.to_oid()), - ("interval_precision", PgType::Int4.to_oid()), - ("character_set_catalog", PgType::Text.to_oid()), - ("character_set_schema", PgType::Text.to_oid()), - ("character_set_name", PgType::Text.to_oid()), - ("collation_catalog", PgType::Text.to_oid()), - ("collation_schema", PgType::Text.to_oid()), - ("collation_name", PgType::Text.to_oid()), - ("domain_catalog", PgType::Text.to_oid()), - ("domain_schema", PgType::Text.to_oid()), - ("domain_name", PgType::Text.to_oid()), - ("udt_catalog", PgType::Text.to_oid()), - ("udt_schema", PgType::Text.to_oid()), - ("udt_name", PgType::Text.to_oid()), - ("scope_catalog", PgType::Text.to_oid()), - ("scope_schema", PgType::Text.to_oid()), - ("scope_name", PgType::Text.to_oid()), - ("maximum_cardinality", PgType::Int4.to_oid()), - ("dtd_identifier", PgType::Text.to_oid()), - ("is_self_referencing", PgType::Text.to_oid()), - ("is_identity", PgType::Text.to_oid()), - ("identity_generation", PgType::Text.to_oid()), - ("identity_start", PgType::Text.to_oid()), - ("identity_increment", PgType::Text.to_oid()), - ("identity_maximum", PgType::Text.to_oid()), - ("identity_minimum", PgType::Text.to_oid()), - ("identity_cycle", PgType::Text.to_oid()), - ("is_generated", PgType::Text.to_oid()), - ("generation_expression", PgType::Text.to_oid()), - ("is_updatable", PgType::Text.to_oid()), - ]; - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("information_schema.key_column_usage") { - // Return all information_schema.key_column_usage columns (9 total) - let all_columns = vec![ - ("constraint_catalog", PgType::Text.to_oid()), - ("constraint_schema", PgType::Text.to_oid()), - ("constraint_name", PgType::Text.to_oid()), - ("table_catalog", PgType::Text.to_oid()), - ("table_schema", PgType::Text.to_oid()), - ("table_name", PgType::Text.to_oid()), - ("column_name", PgType::Text.to_oid()), - ("ordinal_position", PgType::Int4.to_oid()), - ("position_in_unique_constraint", PgType::Int4.to_oid()), - ]; - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } else if query.contains("information_schema.table_constraints") { - // Return all information_schema.table_constraints columns - let all_columns = vec![ - ("constraint_catalog", PgType::Text.to_oid()), - ("constraint_schema", PgType::Text.to_oid()), - ("constraint_name", PgType::Text.to_oid()), - ("table_catalog", PgType::Text.to_oid()), - ("table_schema", PgType::Text.to_oid()), - ("table_name", PgType::Text.to_oid()), - ("constraint_type", PgType::Text.to_oid()), - ("is_deferrable", PgType::Text.to_oid()), - ("initially_deferred", PgType::Text.to_oid()), - ("enforced", PgType::Text.to_oid()), - ("nulls_distinct", PgType::Text.to_oid()), - ]; - for (i, (name, oid)) in all_columns.into_iter().enumerate() { - fields.push(FieldDescription { - name: name.to_string(), - table_oid: 0, - column_id: (i + 1) as i16, - type_oid: oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } - } else { - // Parse the projection to get column names and types - for (i, proj) in select.projection.iter().enumerate() { - let (col_name, type_oid) = match proj { - sqlparser::ast::SelectItem::UnnamedExpr(expr) => { - match expr { - sqlparser::ast::Expr::Identifier(ident) => { - let name = ident.value.to_lowercase(); - let type_oid = Self::get_catalog_column_type(stmt_name, query); - (name, type_oid) - } - sqlparser::ast::Expr::CompoundIdentifier(parts) => { - let name = parts.last().map(|p| p.value.to_lowercase()).unwrap_or_else(|| "?column?".to_string()); - let type_oid = Self::get_catalog_column_type(stmt_name, query); - (name, type_oid) - } - _ => ("?column?".to_string(), PgType::Text.to_oid()), - } - } - sqlparser::ast::SelectItem::ExprWithAlias { alias, expr } => { - let type_oid = match expr { - sqlparser::ast::Expr::Identifier(ident) => { - Self::get_catalog_column_type(&ident.value.to_lowercase(), query) - } - sqlparser::ast::Expr::CompoundIdentifier(parts) => { - let name = parts.last().map(|p| p.value.to_lowercase()).unwrap_or_else(|| "?column?".to_string()); - Self::get_catalog_column_type(stmt_name, query) - } - _ => PgType::Text.to_oid(), - }; - (alias.value.clone(), type_oid) - } - _ => ("?column?".to_string(), PgType::Text.to_oid()), - }; - - fields.push(FieldDescription { - name: col_name, - table_oid: 0, - column_id: (i + 1) as i16, - type_oid, - type_size: -1, - type_modifier: -1, - format: 0, - }); - } - } - - fields - } else { - Vec::new() - } - } else { - Vec::new() - } - } else { - Vec::new() - }; - - if !field_descriptions.is_empty() { - info!("Sending RowDescription with {} catalog fields in Describe", field_descriptions.len()); - - // Update the prepared statement with these field descriptions - // so they're available during Execute - drop(statements); - let mut statements_mut = session.prepared_statements.write().await; - if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { - stmt_mut.field_descriptions = field_descriptions.clone(); - info!("Updated statement '{}' with {} catalog field descriptions", stmt_name, field_descriptions.len()); - } - drop(statements_mut); - - framed.send(BackendMessage::RowDescription(field_descriptions)).await - .map_err(PgSqliteError::Io)?; - Ok(true) - } else { - // v29h: sqlparser failed (e.g. LATERAL unnest WITH ORDINALITY). - // Fallback: extract column aliases from SQL via regex. - if let Ok(re) = regex::Regex::new(r"(?i)AS\s+([a-zA-Z_]\w*)") { - let mut fallback_fields: Vec = Vec::new(); - for cap in re.captures_iter(query) { - if let Some(m) = cap.get(1) { - let col = m.as_str().to_string(); - if col.len() > 64 || col.contains('(') { continue; } - fallback_fields.push(FieldDescription { - name: col, - table_oid: 0, - column_id: (fallback_fields.len() + 1) as i16, - type_oid: PgType::Text.to_oid(), - type_size: -1, - type_modifier: -1, - format: 0, - }); - if fallback_fields.len() >= 100 { break; } - } - } - if !fallback_fields.is_empty() { - info!("v29h fallback: extracted {} columns via regex for {}", fallback_fields.len(), stmt_name); - drop(statements); - let mut statements_mut = session.prepared_statements.write().await; - if let Some(stmt_mut) = statements_mut.get_mut(stmt_name) { - stmt_mut.field_descriptions = fallback_fields.clone(); - } - drop(statements_mut); - framed.send(BackendMessage::RowDescription(fallback_fields)).await - .map_err(PgSqliteError::Io)?; - return Ok(true); - } - } - // Fallback: cannot determine fields -> caller sends NoData - info!("Could not determine catalog fields, Describe will send NoData"); - return Ok(false); - } - } else { - Ok(false) - } - } pub async fn handle_close( framed: &mut Framed, session: &Arc, @@ -5385,7 +4365,7 @@ impl ExtendedQueryHandler { t if t == PgType::Time.to_oid() || t == PgType::Timetz.to_oid() => { if let Ok(s) = String::from_utf8(bytes.clone()) { // Check if this is an integer (microseconds since midnight) - if let Some(micros) = crate::types::datetime_utils::time_text_to_micros(&s) { + if let Ok(micros) = s.parse::() { // Convert microseconds to formatted time use crate::types::datetime_utils::format_microseconds_to_time; let formatted = format_microseconds_to_time(micros); @@ -5474,15 +4454,6 @@ impl ExtendedQueryHandler { where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - // === PATCH v29l: the engine-facing rewrites live in ONE place === - // Describe's shape probe has to run the *identical* chain, otherwise - // Describe and Execute ask two different engines the same question -- - // that drift is exactly what produced the NoData-but-rows protocol - // errors (v29j fixed the catalog half of it, this fixes the rest). - // See Self::v29l_engine_sql for the individual rewrites and why. - let __v29l_owned = Self::v29l_engine_sql(query); - let query: &str = __v29l_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") { @@ -5530,36 +4501,6 @@ impl ExtendedQueryHandler { } } - // PATCH(FIX B): align the catalog result with the field descriptions that - // Describe already announced to the client. pgsqlite's catalog JOIN handler - // materialises only the main table's columns (e.g. 1 column for a query - // projecting "n.nspname, d.description"), while Describe promised the full - // projection. The mismatch breaks pgx/JDBC clients. Pad with NULLs. - { - let stmt_name_opt = { - let portals = session.portals.read().await; - portals.get(portal_name).map(|p| p.statement_name.clone()) - }; - if let Some(stmt_name) = stmt_name_opt { - let statements = session.prepared_statements.read().await; - if let Some(stmt) = statements.get(&stmt_name) { - let want = stmt.field_descriptions.len(); - let have = catalog_response.columns.len(); - if want > have { - info!("CATALOG ALIGN: padding catalog result from {} to {} columns", have, want); - for fd in stmt.field_descriptions.iter().skip(have) { - catalog_response.columns.push(fd.name.clone()); - } - for row in &mut catalog_response.rows { - while row.len() < want { - row.push(None); - } - } - } - } - } - } - catalog_response } else { info!("Query not intercepted, executing normally"); @@ -5585,50 +4526,7 @@ impl ExtendedQueryHandler { // - AND we have columns to describe // Note: We do NOT send RowDescription if switching to binary format because // Describe(Portal) would have already sent it with the correct format - // PATCH(FIX A): only send RowDescription at Execute when Describe did NOT - // already send one. Sending a second, contradictory RowDescription makes - // pgx (dbx) and JDBC (DBeaver) abort while parsing the response. - // PATCH(v29i): the extended query protocol forbids RowDescription - // in response to Execute -- real PostgreSQL only ever emits it for - // Describe. Emitting it here (which happened on every statement - // whose Describe answered NoData) is exactly what makes - // tokio-postgres/dbx abort with "unexpected message from server" - // and pgjdbc/DBeaver with "Received resultset tuples, but no field - // structure". The v29i truth probe makes Describe answer properly, - // so this illegal fallback is dead weight. Env escape hatch kept. - let legacy_exec_rowdesc = std::env::var("PGSQLITE_LEGACY_EXEC_ROWDESC") - .map(|v| v == "1") - .unwrap_or(false); - let would_have_sent = - stmt.field_descriptions.is_empty() && !response.columns.is_empty(); - if would_have_sent && !legacy_exec_rowdesc { - warn!( - "v29i: suppressing illegal Execute-stage RowDescription ({} cols); Describe never announced a shape for: {}", - response.columns.len(), query - ); - // v29l: suppressing the RowDescription alone is not enough. If - // we now stream DataRows the client holds rows it has no field - // structure for -- tokio-postgres/dbx aborts the connection with - // "error parsing response from server", pgjdbc with "Received - // resultset tuples, but no field structure". A clean - // ErrorResponse is strictly better: the client shows a real - // message and the connection stays usable (Sync resets it). - // Zero-row results are harmless (NoData + CommandComplete is a - // perfectly legal exchange), so they are let through. - let shape_error_enabled = std::env::var("PGSQLITE_V29L_SHAPE_ERROR") - .map(|v| v != "0") - .unwrap_or(true); - if shape_error_enabled && !response.rows.is_empty() { - let ncols = response.columns.len(); - let nrows = response.rows.len(); - drop(statements); - drop(portals); - return Err(PgSqliteError::NotSupported(format!( - "pgsqlite could not determine this statement's result shape at Describe time, so its {nrows} row(s) x {ncols} column(s) cannot be sent without corrupting the protocol stream. Rewrite the statement, or set PGSQLITE_V29L_SHAPE_ERROR=0 to fall back to the (lossy) old behaviour." - ))); - } - } - let needs_row_desc = would_have_sent && legacy_exec_rowdesc; + let needs_row_desc = stmt.field_descriptions.is_empty() && !response.columns.is_empty(); drop(statements); drop(portals); @@ -5911,26 +4809,6 @@ impl ExtendedQueryHandler { corrected_field_types[i] = 18; // PgType::Char.to_oid() info!("EXECUTE_SELECT: Corrected column '{}' type from {} to CHAR (18)", col_name, old_type); } - } - "attname" | "attacl" | "attoptions" | "attfdwoptions" | "attqual" => { - if i < corrected_field_types.len() { - corrected_field_types[i] = 25; // PgType::Text - } - } - "atttypid" | "attrelid" | "attindkey" | "attcollation" | "attarraytypid" => { - if i < corrected_field_types.len() { - corrected_field_types[i] = 26; // PgType::Oid - } - } - "attnum" | "attlen" | "attndims" | "attcacheoff" | "attmaxalignedlen" | "attstattarget" => { - if i < corrected_field_types.len() { - corrected_field_types[i] = 21; // PgType::Int2 - } - } - "atttypmod" => { - if i < corrected_field_types.len() { - corrected_field_types[i] = 23; // PgType::Int4 - } } _ => {} } @@ -6110,26 +4988,6 @@ impl ExtendedQueryHandler { if i < field_types.len() { field_types[i] = PgType::Char.to_oid(); } - } - "attname" | "attacl" | "attoptions" | "attfdwoptions" | "attqual" => { - if i < field_types.len() { - field_types[i] = 25; // PgType::Text - } - } - "atttypid" | "attrelid" | "attindkey" | "attcollation" | "attarraytypid" => { - if i < field_types.len() { - field_types[i] = 26; // PgType::Oid - } - } - "attnum" | "attlen" | "attndims" | "attcacheoff" | "attmaxalignedlen" | "attstattarget" => { - if i < field_types.len() { - field_types[i] = 21; // PgType::Int2 - } - } - "atttypmod" => { - if i < field_types.len() { - field_types[i] = 23; // PgType::Int4 - } } _ => {} } @@ -7039,100 +5897,6 @@ impl ExtendedQueryHandler { } } - - /// PATCH v15: infer a parameter's type from its *syntactic* context. - /// - /// The schema-based heuristic in `analyze_select_params` can only type a - /// parameter that is compared against a known column (`col = $n`). It is - /// blind to three very common shapes, all of which GUI clients emit: - /// - /// 1. `CAST($n AS BIGINT)` — the SQL-standard cast. The old code only - /// understood the PostgreSQL shorthand `$n::bigint`. - /// 2. `$n::double precision` — multi-word type names (the old regex - /// captured a single `\w+`, so it stopped at `double`). - /// 3. `LIMIT $n` / `OFFSET $n` — PostgreSQL types these as int8. - /// - /// Returning `None` means "no opinion", and the caller falls through to - /// the existing schema-based inference, so this is strictly additive. - fn infer_param_type_from_syntax(query: &str, idx: usize) -> Option { - let param = regex::escape(&format!("${idx}")); - - // Resolve a captured type name to an OID. `pg_type_name_to_oid` - // silently falls back to TEXT for names it does not know, which would - // otherwise make an unknown cast look like a confident "text" answer. - // Only trust a TEXT result when the name really is a text type. - let resolve = |raw: &str| -> Option { - let name = raw.trim().to_lowercase(); - let name = name.split_whitespace().collect::>().join(" "); - if name.is_empty() { - return None; - } - let oid = Self::pg_type_name_to_oid(&name); - if oid != PgType::Text.to_oid() - || matches!(name.as_str(), "text" | "varchar" | "character varying") - { - Some(oid) - } else { - None - } - }; - - // -- 1. CAST($n AS ) / CAST($n AS (len[,scale])) --------- - // The type-name character class excludes '(' and ')', so the greedy - // match stops cleanly before an optional length specifier. - let cast_pat = format!( - r"(?i)\bcast\s*\(\s*{param}\s+as\s+([a-zA-Z][a-zA-Z0-9_ ]*)\s*(?:\(\s*\d+\s*(?:,\s*\d+\s*)?\))?\s*\)" - ); - if let Ok(re) = regex::Regex::new(&cast_pat) - && let Some(c) = re.captures(query) - && let Some(m) = c.get(1) - && let Some(oid) = resolve(m.as_str()) - { - info!( - "Inferred parameter {} type from CAST(...) syntax: {} (OID {})", - idx, - m.as_str().trim(), - oid - ); - return Some(oid); - } - - // -- 2. $n :: (multi-word type names) ----------------------- - // Note `$1::` cannot match inside `$10::` because the character right - // after `$1` would be `0`, not `:` — so no lookahead is needed. - let colon_pat = format!(r"(?i){param}\s*::\s*([a-zA-Z][a-zA-Z0-9_ ]*)"); - if let Ok(re) = regex::Regex::new(&colon_pat) - && let Some(c) = re.captures(query) - && let Some(m) = c.get(1) - && let Some(oid) = resolve(m.as_str()) - { - info!( - "Inferred parameter {} type from :: cast syntax: {} (OID {})", - idx, - m.as_str().trim(), - oid - ); - return Some(oid); - } - - // -- 3. LIMIT $n / OFFSET $n ---------------------------------------- - // PostgreSQL declares both as int8. The trailing \b keeps `$1` from - // matching the `$1` prefix of `$10`. - let lim_pat = format!(r"(?i)\b(?:limit|offset)\s+{param}\b"); - if let Ok(re) = regex::Regex::new(&lim_pat) - && re.is_match(query) - { - let oid = PgType::Int8.to_oid(); - info!( - "Inferred parameter {} type from LIMIT/OFFSET position: int8 (OID {})", - idx, oid - ); - return Some(oid); - } - - None - } - /// Analyze SELECT query to determine parameter types from WHERE clause async fn analyze_select_params(query: &str, db: &Arc, session: &Arc) -> Result, PgSqliteError> { // First, check for explicit parameter casts like $1::int4 @@ -7162,15 +5926,7 @@ impl ExtendedQueryHandler { if found_type { continue; } - - // PATCH v15: before falling back to schema inference (and ultimately - // to text), try the syntactic shapes the column heuristic cannot see: - // CAST($n AS T), multi-word $n::T, and LIMIT/OFFSET $n. - if let Some(oid) = Self::infer_param_type_from_syntax(query, i) { - param_types.push(oid); - continue; - } - + // If no explicit cast, try to infer from column comparisons // Extract table name from SELECT query (only if needed) let table_name = if let Some(name) = extract_table_name_from_select(query) { @@ -7542,20 +6298,6 @@ impl ExtendedQueryHandler { } - -/// 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") -} - /// Extract table name from SELECT query fn extract_table_name_from_select(query: &str) -> Option { // Look for FROM clause using case-insensitive search @@ -7573,10 +6315,6 @@ 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) { - return None; - } Some(table_name.to_string()) } else { None @@ -7767,4 +6505,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") +} From 059bdd5f9ff0737050ade60a5be2e9d764d0d362 Mon Sep 17 00:00:00 2001 From: lijiajun Date: Thu, 13 Aug 2026 12:14:57 +0800 Subject: [PATCH 04/13] fix(rebase): restore user-added symbols dropped by erans-take resolution Rebasing v40+v41 onto erans #87/#88 left several user-added symbols unresolved because conflicted files were resolved by taking erans' version: - translator/mod.rs: declare the user-added escape/ilike/limit translator modules (files already present, only the mod declarations were dropped). - translator/schema_prefix_translator.rs: restore user's SchemaPrefixTranslator (strip_public_prefix + translate_query wired to LimitTranslator). - catalog/query_interceptor.rs: re-add CatalogInterceptor::parse_pg_type_modifiers and fix the v23_* test calls to use Self::parse_select (it is an assoc fn, not a free fn). --- src/catalog/query_interceptor.rs | 38 +- src/translator/mod.rs | 6 + src/translator/schema_prefix_translator.rs | 599 +++++++++++++++------ 3 files changed, 461 insertions(+), 182 deletions(-) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 5ef814ed..e5136389 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -1535,38 +1535,62 @@ impl CatalogInterceptor { _ => 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(&parse_select( + 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(&parse_select( + 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(&parse_select( + 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(&parse_select( + 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(&parse_select( + 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(&parse_select( + 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(&parse_select( + assert!(CatalogInterceptor::from_is_sqlite_resolvable(&Self::parse_select( "SELECT count(*) FROM pg_class" ))); } diff --git a/src/translator/mod.rs b/src/translator/mod.rs index 29b0da3e..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; diff --git a/src/translator/schema_prefix_translator.rs b/src/translator/schema_prefix_translator.rs index 2c919151..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; + } + } + let end = if closed { j + 1 } else { n }; + for ci in i..end { + out.push(chars[ci]); + } + i = end; + prev_ident = true; + 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; } - i += 1; - break; } - i += 1; } - 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; + 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); - } - - // 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" - ]; + // === 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" + ]; - 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'" + ); } - /// `'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_quoted_public_with_unquoted_table() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix(r#"SELECT * FROM "public".bath_records"#), + "SELECT * FROM bath_records" + ); } - /// 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_quoted_table() { + 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_unquoted_public_with_limit() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT * FROM public.bath_records LIMIT 5"), + "SELECT * FROM bath_records LIMIT 5" + ); } - /// 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 strips_multiple_public_prefixes_in_join() { 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 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" ); } #[test] - fn test_unterminated_literal_is_left_alone() { - let query = "SELECT 'information_schema.tables"; - assert_eq!(SchemaPrefixTranslator::translate_query(query), query); + fn leaves_republic_word_alone() { + assert_eq!( + SchemaPrefixTranslator::strip_public_prefix("SELECT republic.id FROM republic"), + "SELECT republic.id FROM republic" + ); + } + + #[test] + 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'" + ); } } From 071a1e0587d56f8ca93d65a9f189feb0a51386f7 Mon Sep 17 00:00:00 2001 From: sindo Date: Fri, 14 Aug 2026 10:43:01 +0800 Subject: [PATCH 05/13] fix(extended): restore v29i catalog truth probe (DataGrip table-list regression) Rebase (commit 7dbae72) reverted extended.rs to erans' clean version and dropped the v29i catalog truth probe. Without it, Describe emitted NoData (0 columns) for catalog queries whose Parse phase cannot infer the result shape -- e.g. the DBX/JetBrains object-list query -- so DataGrip rendered an empty table list even though Execute returned 16 rows. Re-add v29i_fields_from_columns + v29i_probe_catalog_columns and call the probe at the top of handle_describe (statement path): when the announced field count disagrees with what CatalogInterceptor actually returns, we realign and send a correct RowDescription (memoised per query text). Verified on dune (v45, md5 0171742ed0a25c2bf4790b5ebc7326c1): an extended-protocol probe of the object-list query now returns 9 columns / 16 rows; the server log shows 'v29i truth probe: announced 0 fields but the catalog returns 9 -> realigning'. --- src/catalog/query_interceptor.rs | 154 +++++++++++++++++++++++++++++++ src/query/extended.rs | 137 +++++++++++++++++++++++++-- 2 files changed, 284 insertions(+), 7 deletions(-) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index e5136389..9d51bf2d 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -725,6 +725,150 @@ impl CatalogInterceptor { 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 })) + } + } + } + // 从 dbx 列默认值查询提取表名: WHERE ... c.relname = 。 // 参数化版本被 v29i 探测替换成 c.relname = NULL, 提取不到则返回 None。 fn v42_extract_attrdef_table(query: &str) -> Option { @@ -1647,6 +1791,16 @@ pub async fn intercept_query(query: &str, db: Arc, session: Option 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"); + } + + 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 +2043,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 +2104,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()); From 71eb82e4504a197acc8f9eea5d2b72857c8d5016 Mon Sep 17 00:00:00 2001 From: pgsqlite-local Date: Mon, 17 Aug 2026 10:24:22 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix(catalog):=20=E4=BF=AE=E5=A4=8D=20dbx?= =?UTF-8?q?=20Tables=20=E8=8A=82=E7=82=B9=E8=A1=A8=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E7=A9=BA=E7=99=BD=20(v47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pgsqlite 的 pg_inherits 视图缺 ihparent 列,dbx 点开 Tables 节点发的查询 (LEFT JOIN pg_inherits i ... pc.oid = i.ihparent + LIMIT -1 OFFSET) 报 'no such column: i.ihparent' -> 表列表空白。新增 v47_dbx_table_list handler, 把继承链去掉 (SQLite 无继承, parent_schema/parent_name 恒 NULL),保留 table_name/table_type/table_comment 三列,列序严格对齐。 含此前未提交的 v45(schema-list)/v46(extension-list)。 Verified: 真实 Tables 查询 simple+extended 均返回 16 行 5 列,Describe==Execute 列数一致;v40/v41/v42/v43/v45/v46 回归无破坏。 --- src/catalog/query_interceptor.rs | 166 +++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 9d51bf2d..793c8f60 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -869,6 +869,157 @@ impl CatalogInterceptor { } } + // === 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 { @@ -1801,6 +1952,21 @@ pub async fn intercept_query(query: &str, db: Arc, session: Option Date: Mon, 17 Aug 2026 11:54:39 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(extended):=20Parse=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E4=B8=BA=20catalog=20=E6=9F=A5=E8=AF=A2=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9C=9F=E5=AE=9E=E5=88=97=E6=95=B0=20(v47fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbx 走 extended 协议且用 Describe(Portal) 获取列信息,而 truth probe 只在 Describe(Statement) 运行。Parse 阶段对 catalog 查询故意设 fd_len=0 (handle_parse 526行 Vec::new()),导致 Describe(Portal) 直接发 NoData(0列), dbx 据此把 Execute 的 5 列 DataRow 按 0 列丢弃 -> 表列表/对象列表空白。 修复:Parse 阶段对 catalog 查询调用 v29i_probe_catalog_columns 探测真实列数 并写入 field_descriptions,使 Describe(S)/Describe(Portal) 从一开始就拿到 正确列数,不再依赖 Describe(Statement) 的 realigning 补救。 Verified: 模拟 dbx 完整流程 Parse->Describe(S)->Bind->Describe(P)->Execute, Describe(S)=Describe(P)=Execute 字段数=5,16 行;simple 路径 16 行 5 列。 已部署 v47fix (md5 2f2a2ee8...)。 --- src/query/extended.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/query/extended.rs b/src/query/extended.rs index 17975315..4d08b83a 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -528,8 +528,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 @@ -4587,7 +4598,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; From ef354deeb78b892eddb5ee5f22ecc59309204504 Mon Sep 17 00:00:00 2001 From: pgsqlite-local Date: Mon, 17 Aug 2026 14:51:27 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix(extended):=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8C=96=20LIMIT/OFFSET=20=E6=8E=A2=E6=B5=8B=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E5=8C=96=20(v47fix2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v29i_probe_catalog_columns 把 $1..$32 替换成 NULL 后执行探测。dbx 表列表 查询含 LIMIT CAST($4 AS BIGINT) OFFSET CAST($5 AS BIGINT),参数化替换后 变成 LIMIT CAST(NULL AS BIGINT),在 SQLite 里是非法语法 (LIMIT must be an integer),导致整条探测失败、返回 None,Parse 阶段 fd_len 仍为 0 -> Describe(Portal) 发 NoData(0列) -> 客户端把数据按 0 列丢弃 -> 表列表空白。 列数与行数无关,探测前将 LIMIT/OFFSET NULL 修正为合法形态 (LIMIT 0 / 移除 OFFSET) 再探测。 已部署 v47fix2 (md5 0de88805...)。验证: 真参数化探针 Parse->Describe(S)->Bind->Describe(P)->Execute 三阶段列数均=5, 16 行; dbx 重连后自动发表列表查询, 服务端 execute_select 正常无报错。 --- src/query/extended.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/query/extended.rs b/src/query/extended.rs index 4d08b83a..c94e24c2 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -2019,6 +2019,18 @@ impl ExtendedQueryHandler { 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, From b55d8dd611beaf01204762bc6db2d3c5cea1b638 Mon Sep 17 00:00:00 2001 From: sindo Date: Tue, 18 Aug 2026 16:57:38 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix(extended):=20Describe(Portal)=20?= =?UTF-8?q?=E7=A9=BA=E5=88=86=E6=94=AF=E8=A1=A5=20catalog=20=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=EF=BC=8C=E4=BF=AE=E5=A4=8D=20dbx=20=E8=A1=A8=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbx/DataGrip 只走 Describe(Portal) 取列数,且 Parse 阶段 catalog 探测未落到 statement(field_descriptions 恒为 0),导致 Describe(Portal) 发 NoData(0列),客户端把 Execute 的 DataRow 按 0 列丢弃 -> 表列表/对象列表等节点空白。 在 Describe(Portal) field_descriptions 为空且为 catalog 查询时, 复用 v29i_probe_catalog_columns 探测真实列数并发送 RowDescription, 与 Describe(Statement) 的 truth probe 行为对齐。已用真实 Parse->Bind->Describe(Portal)->Execute 探针验证:Describe(Portal) 宣告 5 列、Execute 返回 16 行。部署 tag=v47fix3 md5=c0f11d40... --- src/query/extended.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/query/extended.rs b/src/query/extended.rs index c94e24c2..813ffafd 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -2728,6 +2728,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)?; } From c66a33be7b38e6fb6a6e02514f85e08a523eacf1 Mon Sep 17 00:00:00 2001 From: sindo Date: Tue, 18 Aug 2026 22:37:25 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix(extended):=20analyze=5Fselect=5Fparam?= =?UTF-8?q?s=20=E8=AF=86=E5=88=AB=20CAST($N=20AS=20TYPE)=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20dbx=20=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B=E9=94=99?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbx/DataGrip 表列表查询用 LIMIT CAST($4 AS BIGINT) OFFSET CAST($5 AS BIGINT) 的参数写法,但 analyze_select_params 只识别 $N::type,漏掉了 CAST($N AS TYPE),导致 $4/$5 被默认推断成 text(25) 而非 int8(20)。 服务端 Describe(S) 返回全 text 的参数类型,pgjdbc 拿到错误类型后在 Bind 阶段序列化参数时报 'error serializing parameter N'。 补上 CAST($N AS TYPE) 正则识别。验证:传空 param_types 触发推断, ParameterDescription 从 [25,25,25,25,25] 恢复为 [25,25,25,20,20]。 部署 tag=v47fix4 md5=f25c42c7185b11f15ed161b5892dab99 --- src/query/extended.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/query/extended.rs b/src/query/extended.rs index 813ffafd..72765027 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -6099,7 +6099,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; } From 2362daaf4a06788faaea7ef97ee3d610e99509da Mon Sep 17 00:00:00 2001 From: sindo Date: Tue, 18 Aug 2026 22:52:42 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix(extended):=20execute=5Fselect=20?= =?UTF-8?q?=E8=A1=A5=20strip=20public=20schema=20=E5=89=8D=E7=BC=80?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=20dbx=20=E7=82=B9=E5=BC=80=E8=A1=A8=E6=8A=A5?= =?UTF-8?q?=20no=20such=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbx 点开表发 SELECT * FROM "public"."bath_records",而 extended 协议的 handle_execute 走的是 extended.rs 自己的 execute_select(4623),它直接 db.query 不经过 executor.rs 那条带 strip_public_prefix 的路径,SQLite 报 'no such table: public.bath_records'。 在 execute_select 入口补 strip_public_prefix(与 executor.rs PATCH v27 对齐), 去掉 public. schema 前缀。验证:SELECT * FROM "public"."bath_records" 从报 no such table 变为正常返回 9 列(空表 0 行),users 表正常返回 2 行。 部署 tag=v47fix5 md5=34c364d3b6d3c4a0fd96d20c7cd0b40d --- src/query/extended.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/query/extended.rs b/src/query/extended.rs index 72765027..f529fd7d 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -4631,6 +4631,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") { From 56afd02f93c1bf52d90b128311dea8c0fb000c32 Mon Sep 17 00:00:00 2001 From: sindo Date: Tue, 18 Aug 2026 23:07:41 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(extended):=20Describe(Portal)=20?= =?UTF-8?q?=E5=89=8D=E7=A7=BB=20catalog=20truth=20probe=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=96=E9=94=AE=E6=9F=A5=E8=AF=A2=E5=88=97=E6=95=B0=E9=94=99?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbx 点开表发外键查询(getImportedKeys, information_schema, 7 列),但 Parse 阶段 field_descriptions 被错算成 3 列。dbx 走 Describe(Portal) 取列数, v47fix3 只在 field_descriptions 为空时补探测,没覆盖'非空但列数错'的情况, 导致 Describe(P) 发 3 列 RowDescription、Execute 发 7 列 DataRow,列数错位, pgjdbc 报 'unexpected message from server'。 将 catalog truth probe 前移到 Describe(Portal) 入口:对 catalog 查询探测真实 列数,与 field_descriptions 不一致则 realign。验证:外键查询 Describe(P) 从 3 列修正为 7 列。部署 tag=v47fix6 md5=560c5b864c3451db2230eef0793ec88f --- src/query/extended.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/query/extended.rs b/src/query/extended.rs index f529fd7d..ce39345e 100644 --- a/src/query/extended.rs +++ b/src/query/extended.rs @@ -2653,6 +2653,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}")))?; From 412e30f68d5e2d97c69e7c67e2a68425f034adf5 Mon Sep 17 00:00:00 2001 From: sindo Date: Wed, 19 Aug 2026 15:43:17 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(extended):=20handle=5Fparse=20?= =?UTF-8?q?=E8=A1=A5=20strip=20public=20=E5=89=8D=E7=BC=80=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E8=A1=A8=E6=95=B0=E6=8D=AE=E6=9F=A5=E8=AF=A2=E5=88=97?= =?UTF-8?q?=E6=95=B0=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbx 点开表发 SELECT * FROM "public"."bath_records"(非 catalog),Parse 阶段 translated_for_analysis 没 strip public -> field_descriptions 测试查询失败 -> field_descriptions 空 -> Describe(P) 发 NoData(0列),而 Execute 阶段 execute_select (v47fix5) strip 后返回 9 列 -> 列数错位 -> pgjdbc 'unexpected message from server'。 在 handle_parse 翻译链路补 strip_public_prefix(与 v47fix5 对齐),让 Parse 阶段 field_descriptions 正确算出 9 列。验证:Describe(P) 从 NoData 变为 9 列 RowDescription。 部署 tag=v47fix7 md5=a11fb86ddc99a7b89a7b5d84ef69c86a --- src/query/extended.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/query/extended.rs b/src/query/extended.rs index ce39345e..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) {