|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | + |
| 3 | +//! Integration tests: a positional `INSERT`/`UPSERT` `VALUES` clause (no |
| 4 | +//! explicit column list) must bind each value to the collection's |
| 5 | +//! DECLARED column names, not synthetic `col0`, `col1`, ... placeholders. |
| 6 | +//! |
| 7 | +//! The defect (#202): `INSERT INTO probe VALUES (42, 'stored?')` on |
| 8 | +//! `probe (id INT PRIMARY KEY, note TEXT)` stored the row under `col0`/ |
| 9 | +//! `col1` instead of `id`/`note`. `SELECT *` still looked correct (it |
| 10 | +//! doesn't care about names), but any named projection or `WHERE` |
| 11 | +//! predicate on `id`/`note` silently saw nothing — the row was there, |
| 12 | +//! just unaddressable under its real column names. |
| 13 | +//! |
| 14 | +//! All tests use `plan_sql()` with a minimal catalog (mirrors |
| 15 | +//! `point_get_operand_order.rs` / `schema_qualified_rejection.rs`). |
| 16 | +
|
| 17 | +use nodedb_sql::types::{CollectionInfo, ColumnInfo, EngineType, SqlDataType}; |
| 18 | +use nodedb_sql::{SqlCatalog, SqlCatalogError, SqlError, SqlPlan, SqlValue, plan_sql}; |
| 19 | +use nodedb_types::DatabaseId; |
| 20 | + |
| 21 | +struct Catalog; |
| 22 | + |
| 23 | +impl SqlCatalog for Catalog { |
| 24 | + fn get_collection( |
| 25 | + &self, |
| 26 | + _: DatabaseId, |
| 27 | + name: &str, |
| 28 | + ) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> { |
| 29 | + let info = match name { |
| 30 | + // Strict document collection with a declared 2-column schema — |
| 31 | + // the exact shape from the issue repro. |
| 32 | + "probe" => Some(CollectionInfo { |
| 33 | + name: "probe".into(), |
| 34 | + engine: EngineType::DocumentStrict, |
| 35 | + columns: vec![ |
| 36 | + ColumnInfo { |
| 37 | + name: "id".into(), |
| 38 | + data_type: SqlDataType::Int64, |
| 39 | + nullable: false, |
| 40 | + is_primary_key: true, |
| 41 | + default: None, |
| 42 | + raw_type: Some("INT".into()), |
| 43 | + }, |
| 44 | + ColumnInfo { |
| 45 | + name: "note".into(), |
| 46 | + data_type: SqlDataType::String, |
| 47 | + nullable: true, |
| 48 | + is_primary_key: false, |
| 49 | + default: None, |
| 50 | + raw_type: Some("TEXT".into()), |
| 51 | + }, |
| 52 | + ], |
| 53 | + primary_key: Some("id".into()), |
| 54 | + has_auto_tier: false, |
| 55 | + indexes: Vec::new(), |
| 56 | + bitemporal: false, |
| 57 | + primary: nodedb_types::PrimaryEngine::Document, |
| 58 | + vector_primary: None, |
| 59 | + partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed, |
| 60 | + }), |
| 61 | + // Schemaless collection: no declared column order exists to |
| 62 | + // bind positionally to. The pre-existing `col{i}` fallback is |
| 63 | + // the correct, unchanged behaviour here. |
| 64 | + "loose" => Some(CollectionInfo { |
| 65 | + name: "loose".into(), |
| 66 | + engine: EngineType::DocumentSchemaless, |
| 67 | + columns: Vec::new(), |
| 68 | + primary_key: Some("id".into()), |
| 69 | + has_auto_tier: false, |
| 70 | + indexes: Vec::new(), |
| 71 | + bitemporal: false, |
| 72 | + primary: nodedb_types::PrimaryEngine::Document, |
| 73 | + vector_primary: None, |
| 74 | + partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed, |
| 75 | + }), |
| 76 | + // KV collection: key/value are separated by the KV insert path |
| 77 | + // matching column names against the "key"/"ttl" sentinels, not |
| 78 | + // by declared column order. Pinned so a future refactor doesn't |
| 79 | + // accidentally route KV through the new positional-binding path. |
| 80 | + "kv_probe" => Some(CollectionInfo { |
| 81 | + name: "kv_probe".into(), |
| 82 | + engine: EngineType::KeyValue, |
| 83 | + columns: Vec::new(), |
| 84 | + primary_key: Some("key".into()), |
| 85 | + has_auto_tier: false, |
| 86 | + indexes: Vec::new(), |
| 87 | + bitemporal: false, |
| 88 | + primary: nodedb_types::PrimaryEngine::Document, |
| 89 | + vector_primary: None, |
| 90 | + partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed, |
| 91 | + }), |
| 92 | + _ => None, |
| 93 | + }; |
| 94 | + Ok(info) |
| 95 | + } |
| 96 | + |
| 97 | + fn lookup_array(&self, _name: &str) -> Option<nodedb_sql::types::ArrayCatalogView> { |
| 98 | + None |
| 99 | + } |
| 100 | + |
| 101 | + fn array_exists(&self, _name: &str) -> bool { |
| 102 | + false |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +fn plan_one(sql: &str) -> SqlPlan { |
| 107 | + let mut plans = plan_sql(sql, &Catalog).expect("planning must succeed"); |
| 108 | + assert_eq!(plans.len(), 1, "expected exactly one plan for: {sql}"); |
| 109 | + plans.pop().unwrap() |
| 110 | +} |
| 111 | + |
| 112 | +fn insert_rows(plan: SqlPlan) -> Vec<Vec<(String, SqlValue)>> { |
| 113 | + match plan { |
| 114 | + SqlPlan::Insert { rows, .. } => rows, |
| 115 | + SqlPlan::Upsert { rows, .. } => rows, |
| 116 | + other => panic!("expected an Insert or Upsert plan, got {other:?}"), |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +/// The core regression (#202): a positional INSERT must bind its |
| 121 | +/// values to the DECLARED column names, not `col0`/`col1`. |
| 122 | +#[test] |
| 123 | +fn positional_insert_binds_declared_column_names() { |
| 124 | + let plan = plan_one("INSERT INTO probe VALUES (42, 'stored?')"); |
| 125 | + let rows = insert_rows(plan); |
| 126 | + assert_eq!(rows.len(), 1); |
| 127 | + assert_eq!( |
| 128 | + rows[0], |
| 129 | + vec![ |
| 130 | + ("id".to_string(), SqlValue::Int(42)), |
| 131 | + ("note".to_string(), SqlValue::String("stored?".into())), |
| 132 | + ], |
| 133 | + "positional INSERT must bind values to the declared column names \ |
| 134 | + (`id`, `note`), not synthetic `col0`/`col1`" |
| 135 | + ); |
| 136 | +} |
| 137 | + |
| 138 | +/// Control: a named insert must keep binding exactly as before — this fix |
| 139 | +/// must not perturb the named-column path. |
| 140 | +#[test] |
| 141 | +fn named_insert_still_binds_by_name() { |
| 142 | + let plan = plan_one("INSERT INTO probe (id, note) VALUES (43, 'named')"); |
| 143 | + let rows = insert_rows(plan); |
| 144 | + assert_eq!( |
| 145 | + rows[0], |
| 146 | + vec![ |
| 147 | + ("id".to_string(), SqlValue::Int(43)), |
| 148 | + ("note".to_string(), SqlValue::String("named".into())), |
| 149 | + ] |
| 150 | + ); |
| 151 | +} |
| 152 | + |
| 153 | +/// A VALUES row with MORE values than the collection has declared columns |
| 154 | +/// must be rejected outright — inventing a `col2` slot for the overflow |
| 155 | +/// would reproduce the exact unaddressable-column bug this fix closes. |
| 156 | +#[test] |
| 157 | +fn positional_insert_arity_overflow_is_rejected() { |
| 158 | + let err = plan_sql("INSERT INTO probe VALUES (1, 'a', 'extra')", &Catalog) |
| 159 | + .expect_err("row has 3 values but `probe` declares only 2 columns"); |
| 160 | + assert!( |
| 161 | + matches!( |
| 162 | + err, |
| 163 | + SqlError::InsertColumnArityMismatch { |
| 164 | + given: 3, |
| 165 | + declared: 2, |
| 166 | + .. |
| 167 | + } |
| 168 | + ), |
| 169 | + "expected InsertColumnArityMismatch {{ given: 3, declared: 2, .. }}, got {err:?}" |
| 170 | + ); |
| 171 | +} |
| 172 | + |
| 173 | +/// Schemaless collections have no declared column order to bind |
| 174 | +/// positionally to — the pre-existing `col{i}` fallback remains the |
| 175 | +/// correct, unchanged behaviour there. |
| 176 | +#[test] |
| 177 | +fn positional_insert_on_schemaless_collection_keeps_col_i_fallback() { |
| 178 | + let plan = plan_one("INSERT INTO loose VALUES (1, 'a')"); |
| 179 | + let rows = insert_rows(plan); |
| 180 | + assert_eq!( |
| 181 | + rows[0], |
| 182 | + vec![ |
| 183 | + ("col0".to_string(), SqlValue::Int(1)), |
| 184 | + ("col1".to_string(), SqlValue::String("a".into())), |
| 185 | + ], |
| 186 | + "schemaless collections have no declared columns to bind to; the \ |
| 187 | + `col{{i}}` fallback must remain the last resort" |
| 188 | + ); |
| 189 | +} |
| 190 | + |
| 191 | +/// Same defect, UPSERT path: `UPSERT INTO t VALUES (...)` is pre-processed |
| 192 | +/// to the same `plan_upsert` planner path and must bind identically. |
| 193 | +#[test] |
| 194 | +fn positional_upsert_binds_declared_column_names() { |
| 195 | + let plan = plan_one("UPSERT INTO probe VALUES (44, 'upserted')"); |
| 196 | + let rows = insert_rows(plan); |
| 197 | + assert_eq!( |
| 198 | + rows[0], |
| 199 | + vec![ |
| 200 | + ("id".to_string(), SqlValue::Int(44)), |
| 201 | + ("note".to_string(), SqlValue::String("upserted".into())), |
| 202 | + ] |
| 203 | + ); |
| 204 | +} |
| 205 | + |
| 206 | +/// KV collections split key/value by matching column *names* against |
| 207 | +/// `pk_col`/`"key"`/`"ttl"`, not by declared column order. A positional |
| 208 | +/// insert supplies no column list, so there is no key to bind to — |
| 209 | +/// `build_kv_insert_plan` would otherwise emit an empty-keyed, empty-valued |
| 210 | +/// entry (every such row colliding, all data silently dropped). It is |
| 211 | +/// rejected outright, mirroring the arity-overflow decision. |
| 212 | +#[test] |
| 213 | +fn positional_insert_on_kv_collection_is_rejected() { |
| 214 | + let err = plan_sql("INSERT INTO kv_probe VALUES ('k1', 'v1')", &Catalog) |
| 215 | + .expect_err("positional KV insert has no key/value column names to bind to"); |
| 216 | + assert!( |
| 217 | + matches!(err, SqlError::PositionalKvInsertUnsupported { .. }), |
| 218 | + "expected PositionalKvInsertUnsupported, got {err:?}" |
| 219 | + ); |
| 220 | +} |
0 commit comments