Skip to main content

oxisql_sqlite_compat/
types.rs

1//! Value conversions between `limbo` and `oxisql_core`.
2//!
3//! # Mapping table
4//!
5//! | Limbo `limbo::Value`    | OxiSQL [`oxisql_core::Value`] |
6//! |-------------------------|-------------------------------|
7//! | `Integer(i64)`          | `Value::I64(i64)`             |
8//! | `Real(f64)`             | `Value::F64(f64)`             |
9//! | `Text(String)`          | `Value::Text(String)`         |
10//! | `Blob(Vec<u8>)`         | `Value::Blob(Vec<u8>)`        |
11//! | `Null`                  | `Value::Null`                 |
12//!
13//! The reverse mapping (OxiSQL → Limbo) is used when binding `$N` parameters
14//! after they have been rewritten to `?` placeholders by [`rewrite_params`].
15//! Rich OxiSQL types that have no Limbo counterpart (Timestamp, Date, Time,
16//! Uuid, Decimal, Json) are stored as their string or integer representations.
17//!
18//! When the column's declared SQL type is known, [`limbo_to_core_typed`] can
19//! produce richer typed [`oxisql_core::Value`] variants instead of the raw storage-class
20//! representations.
21
22use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
23use limbo::Value as LimboValue;
24use oxisql_core::Value as CoreValue;
25
26use crate::error::SqliteCompatError;
27
28/// Unix epoch as a `NaiveDate` — used for day-count computations.
29const UNIX_EPOCH_DATE: fn() -> NaiveDate =
30    || NaiveDate::from_ymd_opt(1970, 1, 1).expect("epoch date is valid");
31
32/// Convert a single `limbo::Value` into an `oxisql_core::Value`.
33///
34/// This is the untyped fallback — all five Limbo storage classes are mapped
35/// directly without any enrichment.  Prefer [`limbo_to_core_typed`] when the
36/// column's declared SQL type is available.
37///
38/// # Errors
39///
40/// Returns [`SqliteCompatError::TypeMap`] for value variants that cannot be
41/// represented (currently none — all five Limbo types are mapped).
42pub fn limbo_to_core(val: LimboValue) -> Result<CoreValue, SqliteCompatError> {
43    limbo_to_core_typed(val, None)
44}
45
46/// Convert a single `limbo::Value` into a (possibly richer) `oxisql_core::Value`
47/// using an optional declared SQL type hint.
48///
49/// When `decl_type` matches a known rich type the stored integer or text is
50/// lifted into the corresponding typed variant:
51///
52/// | Declared type            | Storage       | Produced variant           |
53/// |--------------------------|---------------|----------------------------|
54/// | `DATE` (not DATETIME)    | Text / Int    | `Value::Date(days)`        |
55/// | `DATETIME` / `TIMESTAMP` | Text / Int    | `Value::Timestamp(µs)`     |
56/// | `TIME` (not TIMESTAMP)   | Text / Int    | `Value::Time(µs)`          |
57/// | `UUID`                   | Text / Blob16 | `Value::Uuid(u128)`        |
58///
59/// Comparison is case-insensitive and prefix-based (e.g. `"timestamp with tz"`
60/// still triggers `TIMESTAMP` handling).  If parsing fails the value falls back
61/// to the untyped variant instead of returning an error.
62///
63/// # Errors
64///
65/// Returns [`SqliteCompatError::TypeMap`] for value variants that cannot be
66/// represented (currently none — all five Limbo types are mapped).
67pub fn limbo_to_core_typed(
68    val: LimboValue,
69    decl_type: Option<&str>,
70) -> Result<CoreValue, SqliteCompatError> {
71    // Normalise the declared type for prefix matching (upper-case once).
72    let dt_upper: Option<String> = decl_type.map(|s| s.to_ascii_uppercase());
73    let dt = dt_upper.as_deref();
74
75    let v = match val {
76        LimboValue::Null => CoreValue::Null,
77        LimboValue::Real(f) => CoreValue::F64(f),
78
79        LimboValue::Integer(n) => {
80            if let Some(dt) = dt {
81                if is_datetime_type(dt) {
82                    return Ok(CoreValue::Timestamp(n));
83                } else if is_date_type(dt) {
84                    // Stored as days since epoch.
85                    let days = i32::try_from(n).unwrap_or(n as i32);
86                    return Ok(CoreValue::Date(days));
87                } else if is_time_type(dt) {
88                    return Ok(CoreValue::Time(n));
89                }
90            }
91            CoreValue::I64(n)
92        }
93
94        LimboValue::Text(s) => {
95            if let Some(dt) = dt {
96                if is_datetime_type(dt) {
97                    if let Some(ts) = parse_text_as_timestamp(&s) {
98                        return Ok(CoreValue::Timestamp(ts));
99                    }
100                } else if is_date_type(dt) {
101                    if let Some(days) = parse_text_as_date(&s) {
102                        return Ok(CoreValue::Date(days));
103                    }
104                } else if is_time_type(dt) {
105                    if let Some(us) = parse_text_as_time(&s) {
106                        return Ok(CoreValue::Time(us));
107                    }
108                } else if is_uuid_type(dt) {
109                    if let Some(u) = parse_text_as_uuid(&s) {
110                        return Ok(CoreValue::Uuid(u));
111                    }
112                }
113            }
114            CoreValue::Text(s)
115        }
116
117        LimboValue::Blob(b) => {
118            // UUID stored as raw 16-byte big-endian blob.
119            if let Some(dt) = dt {
120                if is_uuid_type(dt) && b.len() == 16 {
121                    let mut arr = [0u8; 16];
122                    arr.copy_from_slice(&b);
123                    let u = u128::from_be_bytes(arr);
124                    return Ok(CoreValue::Uuid(u));
125                }
126            }
127            CoreValue::Blob(b)
128        }
129    };
130    Ok(v)
131}
132
133// ── Declared-type predicate helpers ───────────────────────────────────────────
134
135/// True when `dt` (already upper-cased) indicates a DATETIME / TIMESTAMP type.
136///
137/// Must be checked **before** [`is_date_type`] because "DATETIME" starts with
138/// "DATE" and "TIMESTAMP" starts with "TIME".
139#[inline]
140fn is_datetime_type(dt: &str) -> bool {
141    dt.starts_with("DATETIME") || dt.starts_with("TIMESTAMP")
142}
143
144/// True when `dt` (already upper-cased) indicates a DATE-only type.
145///
146/// Checked only after ruling out DATETIME.
147#[inline]
148fn is_date_type(dt: &str) -> bool {
149    dt.starts_with("DATE")
150}
151
152/// True when `dt` (already upper-cased) indicates a TIME-of-day type.
153///
154/// Checked only after ruling out TIMESTAMP / DATETIME.
155#[inline]
156fn is_time_type(dt: &str) -> bool {
157    dt.starts_with("TIME")
158}
159
160/// True when `dt` (already upper-cased) indicates a UUID type.
161#[inline]
162fn is_uuid_type(dt: &str) -> bool {
163    dt.starts_with("UUID")
164}
165
166// ── Text parsing helpers ──────────────────────────────────────────────────────
167
168/// Parse an ISO-8601 datetime string into microseconds since Unix epoch.
169///
170/// Accepts `"YYYY-MM-DDTHH:MM:SS"`, `"YYYY-MM-DD HH:MM:SS"`, and variants
171/// with optional sub-second fractions.  Returns `None` on parse failure.
172fn parse_text_as_timestamp(s: &str) -> Option<i64> {
173    // Try the two common separator styles (T and space).
174    let fmt_t = "%Y-%m-%dT%H:%M:%S%.f";
175    let fmt_sp = "%Y-%m-%d %H:%M:%S%.f";
176    let fmt_t_no_frac = "%Y-%m-%dT%H:%M:%S";
177    let fmt_sp_no_frac = "%Y-%m-%d %H:%M:%S";
178
179    let dt: Option<NaiveDateTime> = NaiveDateTime::parse_from_str(s, fmt_t)
180        .or_else(|_| NaiveDateTime::parse_from_str(s, fmt_sp))
181        .or_else(|_| NaiveDateTime::parse_from_str(s, fmt_t_no_frac))
182        .or_else(|_| NaiveDateTime::parse_from_str(s, fmt_sp_no_frac))
183        .ok();
184
185    dt.map(|d| {
186        let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)
187            .and_then(|d| d.and_hms_opt(0, 0, 0))
188            .expect("epoch datetime is valid");
189        let dur = d.signed_duration_since(epoch);
190        dur.num_microseconds()
191            .unwrap_or(dur.num_milliseconds() * 1_000)
192    })
193}
194
195/// Parse an ISO-8601 date string `"YYYY-MM-DD"` into days since Unix epoch.
196///
197/// Returns `None` on parse failure.
198fn parse_text_as_date(s: &str) -> Option<i32> {
199    let d = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
200    let epoch = UNIX_EPOCH_DATE();
201    // `signed_duration_since` is always in whole days here because both are
202    // date-only values.
203    let days = d.signed_duration_since(epoch).num_days();
204    i32::try_from(days).ok()
205}
206
207/// Parse a time-of-day string into microseconds since midnight.
208///
209/// Accepts `"HH:MM:SS"` and `"HH:MM:SS.ffffff"`.  Returns `None` on parse
210/// failure.
211fn parse_text_as_time(s: &str) -> Option<i64> {
212    let t: Option<NaiveTime> = NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
213        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S"))
214        .ok();
215    t.map(|t| {
216        let midnight = NaiveTime::from_hms_opt(0, 0, 0).expect("midnight is valid");
217        let dur = t.signed_duration_since(midnight);
218        dur.num_microseconds()
219            .unwrap_or(dur.num_milliseconds() * 1_000)
220    })
221}
222
223/// Parse a hyphenated UUID string `"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"` into
224/// a `u128`.  Returns `None` on parse failure.
225fn parse_text_as_uuid(s: &str) -> Option<u128> {
226    // Expect exactly 36 bytes: 8-4-4-4-12 hex with dashes.
227    if s.len() != 36 {
228        return None;
229    }
230    let parts: Vec<&str> = s.split('-').collect();
231    if parts.len() != 5 {
232        return None;
233    }
234    let expected_lens = [8usize, 4, 4, 4, 12];
235    for (part, &expected) in parts.iter().zip(expected_lens.iter()) {
236        if part.len() != expected {
237            return None;
238        }
239    }
240
241    // Concatenate all hex digits and parse as u128.
242    let hex: String = parts.concat();
243    u128::from_str_radix(&hex, 16).ok()
244}
245
246/// Convert an `oxisql_core::Value` into a `limbo::Value`.
247///
248/// Rich OxiSQL types are coerced to the closest SQLite storage class:
249/// - `Bool`      → `Integer` (0 / 1)
250/// - `Timestamp` → `Integer` (microseconds since epoch)
251/// - `Date`      → `Integer` (days since epoch)
252/// - `Time`      → `Integer` (microseconds since midnight)
253/// - `Uuid`      → `Text`    (hyphenated UUID string)
254/// - `Decimal`   → `Text`    (decimal string)
255/// - `Json`      → `Text`    (JSON string)
256/// - `Array`     → `Text`    (debug representation — not round-trippable)
257///
258/// # Errors
259///
260/// Returns [`SqliteCompatError::TypeMap`] when a `Uuid` value cannot be
261/// formatted (should never occur in practice).
262pub fn core_to_limbo(val: &CoreValue) -> Result<LimboValue, SqliteCompatError> {
263    let v = match val {
264        CoreValue::Null => LimboValue::Null,
265        CoreValue::Bool(b) => LimboValue::Integer(i64::from(*b)),
266        CoreValue::I64(n) => LimboValue::Integer(*n),
267        CoreValue::F64(f) => LimboValue::Real(*f),
268        CoreValue::Text(s) => LimboValue::Text(s.clone()),
269        CoreValue::Blob(b) => LimboValue::Blob(b.clone()),
270        CoreValue::Timestamp(us) => LimboValue::Integer(*us),
271        CoreValue::Date(days) => LimboValue::Integer(i64::from(*days)),
272        CoreValue::Time(us) => LimboValue::Integer(*us),
273        CoreValue::Uuid(u) => {
274            // Format as hyphenated UUID string (16 bytes / u128).
275            let hi = (u >> 64) as u64;
276            let lo = *u as u64;
277            let raw: [u8; 16] = {
278                let mut buf = [0u8; 16];
279                buf[..8].copy_from_slice(&hi.to_be_bytes());
280                buf[8..].copy_from_slice(&lo.to_be_bytes());
281                buf
282            };
283            let s = format!(
284                "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
285                u32::from_be_bytes(
286                    raw[0..4]
287                        .try_into()
288                        .map_err(|_| SqliteCompatError::TypeMap("uuid slice error".into()))?
289                ),
290                u16::from_be_bytes(
291                    raw[4..6]
292                        .try_into()
293                        .map_err(|_| SqliteCompatError::TypeMap("uuid slice error".into()))?
294                ),
295                u16::from_be_bytes(
296                    raw[6..8]
297                        .try_into()
298                        .map_err(|_| SqliteCompatError::TypeMap("uuid slice error".into()))?
299                ),
300                u16::from_be_bytes(
301                    raw[8..10]
302                        .try_into()
303                        .map_err(|_| SqliteCompatError::TypeMap("uuid slice error".into()))?
304                ),
305                {
306                    let b = &raw[10..16];
307                    ((b[0] as u64) << 40)
308                        | ((b[1] as u64) << 32)
309                        | ((b[2] as u64) << 24)
310                        | ((b[3] as u64) << 16)
311                        | ((b[4] as u64) << 8)
312                        | (b[5] as u64)
313                }
314            );
315            LimboValue::Text(s)
316        }
317        CoreValue::Decimal(s) | CoreValue::Json(s) => LimboValue::Text(s.clone()),
318        CoreValue::Array(arr) => LimboValue::Text(format!("{arr:?}")),
319        CoreValue::TypedArray { values: arr, .. } => LimboValue::Text(format!("{arr:?}")),
320    };
321    Ok(v)
322}
323
324/// Split a SQL string containing multiple statements separated by `;` into
325/// individual statement slices.
326///
327/// The splitter is token-aware: semicolons that appear inside single-quoted
328/// string literals (`'...'`), double-quoted identifiers (`"..."`),
329/// backtick-quoted identifiers (`` `...` ``), block comments (`/* ... */`),
330/// or line comments (`-- ...`) are **not** treated as statement boundaries.
331///
332/// # Behaviour
333///
334/// - SQL-standard `''` escape sequences inside single-quoted strings are
335///   recognised, so `'it''s ok; really'` is treated as one token.
336/// - Unterminated strings or comments at end-of-input are passed through
337///   without error; the SQL engine will surface a syntax error if needed.
338/// - Empty statements (e.g. `;;`) are silently dropped.
339/// - The returned slices borrow from `sql` and are already trimmed of
340///   leading/trailing whitespace.
341pub(crate) fn split_statements(sql: &str) -> Vec<&str> {
342    let mut stmts = Vec::new();
343    let bytes = sql.as_bytes();
344    let len = bytes.len();
345    let mut i = 0usize;
346    let mut stmt_start = 0usize;
347
348    while i < len {
349        match bytes[i] {
350            b'\'' => {
351                // Single-quoted string: advance past the closing `'`, handling
352                // `''` escape sequences.
353                i += 1;
354                while i < len {
355                    if bytes[i] == b'\'' {
356                        if i + 1 < len && bytes[i + 1] == b'\'' {
357                            i += 2; // escaped ''
358                        } else {
359                            i += 1; // closing quote
360                            break;
361                        }
362                    } else {
363                        i += 1;
364                    }
365                }
366            }
367            b'"' => {
368                // Double-quoted identifier — advance to the matching `"`.
369                i += 1;
370                while i < len && bytes[i] != b'"' {
371                    i += 1;
372                }
373                if i < len {
374                    i += 1; // consume closing "
375                }
376            }
377            b'`' => {
378                // Backtick-quoted identifier (MySQL-style; SQLite accepts it).
379                i += 1;
380                while i < len && bytes[i] != b'`' {
381                    i += 1;
382                }
383                if i < len {
384                    i += 1; // consume closing `
385                }
386            }
387            b'-' if i + 1 < len && bytes[i + 1] == b'-' => {
388                // Line comment: skip to the end of the line.
389                while i < len && bytes[i] != b'\n' {
390                    i += 1;
391                }
392            }
393            b'/' if i + 1 < len && bytes[i + 1] == b'*' => {
394                // Block comment: skip to the closing `*/`.
395                i += 2;
396                while i + 1 < len {
397                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
398                        i += 2;
399                        break;
400                    }
401                    i += 1;
402                }
403            }
404            b';' => {
405                let stmt = sql[stmt_start..i].trim();
406                if !stmt.is_empty() {
407                    stmts.push(stmt);
408                }
409                i += 1;
410                stmt_start = i;
411            }
412            _ => {
413                i += 1;
414            }
415        }
416    }
417
418    // Handle a trailing statement that has no terminating `;`.
419    let tail = sql[stmt_start..].trim();
420    if !tail.is_empty() {
421        stmts.push(tail);
422    }
423
424    stmts
425}
426
427/// Rewrite OxiSQL-style `$N` positional placeholders to SQLite `?` placeholders,
428/// returning the reordered parameter list.
429///
430/// # Behaviour
431///
432/// - `$1`, `$2`, … are replaced left-to-right with `?`.
433/// - Parameters inside single-quoted string literals (`'...'`) are preserved.
434/// - Double-quoted identifiers (`"..."`) are similarly skipped.
435/// - The returned `params` vec is ordered by `$N` index (1-based), so `$2, $1`
436///   in the SQL results in `[params[1], params[0]]` in the output.
437///
438/// # Errors
439///
440/// Returns [`SqliteCompatError::TypeMap`] if a placeholder references a parameter
441/// index that is out of range for the supplied `params` slice.
442pub fn rewrite_params(
443    sql: &str,
444    params: &[&dyn oxisql_core::ToSqlValue],
445) -> Result<(String, Vec<LimboValue>), SqliteCompatError> {
446    let mut out = String::with_capacity(sql.len());
447    let mut ordered: Vec<LimboValue> = Vec::new();
448    let chars: Vec<char> = sql.chars().collect();
449    let n = chars.len();
450    let mut i = 0;
451
452    while i < n {
453        match chars[i] {
454            // Single-quoted string literal — copy verbatim.
455            '\'' => {
456                out.push('\'');
457                i += 1;
458                while i < n {
459                    let c = chars[i];
460                    out.push(c);
461                    i += 1;
462                    if c == '\'' {
463                        // Escaped quote ''
464                        if i < n && chars[i] == '\'' {
465                            out.push('\'');
466                            i += 1;
467                        } else {
468                            break;
469                        }
470                    }
471                }
472            }
473            // Double-quoted identifier — copy verbatim.
474            '"' => {
475                out.push('"');
476                i += 1;
477                while i < n && chars[i] != '"' {
478                    out.push(chars[i]);
479                    i += 1;
480                }
481                if i < n {
482                    out.push('"');
483                    i += 1;
484                }
485            }
486            // Potential positional parameter $N.
487            '$' => {
488                i += 1;
489                // Collect digits.
490                let start = i;
491                while i < n && chars[i].is_ascii_digit() {
492                    i += 1;
493                }
494                if i > start {
495                    let idx_str: String = chars[start..i].iter().collect();
496                    let idx: usize = idx_str.parse::<usize>().map_err(|_| {
497                        SqliteCompatError::TypeMap(format!(
498                            "invalid parameter placeholder: ${idx_str}"
499                        ))
500                    })?;
501                    if idx == 0 || idx > params.len() {
502                        return Err(SqliteCompatError::TypeMap(format!(
503                            "parameter ${idx} is out of range (have {} params)",
504                            params.len()
505                        )));
506                    }
507                    let limbo_val = core_to_limbo(&params[idx - 1].to_value())?;
508                    ordered.push(limbo_val);
509                    out.push('?');
510                } else {
511                    // Bare `$` with no digits — pass through unchanged.
512                    out.push('$');
513                }
514            }
515            c => {
516                out.push(c);
517                i += 1;
518            }
519        }
520    }
521
522    Ok((out, ordered))
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    // ── split_statements ──────────────────────────────────────────────────────
530
531    #[test]
532    fn test_split_basic() {
533        let stmts = split_statements("SELECT 1; SELECT 2");
534        assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
535    }
536
537    #[test]
538    fn test_split_trailing_semicolon() {
539        let stmts = split_statements("SELECT 1; SELECT 2;");
540        assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
541    }
542
543    #[test]
544    fn test_split_single_statement_no_semicolon() {
545        let stmts = split_statements("SELECT 1");
546        assert_eq!(stmts, vec!["SELECT 1"]);
547    }
548
549    #[test]
550    fn test_split_empty_statements() {
551        let stmts = split_statements(";;;");
552        assert!(stmts.is_empty(), "expected 0 stmts, got {stmts:?}");
553    }
554
555    #[test]
556    fn test_split_whitespace_only() {
557        let stmts = split_statements("   \n  ");
558        assert!(stmts.is_empty());
559    }
560
561    #[test]
562    fn test_split_semicolon_in_single_quoted_string() {
563        let stmts = split_statements("INSERT INTO t VALUES ('a;b')");
564        assert_eq!(stmts, vec!["INSERT INTO t VALUES ('a;b')"]);
565    }
566
567    #[test]
568    fn test_split_escaped_single_quotes() {
569        // 'it''s ok;really' contains a '' escape and a ; — neither should split.
570        let stmts = split_statements("INSERT INTO t VALUES ('it''s ok;really')");
571        assert_eq!(stmts, vec!["INSERT INTO t VALUES ('it''s ok;really')"]);
572    }
573
574    #[test]
575    fn test_split_double_quoted_identifier() {
576        let stmts = split_statements(r#"SELECT "col;name" FROM t"#);
577        assert_eq!(stmts, vec![r#"SELECT "col;name" FROM t"#]);
578    }
579
580    #[test]
581    fn test_split_backtick_quoted_identifier() {
582        let stmts = split_statements("SELECT `col;name` FROM t");
583        assert_eq!(stmts, vec!["SELECT `col;name` FROM t"]);
584    }
585
586    #[test]
587    fn test_split_line_comment() {
588        // The ; after -- must not be a statement boundary.
589        let stmts = split_statements("SELECT 1 -- ; this is a comment\n");
590        assert_eq!(stmts, vec!["SELECT 1 -- ; this is a comment"]);
591    }
592
593    #[test]
594    fn test_split_line_comment_between_stmts() {
595        let sql = "SELECT 1; -- comment with ; semicolon\nSELECT 2";
596        let stmts = split_statements(sql);
597        assert_eq!(stmts.len(), 2, "got {stmts:?}");
598        assert_eq!(stmts[0], "SELECT 1");
599        assert_eq!(stmts[1], "-- comment with ; semicolon\nSELECT 2");
600    }
601
602    #[test]
603    fn test_split_block_comment() {
604        let stmts = split_statements("SELECT /* ; */ 1");
605        assert_eq!(stmts, vec!["SELECT /* ; */ 1"]);
606    }
607
608    #[test]
609    fn test_split_block_comment_spanning_stmts() {
610        let sql = "SELECT 1; /* comment; with semicolons */ SELECT 2";
611        let stmts = split_statements(sql);
612        assert_eq!(stmts.len(), 2, "got {stmts:?}");
613        assert_eq!(stmts[0], "SELECT 1");
614        assert_eq!(stmts[1], "/* comment; with semicolons */ SELECT 2");
615    }
616
617    #[test]
618    fn test_split_multiple_with_trailing_no_semicolon() {
619        let sql = "CREATE TABLE t (id INT);\nINSERT INTO t VALUES (1)";
620        let stmts = split_statements(sql);
621        assert_eq!(stmts.len(), 2, "got {stmts:?}");
622        assert_eq!(stmts[0], "CREATE TABLE t (id INT)");
623        assert_eq!(stmts[1], "INSERT INTO t VALUES (1)");
624    }
625
626    #[test]
627    fn test_split_trims_whitespace() {
628        let stmts = split_statements("  SELECT 1  ;  SELECT 2  ");
629        assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
630    }
631
632    // ── limbo_to_core ─────────────────────────────────────────────────────────
633
634    #[test]
635    fn test_limbo_to_core_all_types() {
636        assert_eq!(limbo_to_core(LimboValue::Null).unwrap(), CoreValue::Null);
637        assert_eq!(
638            limbo_to_core(LimboValue::Integer(42)).unwrap(),
639            CoreValue::I64(42)
640        );
641        assert_eq!(
642            limbo_to_core(LimboValue::Real(1.5)).unwrap(),
643            CoreValue::F64(1.5)
644        );
645        assert_eq!(
646            limbo_to_core(LimboValue::Text("hello".into())).unwrap(),
647            CoreValue::Text("hello".into())
648        );
649        assert_eq!(
650            limbo_to_core(LimboValue::Blob(vec![1, 2, 3])).unwrap(),
651            CoreValue::Blob(vec![1, 2, 3])
652        );
653    }
654
655    #[test]
656    fn test_core_to_limbo_basic() {
657        assert_eq!(core_to_limbo(&CoreValue::Null).unwrap(), LimboValue::Null);
658        assert_eq!(
659            core_to_limbo(&CoreValue::I64(7)).unwrap(),
660            LimboValue::Integer(7)
661        );
662        assert_eq!(
663            core_to_limbo(&CoreValue::F64(1.5)).unwrap(),
664            LimboValue::Real(1.5)
665        );
666        assert_eq!(
667            core_to_limbo(&CoreValue::Bool(true)).unwrap(),
668            LimboValue::Integer(1)
669        );
670    }
671
672    #[test]
673    fn test_rewrite_params_basic() {
674        let params: Vec<&dyn oxisql_core::ToSqlValue> = vec![&42i64, &"hello"];
675        let (sql, vals) = rewrite_params("SELECT $1, $2", &params).unwrap();
676        assert_eq!(sql, "SELECT ?, ?");
677        assert_eq!(vals.len(), 2);
678        assert_eq!(vals[0], LimboValue::Integer(42));
679        assert_eq!(vals[1], LimboValue::Text("hello".into()));
680    }
681
682    #[test]
683    fn test_rewrite_params_skips_string_literals() {
684        let params: Vec<&dyn oxisql_core::ToSqlValue> = vec![&99i64];
685        let (sql, vals) = rewrite_params("SELECT '$1' WHERE id = $1", &params).unwrap();
686        assert_eq!(sql, "SELECT '$1' WHERE id = ?");
687        assert_eq!(vals.len(), 1);
688        assert_eq!(vals[0], LimboValue::Integer(99));
689    }
690
691    #[test]
692    fn test_rewrite_params_out_of_range() {
693        let params: Vec<&dyn oxisql_core::ToSqlValue> = vec![&1i64];
694        assert!(rewrite_params("SELECT $2", &params).is_err());
695    }
696
697    #[test]
698    fn test_rewrite_params_no_params() {
699        let params: &[&dyn oxisql_core::ToSqlValue] = &[];
700        let (sql, vals) = rewrite_params("SELECT 1", params).unwrap();
701        assert_eq!(sql, "SELECT 1");
702        assert!(vals.is_empty());
703    }
704}