Skip to main content

helios_sof/sqlquery/
engine.rs

1//! In-memory SQLite engine used by `$sqlquery-run`.
2//!
3//! One connection per request. Each depends-on ViewDefinition is materialized
4//! into a named table; the user's SQL then runs against those tables.
5
6use futures::Stream;
7use futures::StreamExt;
8use rusqlite::{Connection, ToSql, params_from_iter};
9use serde_json::Value;
10use std::pin::Pin;
11
12use super::{BoundParam, SqlQueryError};
13
14/// FHIR type code for a column. Mirrors the value-set used by
15/// `ViewDefinition.select.column.type` so we can pick the correct value[X]
16/// when rendering `_format=fhir`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ColumnFhirType {
19    Boolean,
20    Integer,
21    Integer64,
22    Decimal,
23    Date,
24    DateTime,
25    Instant,
26    Time,
27    Base64Binary,
28    /// Catch-all for `string`, `code`, `id`, `uri`, `canonical`, `url`,
29    /// `markdown`, `oid`, etc. The exact code is preserved so the FHIR
30    /// formatter can emit `valueCode` vs `valueString` correctly.
31    String(String),
32}
33
34impl ColumnFhirType {
35    pub fn from_code(code: &str) -> Self {
36        match code {
37            "boolean" => ColumnFhirType::Boolean,
38            "integer" | "positiveInt" | "unsignedInt" => ColumnFhirType::Integer,
39            "integer64" => ColumnFhirType::Integer64,
40            "decimal" => ColumnFhirType::Decimal,
41            "date" => ColumnFhirType::Date,
42            "dateTime" => ColumnFhirType::DateTime,
43            "instant" => ColumnFhirType::Instant,
44            "time" => ColumnFhirType::Time,
45            "base64Binary" => ColumnFhirType::Base64Binary,
46            other => ColumnFhirType::String(other.to_string()),
47        }
48    }
49
50    /// The canonical FHIR type code for this type (#842/04): the
51    /// representative code [`Self::from_code`] maps every original code in
52    /// this variant's family to (`"integer"` for `integer`, `positiveInt`,
53    /// and `unsignedInt` alike), except [`ColumnFhirType::String`], which
54    /// returns the exact code it was built from. Used to display a
55    /// resolved dependency's own declared column type (the SQL Library
56    /// Columns card's *Type* cell, #842) without re-deriving it from the
57    /// source `ViewDefinition` JSON a second time.
58    pub fn code(&self) -> &str {
59        match self {
60            ColumnFhirType::Boolean => "boolean",
61            ColumnFhirType::Integer => "integer",
62            ColumnFhirType::Integer64 => "integer64",
63            ColumnFhirType::Decimal => "decimal",
64            ColumnFhirType::Date => "date",
65            ColumnFhirType::DateTime => "dateTime",
66            ColumnFhirType::Instant => "instant",
67            ColumnFhirType::Time => "time",
68            ColumnFhirType::Base64Binary => "base64Binary",
69            ColumnFhirType::String(code) => code,
70        }
71    }
72
73    /// SQLite type-affinity declaration for `CREATE TABLE`.
74    pub fn sqlite_affinity(&self) -> &'static str {
75        match self {
76            ColumnFhirType::Boolean | ColumnFhirType::Integer | ColumnFhirType::Integer64 => {
77                "INTEGER"
78            }
79            ColumnFhirType::Decimal => "REAL",
80            _ => "TEXT",
81        }
82    }
83}
84
85/// One column in a materialized table.
86#[derive(Debug, Clone)]
87pub struct ColumnSchema {
88    pub name: String,
89    pub fhir_type: ColumnFhirType,
90}
91
92/// Per-table schema: the column list (order matters for INSERT).
93#[derive(Debug, Clone)]
94pub struct TableSchema {
95    pub columns: Vec<ColumnSchema>,
96}
97
98impl TableSchema {
99    /// Build a schema from a ViewDefinition's `select[].column[]` list.
100    /// Walks every `select` entry (including nested `select` under `forEach`)
101    /// and collects columns in document order.
102    pub fn from_view_definition(view: &Value) -> Self {
103        let mut columns = Vec::new();
104        if let Some(selects) = view.get("select").and_then(|v| v.as_array()) {
105            for s in selects {
106                collect_columns(s, &mut columns);
107            }
108        }
109        TableSchema { columns }
110    }
111}
112
113fn collect_columns(select: &Value, out: &mut Vec<ColumnSchema>) {
114    if let Some(cols) = select.get("column").and_then(|v| v.as_array()) {
115        for col in cols {
116            let Some(name) = col.get("name").and_then(|v| v.as_str()) else {
117                continue;
118            };
119            let type_code = col
120                .get("type")
121                .and_then(|v| v.as_str())
122                .unwrap_or("string")
123                .to_string();
124            out.push(ColumnSchema {
125                name: name.to_string(),
126                fhir_type: ColumnFhirType::from_code(&type_code),
127            });
128        }
129    }
130    if let Some(nested) = select.get("select").and_then(|v| v.as_array()) {
131        for s in nested {
132            collect_columns(s, out);
133        }
134    }
135    if let Some(union) = select.get("unionAll").and_then(|v| v.as_array()) {
136        for s in union {
137            collect_columns(s, out);
138        }
139    }
140}
141
142/// Result of running the user query.
143pub struct QueryResult {
144    pub columns: Vec<String>,
145    /// Column FHIR types, in `columns` order. Inferred from the rusqlite
146    /// declared column type plus a per-row check (NULL columns fall back to
147    /// `String`).
148    pub column_types: Vec<ColumnFhirType>,
149    /// Each row is a Vec of optional values in `columns` order.
150    pub rows: Vec<Vec<Option<Value>>>,
151}
152
153/// The in-memory SQLite engine.
154pub struct InMemorySqlEngine {
155    conn: Connection,
156}
157
158impl InMemorySqlEngine {
159    pub fn open() -> Result<Self, SqlQueryError> {
160        let conn = Connection::open_in_memory()?;
161        // Aggressive in-memory pragmas — we never persist this DB.
162        conn.execute_batch(
163            "PRAGMA journal_mode = MEMORY;
164             PRAGMA synchronous = OFF;
165             PRAGMA temp_store = MEMORY;
166             PRAGMA foreign_keys = OFF;",
167        )?;
168        Ok(Self { conn })
169    }
170
171    /// Returns an interrupt handle that can cancel a running statement from
172    /// another thread (used by the request-level timeout watchdog).
173    pub fn interrupt_handle(&self) -> rusqlite::InterruptHandle {
174        self.conn.get_interrupt_handle()
175    }
176
177    /// Create a table with the given label and schema.
178    pub fn create_table(&self, label: &str, schema: &TableSchema) -> Result<(), SqlQueryError> {
179        validate_identifier(label)?;
180        let mut columns_ddl = Vec::with_capacity(schema.columns.len());
181        for col in &schema.columns {
182            validate_identifier(&col.name)?;
183            columns_ddl.push(format!(
184                "\"{}\" {}",
185                col.name,
186                col.fhir_type.sqlite_affinity()
187            ));
188        }
189        let sql = if columns_ddl.is_empty() {
190            // SQLite needs at least one column.
191            format!("CREATE TABLE \"{label}\" (\"_empty\" TEXT)")
192        } else {
193            format!("CREATE TABLE \"{}\" ({})", label, columns_ddl.join(", "))
194        };
195        self.conn.execute(&sql, [])?;
196        Ok(())
197    }
198
199    /// Creates a view named `label` that exposes every row and column of
200    /// the existing table (or view) `source`, so a consumer's SQL can address
201    /// that table under a second name without copying a single row. Both
202    /// identifiers go through the same validation as [`Self::create_table`].
203    /// The view lives in this request's in-memory database and disappears
204    /// with it.
205    pub fn create_view(&self, label: &str, source: &str) -> Result<(), SqlQueryError> {
206        validate_identifier(label)?;
207        validate_identifier(source)?;
208        self.conn.execute(
209            &format!("CREATE VIEW \"{label}\" AS SELECT * FROM \"{source}\""),
210            [],
211        )?;
212        // SQLite defers a view's column/table resolution to first use (the
213        // same forward-reference allowance it grants triggers), so a missing
214        // `source` would otherwise go unnoticed until a caller later queries
215        // `label`. Prepare (without running) a statement against the new
216        // view right away so a missing `source` is reported from
217        // `create_view` itself, and drop the unusable view instead of
218        // leaving a dangling one behind.
219        if let Err(e) = self.conn.prepare(&format!("SELECT * FROM \"{label}\"")) {
220            let _ = self.conn.execute(&format!("DROP VIEW \"{label}\""), []);
221            return Err(e.into());
222        }
223        Ok(())
224    }
225
226    /// Streams `rows` into `label` on a dedicated blocking thread, then
227    /// hands the engine back to the caller.
228    ///
229    /// The engine (and the `rusqlite::Statement` the insert loop prepares)
230    /// is not `Send` across an `.await` point, and the row stream is
231    /// typically a `tokio::sync::mpsc` channel whose `recv` spends the
232    /// polling task's cooperative budget on every item — draining more than
233    /// 128 rows in an ordinary async task therefore starves the waker and
234    /// hangs forever. Moving the whole operation into
235    /// `tokio::task::spawn_blocking` sidesteps both problems: the engine
236    /// crosses into a blocking-pool thread by value, the stream is driven
237    /// with `Handle::block_on` (which resets the cooperative budget on every
238    /// poll and is safe to use exactly because there is no scheduler to
239    /// starve on that thread), and the engine is returned to the caller once
240    /// the whole insert has completed.
241    ///
242    /// Returns `Ok((engine, n))` on success, where `n` is the number of rows
243    /// inserted and the transaction has been committed. On `Err`, the
244    /// transaction has already been rolled back and the engine is dropped;
245    /// callers are expected to abort the current plan rather than keep using
246    /// a half-populated database.
247    pub async fn insert_rows<S>(
248        self,
249        label: &str,
250        schema: &TableSchema,
251        rows: Pin<Box<S>>,
252        max_rows: usize,
253    ) -> Result<(Self, usize), SqlQueryError>
254    where
255        S: Stream<Item = Result<Value, String>> + Send + 'static + ?Sized,
256    {
257        let label = label.to_string();
258        let schema = schema.clone();
259        tokio::task::spawn_blocking(move || {
260            let handle = tokio::runtime::Handle::current();
261            let mut engine = self;
262            let inserted = engine.insert_rows_blocking(&label, &schema, rows, max_rows, &handle)?;
263            Ok((engine, inserted))
264        })
265        .await
266        .map_err(|e| SqlQueryError::Internal(format!("sqlquery worker panicked: {e}")))?
267    }
268
269    /// Synchronous body of [`Self::insert_rows`]. Runs entirely on the
270    /// blocking thread `insert_rows` spawned; every `rows.next()` call
271    /// (including the no-columns fast path) goes through `handle.block_on`
272    /// instead of `.await`, since this function is not itself async.
273    fn insert_rows_blocking<S>(
274        &mut self,
275        label: &str,
276        schema: &TableSchema,
277        mut rows: Pin<Box<S>>,
278        max_rows: usize,
279        handle: &tokio::runtime::Handle,
280    ) -> Result<usize, SqlQueryError>
281    where
282        S: Stream<Item = Result<Value, String>> + Send + ?Sized,
283    {
284        validate_identifier(label)?;
285        for col in &schema.columns {
286            validate_identifier(&col.name)?;
287        }
288        if schema.columns.is_empty() {
289            // Drain the stream without inserting; nothing to persist.
290            let mut n = 0usize;
291            while let Some(item) = handle.block_on(rows.next()) {
292                item.map_err(SqlQueryError::SourceStream)?;
293                n += 1;
294                if n > max_rows {
295                    return Err(SqlQueryError::RowCapExceeded { max: max_rows });
296                }
297            }
298            return Ok(n);
299        }
300
301        let placeholders = std::iter::repeat_n("?", schema.columns.len())
302            .collect::<Vec<_>>()
303            .join(", ");
304        let cols_quoted = schema
305            .columns
306            .iter()
307            .map(|c| format!("\"{}\"", c.name))
308            .collect::<Vec<_>>()
309            .join(", ");
310        let insert_sql = format!("INSERT INTO \"{label}\" ({cols_quoted}) VALUES ({placeholders})");
311
312        self.conn.execute("BEGIN", [])?;
313        let mut inserted = 0usize;
314        let result: Result<usize, SqlQueryError> = (|| {
315            let mut stmt = self.conn.prepare(&insert_sql)?;
316            while let Some(item) = handle.block_on(rows.next()) {
317                let row = item.map_err(SqlQueryError::SourceStream)?;
318                inserted += 1;
319                if inserted > max_rows {
320                    return Err(SqlQueryError::RowCapExceeded { max: max_rows });
321                }
322                let params: Vec<rusqlite::types::Value> = schema
323                    .columns
324                    .iter()
325                    .map(|c| json_to_sqlite_value(&row, c))
326                    .collect();
327                let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v as &dyn ToSql).collect();
328                stmt.execute(params_from_iter(param_refs))?;
329            }
330            Ok(inserted)
331        })();
332        match result {
333            Ok(n) => {
334                self.conn.execute("COMMIT", [])?;
335                Ok(n)
336            }
337            Err(e) => {
338                let _ = self.conn.execute("ROLLBACK", []);
339                Err(e)
340            }
341        }
342    }
343
344    /// Run a SELECT with named bindings and a row cap.
345    pub fn execute_select(
346        &self,
347        sql: &str,
348        bindings: &[BoundParam],
349        max_rows: usize,
350    ) -> Result<QueryResult, SqlQueryError> {
351        let mut stmt = self.conn.prepare(sql)?;
352
353        // Resolve each `:name` binding against the prepared statement's
354        // parameter index. Names not referenced by the SQL are silently
355        // ignored (the SQL may declare more params than it uses, or none).
356        for b in bindings {
357            let with_colon = format!(":{}", b.name);
358            if let Some(idx) = stmt.parameter_index(&with_colon)? {
359                stmt.raw_bind_parameter(idx, &b.value)?;
360            }
361        }
362
363        let columns: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
364        // Pre-seed with String to be overwritten per row.
365        let mut column_types: Vec<ColumnFhirType> = columns
366            .iter()
367            .map(|_| ColumnFhirType::String("string".to_string()))
368            .collect();
369        let mut rows_out: Vec<Vec<Option<Value>>> = Vec::new();
370
371        let mut rows_iter = stmt.raw_query();
372        while let Some(row) = rows_iter.next()? {
373            if rows_out.len() >= max_rows {
374                // SoF v2: the server's hard cap silently truncates the
375                // result set instead of erroring (spec PR #353: "Servers
376                // MAY enforce a maximum value, silently capping
377                // client-supplied limits at a smaller server-defined
378                // maximum"). Caller-supplied `_limit` is also a silent
379                // cap and is enforced at the handler. Source-row caps
380                // applied during `insert_rows` remain hard errors
381                // because truncating a depends-on table would silently
382                // change query semantics (JOINs, aggregates).
383                break;
384            }
385            let mut row_vals: Vec<Option<Value>> = Vec::with_capacity(columns.len());
386            for (i, _) in columns.iter().enumerate() {
387                let v: rusqlite::types::Value = row.get(i)?;
388                let (json_val, inferred) = sqlite_value_to_json(v);
389                if matches!(column_types[i], ColumnFhirType::String(_)) {
390                    if let Some(ft) = inferred {
391                        column_types[i] = ft;
392                    }
393                }
394                row_vals.push(json_val);
395            }
396            rows_out.push(row_vals);
397        }
398
399        Ok(QueryResult {
400            columns,
401            column_types,
402            rows: rows_out,
403        })
404    }
405}
406
407fn validate_identifier(name: &str) -> Result<(), SqlQueryError> {
408    if name.contains('"') || name.is_empty() {
409        return Err(SqlQueryError::InvalidIdentifier(name.to_string()));
410    }
411    Ok(())
412}
413
414fn json_to_sqlite_value(row: &Value, col: &ColumnSchema) -> rusqlite::types::Value {
415    use rusqlite::types::Value as RV;
416    let raw = row.get(&col.name).unwrap_or(&Value::Null);
417    match raw {
418        Value::Null => RV::Null,
419        Value::Bool(b) => RV::Integer(if *b { 1 } else { 0 }),
420        Value::Number(n) => {
421            if let Some(i) = n.as_i64() {
422                RV::Integer(i)
423            } else if let Some(f) = n.as_f64() {
424                RV::Real(f)
425            } else {
426                RV::Text(n.to_string())
427            }
428        }
429        Value::String(s) => match col.fhir_type {
430            ColumnFhirType::Integer | ColumnFhirType::Integer64 => s
431                .parse::<i64>()
432                .map(RV::Integer)
433                .unwrap_or(RV::Text(s.clone())),
434            ColumnFhirType::Decimal => s
435                .parse::<f64>()
436                .map(RV::Real)
437                .unwrap_or(RV::Text(s.clone())),
438            ColumnFhirType::Boolean => match s.as_str() {
439                "true" | "1" => RV::Integer(1),
440                "false" | "0" => RV::Integer(0),
441                _ => RV::Text(s.clone()),
442            },
443            _ => RV::Text(s.clone()),
444        },
445        Value::Array(_) | Value::Object(_) => RV::Text(raw.to_string()),
446    }
447}
448
449/// Maps a rusqlite value to JSON plus a best-guess `ColumnFhirType`. Useful
450/// for output columns the engine produced (e.g. `SELECT COUNT(*)`).
451fn sqlite_value_to_json(v: rusqlite::types::Value) -> (Option<Value>, Option<ColumnFhirType>) {
452    use rusqlite::types::Value as RV;
453    match v {
454        RV::Null => (None, None),
455        RV::Integer(i) => (Some(Value::Number(i.into())), Some(ColumnFhirType::Integer)),
456        RV::Real(f) => (
457            serde_json::Number::from_f64(f).map(Value::Number),
458            Some(ColumnFhirType::Decimal),
459        ),
460        RV::Text(s) => (Some(Value::String(s)), None),
461        RV::Blob(b) => (
462            Some(Value::String(
463                base64::engine::general_purpose::STANDARD.encode(b),
464            )),
465            Some(ColumnFhirType::Base64Binary),
466        ),
467    }
468}
469
470use base64::Engine as _;
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use futures::stream;
476    use serde_json::json;
477
478    fn schema(cols: &[(&str, ColumnFhirType)]) -> TableSchema {
479        TableSchema {
480            columns: cols
481                .iter()
482                .map(|(n, t)| ColumnSchema {
483                    name: (*n).to_string(),
484                    fhir_type: t.clone(),
485                })
486                .collect(),
487        }
488    }
489
490    #[tokio::test]
491    async fn round_trip_basic() {
492        let engine = InMemorySqlEngine::open().unwrap();
493        let s = schema(&[
494            ("id", ColumnFhirType::String("id".into())),
495            ("n", ColumnFhirType::Integer),
496        ]);
497        engine.create_table("patients", &s).unwrap();
498        let rows = stream::iter(vec![
499            Ok(json!({"id": "a", "n": 1})),
500            Ok(json!({"id": "b", "n": 2})),
501        ]);
502        let (engine, inserted) = engine
503            .insert_rows("patients", &s, Box::pin(rows), 10)
504            .await
505            .unwrap();
506        assert_eq!(inserted, 2);
507        let result = engine
508            .execute_select("SELECT id, n FROM patients ORDER BY n", &[], 10)
509            .unwrap();
510        assert_eq!(result.columns, vec!["id", "n"]);
511        assert_eq!(result.rows.len(), 2);
512        assert_eq!(result.rows[0][0], Some(Value::String("a".into())));
513        assert_eq!(result.rows[0][1], Some(Value::Number(1.into())));
514    }
515
516    #[tokio::test]
517    async fn null_handling() {
518        let engine = InMemorySqlEngine::open().unwrap();
519        let s = schema(&[
520            ("id", ColumnFhirType::String("id".into())),
521            ("age", ColumnFhirType::Integer),
522        ]);
523        engine.create_table("t", &s).unwrap();
524        let rows = stream::iter(vec![Ok(json!({"id": "a"}))]); // age missing
525        let (engine, _inserted) = engine
526            .insert_rows("t", &s, Box::pin(rows), 10)
527            .await
528            .unwrap();
529        let result = engine
530            .execute_select("SELECT id, age FROM t", &[], 10)
531            .unwrap();
532        assert_eq!(result.rows[0][1], None);
533    }
534
535    #[tokio::test]
536    async fn row_cap_exceeded() {
537        let engine = InMemorySqlEngine::open().unwrap();
538        let s = schema(&[("n", ColumnFhirType::Integer)]);
539        engine.create_table("t", &s).unwrap();
540        let rows = stream::iter((0..10).map(|i| Ok(json!({"n": i}))));
541        // `Result<(InMemorySqlEngine, usize), _>` doesn't implement `Debug`
542        // (the engine wraps a `rusqlite::Connection`, which doesn't), so
543        // `unwrap_err()` isn't available here; match instead.
544        let err = match engine.insert_rows("t", &s, Box::pin(rows), 3).await {
545            Ok(_) => panic!("expected RowCapExceeded"),
546            Err(e) => e,
547        };
548        assert!(matches!(err, SqlQueryError::RowCapExceeded { max: 3 }));
549    }
550
551    #[tokio::test]
552    async fn insert_rows_reports_stream_error_as_source_stream() {
553        // A `SofRunner`'s row stream fails mid-materialization (storage
554        // error, backend statement timeout, lost connection). This must be
555        // reported as `SourceStream`, not folded into `MalformedLibrary` —
556        // the Library and its ViewDefinitions are perfectly well-formed.
557        let engine = InMemorySqlEngine::open().unwrap();
558        let s = schema(&[("id", ColumnFhirType::String("id".into()))]);
559        engine.create_table("t", &s).unwrap();
560        let ok_rows = (0..200).map(|i| Ok(json!({"id": format!("p{i}")})));
561        let rows = stream::iter(
562            ok_rows.chain(std::iter::once(Err("connection reset by peer".to_string()))),
563        );
564        let err = match engine.insert_rows("t", &s, Box::pin(rows), 10_000).await {
565            Ok(_) => panic!("expected SourceStream"),
566            Err(e) => e,
567        };
568        let SqlQueryError::SourceStream(msg) = &err else {
569            panic!("expected SourceStream, got {err:?}");
570        };
571        assert!(
572            msg.contains("connection reset by peer"),
573            "unexpected message: {msg}"
574        );
575        assert!(
576            format!("{err}").starts_with("dependency source failed: "),
577            "unexpected display: {err}"
578        );
579    }
580
581    #[tokio::test]
582    async fn execute_select_silently_truncates_at_max_rows() {
583        // SoF v2 PR #353: the server's hard cap silently truncates the
584        // result set; it must not error.
585        let engine = InMemorySqlEngine::open().unwrap();
586        let s = schema(&[("n", ColumnFhirType::Integer)]);
587        engine.create_table("t", &s).unwrap();
588        let rows = stream::iter((1..=10).map(|i| Ok(json!({"n": i}))));
589        let (engine, _inserted) = engine
590            .insert_rows("t", &s, Box::pin(rows), 100)
591            .await
592            .unwrap();
593        let result = engine
594            .execute_select("SELECT n FROM t ORDER BY n", &[], 4)
595            .unwrap();
596        assert_eq!(result.rows.len(), 4);
597        assert_eq!(result.rows[0][0], Some(Value::Number(1.into())));
598        assert_eq!(result.rows[3][0], Some(Value::Number(4.into())));
599    }
600
601    #[test]
602    fn rejects_quote_in_identifier() {
603        let engine = InMemorySqlEngine::open().unwrap();
604        let s = schema(&[("a", ColumnFhirType::Integer)]);
605        let err = engine.create_table("bad\"name", &s).unwrap_err();
606        assert!(matches!(err, SqlQueryError::InvalidIdentifier(_)));
607    }
608
609    #[tokio::test]
610    async fn named_bindings_filter() {
611        let engine = InMemorySqlEngine::open().unwrap();
612        let s = schema(&[("n", ColumnFhirType::Integer)]);
613        engine.create_table("t", &s).unwrap();
614        let rows = stream::iter((1..=5).map(|i| Ok(json!({"n": i}))));
615        let (engine, _inserted) = engine
616            .insert_rows("t", &s, Box::pin(rows), 100)
617            .await
618            .unwrap();
619        let bindings = vec![BoundParam {
620            name: "min".to_string(),
621            value: rusqlite::types::Value::Integer(3),
622        }];
623        let result = engine
624            .execute_select("SELECT n FROM t WHERE n >= :min ORDER BY n", &bindings, 100)
625            .unwrap();
626        assert_eq!(result.rows.len(), 3);
627    }
628
629    /// Regression test for the hang this ticket fixes. A real runner (see
630    /// `crates/persistence/src/sof/sqlite.rs`) feeds `insert_rows` through a
631    /// `tokio::sync::mpsc` channel from a `spawn_blocking` producer. Each
632    /// `recv` on that channel spends one unit of the polling task's
633    /// cooperative budget (128 per poll, tokio's `coop` module); once it ran
634    /// out at row 129, the old executor-blocking implementation (draining
635    /// the stream via the `futures` crate's synchronous executor) never woke
636    /// up again. 1,000 rows is comfortably past
637    /// that threshold — `futures::stream::iter` would not reproduce this,
638    /// since it never touches the cooperative budget.
639    ///
640    /// The consumer runs inside `tokio::spawn` (not directly `.await`ed) and
641    /// the `JoinHandle` is wrapped in `tokio::time::timeout`: on a
642    /// single-threaded runtime (`main`, the pre-fix binary) this test would
643    /// otherwise hang forever instead of failing.
644    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
645    async fn insert_rows_drains_tokio_mpsc_stream_past_coop_budget() {
646        const ROW_COUNT: i64 = 1000;
647        let (tx, rx) = tokio::sync::mpsc::channel::<Result<Value, String>>(256);
648
649        tokio::task::spawn_blocking(move || {
650            for i in 0..ROW_COUNT {
651                let row = json!({"id": format!("p{i}"), "n": i});
652                if tx.blocking_send(Ok(row)).is_err() {
653                    break;
654                }
655            }
656        });
657
658        let consumer = tokio::spawn(async move {
659            let engine = InMemorySqlEngine::open().unwrap();
660            let s = schema(&[
661                ("id", ColumnFhirType::String("id".into())),
662                ("n", ColumnFhirType::Integer),
663            ]);
664            engine.create_table("t", &s).unwrap();
665            let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
666            let (engine, inserted) = engine
667                .insert_rows("t", &s, Box::pin(stream), 10_000)
668                .await
669                .unwrap();
670            let count = engine
671                .execute_select("SELECT COUNT(*) AS c FROM t", &[], 10)
672                .unwrap();
673            (inserted, count)
674        });
675
676        let (inserted, count) = tokio::time::timeout(std::time::Duration::from_secs(10), consumer)
677            .await
678            .expect("insert_rows must not hang past the cooperative budget")
679            .expect("consumer task must not panic");
680
681        assert_eq!(inserted, ROW_COUNT as usize);
682        assert_eq!(count.rows[0][0], Some(Value::Number(ROW_COUNT.into())));
683    }
684
685    #[test]
686    fn code_returns_the_representative_fhir_type_code() {
687        assert_eq!(ColumnFhirType::Boolean.code(), "boolean");
688        assert_eq!(ColumnFhirType::Integer.code(), "integer");
689        assert_eq!(ColumnFhirType::Decimal.code(), "decimal");
690        // `from_code`'s own family members collapse to the same
691        // representative code `code()` reports back.
692        assert_eq!(ColumnFhirType::from_code("positiveInt").code(), "integer");
693        // `String` returns the exact code it was built from, never a
694        // generic "string".
695        assert_eq!(ColumnFhirType::String("id".into()).code(), "id");
696    }
697
698    #[test]
699    fn schema_from_vd_select_columns() {
700        let vd = json!({
701            "select": [{
702                "column": [
703                    {"name": "id", "type": "id"},
704                    {"name": "n", "type": "integer"}
705                ]
706            }]
707        });
708        let s = TableSchema::from_view_definition(&vd);
709        assert_eq!(s.columns.len(), 2);
710        assert_eq!(s.columns[0].name, "id");
711        assert!(matches!(s.columns[1].fhir_type, ColumnFhirType::Integer));
712    }
713
714    #[test]
715    fn schema_walks_nested_selects_and_union() {
716        let vd = json!({
717            "select": [{
718                "column": [{"name": "a"}],
719                "select": [{"column": [{"name": "b"}]}],
720                "unionAll": [{"column": [{"name": "c"}]}]
721            }]
722        });
723        let s = TableSchema::from_view_definition(&vd);
724        assert_eq!(
725            s.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
726            vec!["a", "b", "c"]
727        );
728    }
729
730    #[tokio::test]
731    async fn create_view_exposes_source_rows_and_columns() {
732        let engine = InMemorySqlEngine::open().unwrap();
733        let s = schema(&[
734            ("id", ColumnFhirType::String("id".into())),
735            ("n", ColumnFhirType::Integer),
736        ]);
737        engine.create_table("t", &s).unwrap();
738        let rows = stream::iter(vec![
739            Ok(json!({"id": "a", "n": 1})),
740            Ok(json!({"id": "b", "n": 2})),
741            Ok(json!({"id": "c", "n": 3})),
742        ]);
743        let (engine, inserted) = engine
744            .insert_rows("t", &s, Box::pin(rows), 10)
745            .await
746            .unwrap();
747        assert_eq!(inserted, 3);
748        engine.create_view("v", "t").unwrap();
749
750        let from_view = engine
751            .execute_select("SELECT id, n FROM v ORDER BY n", &[], 100)
752            .unwrap();
753        let from_table = engine
754            .execute_select("SELECT id, n FROM t ORDER BY n", &[], 100)
755            .unwrap();
756        assert_eq!(from_view.rows.len(), 3);
757        assert_eq!(from_view.rows, from_table.rows);
758        assert_eq!(from_view.rows[0][0], Some(Value::String("a".into())));
759        assert_eq!(from_view.rows[0][1], Some(Value::Number(1.into())));
760        assert_eq!(from_view.rows[2][0], Some(Value::String("c".into())));
761        assert_eq!(from_view.rows[2][1], Some(Value::Number(3.into())));
762    }
763
764    #[tokio::test]
765    async fn create_view_reports_the_same_inferred_column_types_as_the_table() {
766        let engine = InMemorySqlEngine::open().unwrap();
767        let s = schema(&[
768            ("id", ColumnFhirType::String("id".into())),
769            ("n", ColumnFhirType::Integer),
770        ]);
771        engine.create_table("t", &s).unwrap();
772        let rows = stream::iter(vec![
773            Ok(json!({"id": "a", "n": 1})),
774            Ok(json!({"id": "b", "n": 2})),
775            Ok(json!({"id": "c", "n": 3})),
776        ]);
777        let (engine, _inserted) = engine
778            .insert_rows("t", &s, Box::pin(rows), 10)
779            .await
780            .unwrap();
781        engine.create_view("v", "t").unwrap();
782
783        let from_view = engine.execute_select("SELECT * FROM v", &[], 100).unwrap();
784        let from_table = engine.execute_select("SELECT * FROM t", &[], 100).unwrap();
785        assert_eq!(from_view.columns, from_table.columns);
786        assert_eq!(from_view.column_types.len(), from_table.column_types.len());
787        for (view_ty, table_ty) in from_view.column_types.iter().zip(&from_table.column_types) {
788            assert_eq!(view_ty.code(), table_ty.code());
789        }
790    }
791
792    #[test]
793    fn create_view_over_empty_schema_table_works() {
794        let engine = InMemorySqlEngine::open().unwrap();
795        let s = TableSchema { columns: vec![] };
796        engine.create_table("e", &s).unwrap();
797        engine.create_view("ve", "e").unwrap();
798        let result = engine
799            .execute_select("SELECT COUNT(*) AS c FROM ve", &[], 100)
800            .unwrap();
801        assert_eq!(result.rows[0][0], Some(Value::Number(0.into())));
802    }
803
804    #[test]
805    fn create_view_rejects_invalid_identifiers() {
806        let engine = InMemorySqlEngine::open().unwrap();
807        let s = schema(&[("a", ColumnFhirType::Integer)]);
808        engine.create_table("t", &s).unwrap();
809
810        let err = engine.create_view("bad\"name", "t").unwrap_err();
811        assert!(matches!(err, SqlQueryError::InvalidIdentifier(_)));
812
813        let err = engine.create_view("v", "").unwrap_err();
814        assert!(matches!(err, SqlQueryError::InvalidIdentifier(_)));
815    }
816
817    #[test]
818    fn create_view_on_missing_source_is_an_error() {
819        let engine = InMemorySqlEngine::open().unwrap();
820        let err = engine.create_view("v", "does_not_exist").unwrap_err();
821        assert!(!matches!(err, SqlQueryError::InvalidIdentifier(_)));
822    }
823}