Skip to main content

corium_sql/
lib.rs

1//! SQL execution and autocommit mutation planning over Corium database values.
2//!
3//! A [`SqlSession`] captures one [`corium_db::Db`] time view. Current,
4//! as-of, and since views expose one wide table per attribute namespace;
5//! history views expose normalized event relations only.
6
7mod catalog;
8mod mutation;
9mod value;
10
11use arrow::record_batch::RecordBatch;
12use corium_db::{Db, DbView};
13use datafusion::execution::context::SQLOptions;
14use datafusion::physical_plan::SendableRecordBatchStream;
15use datafusion::prelude::SessionContext;
16use futures::StreamExt as _;
17use thiserror::Error;
18
19pub use mutation::{MutationKind, SqlMutation, SqlMutationResult};
20pub use value::{SqlColumn, SqlRow, SqlType, SqlValue};
21
22/// SQL planning, catalog, or execution failure.
23#[derive(Debug, Error)]
24pub enum SqlError {
25    /// `DataFusion` rejected or failed the query.
26    #[error(transparent)]
27    DataFusion(#[from] datafusion::error::DataFusionError),
28    /// Arrow rejected a generated batch.
29    #[error(transparent)]
30    Arrow(#[from] arrow::error::ArrowError),
31    /// A Corium schema cannot be represented by the SQL projection.
32    #[error("SQL schema error: {0}")]
33    Schema(String),
34    /// SQL is valid but cannot be represented as a Corium mutation.
35    #[error("SQL mutation error: {0}")]
36    Mutation(String),
37    /// SQL parsing failed before `DataFusion` planning.
38    #[error(transparent)]
39    Parser(#[from] sqlparser::parser::ParserError),
40}
41
42/// One SQL environment over a fixed immutable database view.
43pub struct SqlSession {
44    context: SessionContext,
45    db: Db,
46    basis_t: u64,
47    view: DbView,
48}
49
50impl SqlSession {
51    /// Builds the SQL catalog for `db`.
52    ///
53    /// Current, as-of, and since views get namespace-derived wide tables in
54    /// `corium`; every view gets normalized relations in `corium_sys`.
55    ///
56    /// # Errors
57    /// Returns [`SqlError`] when the database schema cannot be projected.
58    pub fn new(db: &Db) -> Result<Self, SqlError> {
59        let context = SessionContext::new();
60        catalog::register(&context, db)?;
61        Ok(Self {
62            context,
63            db: db.clone(),
64            basis_t: db.basis_t(),
65            view: db.view(),
66        })
67    }
68
69    /// Transaction basis captured by this session.
70    #[must_use]
71    pub const fn basis_t(&self) -> u64 {
72        self.basis_t
73    }
74
75    /// Corium time view captured by this session.
76    #[must_use]
77    pub const fn view(&self) -> DbView {
78        self.view
79    }
80
81    /// Registered Corium relations as `schema.table` names.
82    #[must_use]
83    pub fn tables(&self) -> Vec<String> {
84        let Some(catalog) = self.context.catalog("datafusion") else {
85            return Vec::new();
86        };
87        let mut tables = Vec::new();
88        for schema_name in ["corium", "corium_sys"] {
89            if let Some(schema) = catalog.schema(schema_name) {
90                tables.extend(
91                    schema
92                        .table_names()
93                        .into_iter()
94                        .map(|table| format!("{schema_name}.{table}")),
95                );
96            }
97        }
98        tables.sort();
99        tables
100    }
101
102    /// Plans and starts a read-only SQL query.
103    ///
104    /// DDL, DML, and session-mutating statements are rejected by this method;
105    /// use [`Self::mutation`] to plan supported DML. Dropping the
106    /// returned stream cancels unfinished execution.
107    ///
108    /// # Errors
109    /// Returns [`SqlError`] for SQL parsing, planning, or execution failure.
110    pub async fn query(&self, sql: &str) -> Result<SqlQuery, SqlError> {
111        self.query_params(sql, &[]).await
112    }
113
114    /// Plans and starts a read-only query with PostgreSQL-style `$1`
115    /// parameters bound as typed values.
116    ///
117    /// # Errors
118    /// Returns [`SqlError`] for parameter binding, planning, or execution
119    /// failure.
120    pub async fn query_params(&self, sql: &str, params: &[SqlValue]) -> Result<SqlQuery, SqlError> {
121        let options = SQLOptions::new()
122            .with_allow_ddl(false)
123            .with_allow_dml(false)
124            .with_allow_statements(false);
125        let mut frame = self.context.sql_with_options(sql, options).await?;
126        if !params.is_empty() {
127            frame = frame.with_param_values(
128                params
129                    .iter()
130                    .map(SqlValue::to_scalar)
131                    .collect::<Result<Vec<_>, _>>()?,
132            )?;
133        }
134        let stream = frame.execute_stream().await?;
135        let columns = stream
136            .schema()
137            .fields()
138            .iter()
139            .map(|field| SqlColumn::from_arrow(field))
140            .collect();
141        Ok(SqlQuery {
142            columns,
143            stream,
144            batch: None,
145            row: 0,
146        })
147    }
148
149    /// Plans one supported `INSERT`, `UPDATE`, or `DELETE` into Corium
150    /// transaction forms. Returns `None` for read-only statements.
151    ///
152    /// The returned mutation is fenced to this session's basis. Its forms
153    /// must be submitted through the normal transactor path and then
154    /// [`SqlMutation::finish`] called with the committed database value and
155    /// tempid map to produce any `RETURNING` rows.
156    ///
157    /// # Errors
158    /// Returns [`SqlError`] for malformed or unsupported mutation shapes.
159    pub async fn mutation(&self, sql: &str) -> Result<Option<SqlMutation>, SqlError> {
160        self.mutation_params(sql, &[]).await
161    }
162
163    /// Plans one supported mutation with PostgreSQL-style `$1` parameters.
164    ///
165    /// # Errors
166    /// Returns [`SqlError`] for malformed, unbound, or unsupported mutation
167    /// shapes.
168    pub async fn mutation_params(
169        &self,
170        sql: &str,
171        params: &[SqlValue],
172    ) -> Result<Option<SqlMutation>, SqlError> {
173        mutation::plan(&self.db, sql, params).await
174    }
175
176    /// Describes the columns produced by a mutation's `RETURNING` clause
177    /// without evaluating its rows or planning transaction forms.
178    ///
179    /// Returns `None` for a non-mutation and an empty vector for a mutation
180    /// without `RETURNING`.
181    ///
182    /// # Errors
183    /// Returns [`SqlError`] for malformed mutation syntax or an invalid
184    /// returning projection.
185    pub async fn mutation_columns(
186        &self,
187        sql: &str,
188        params: &[SqlValue],
189    ) -> Result<Option<Vec<SqlColumn>>, SqlError> {
190        mutation::describe(&self.db, sql, params).await
191    }
192}
193
194/// Streaming result of one SQL statement.
195pub struct SqlQuery {
196    columns: Vec<SqlColumn>,
197    stream: SendableRecordBatchStream,
198    batch: Option<RecordBatch>,
199    row: usize,
200}
201
202impl SqlQuery {
203    /// Result columns in projection order.
204    #[must_use]
205    pub fn columns(&self) -> &[SqlColumn] {
206        &self.columns
207    }
208
209    /// Reads the next result row, or `None` at end of stream.
210    ///
211    /// # Errors
212    /// Returns [`SqlError`] when execution or value conversion fails.
213    pub async fn next_row(&mut self) -> Result<Option<SqlRow>, SqlError> {
214        loop {
215            if let Some(batch) = &self.batch
216                && self.row < batch.num_rows()
217            {
218                let row = batch
219                    .columns()
220                    .iter()
221                    .map(|array| {
222                        datafusion::common::ScalarValue::try_from_array(array.as_ref(), self.row)
223                            .map_err(SqlError::from)
224                            .and_then(SqlValue::from_scalar)
225                    })
226                    .collect::<Result<Vec<_>, _>>()?;
227                self.row += 1;
228                return Ok(Some(row));
229            }
230            match self.stream.next().await {
231                Some(batch) => {
232                    self.batch = Some(batch?);
233                    self.row = 0;
234                }
235                None => return Ok(None),
236            }
237        }
238    }
239
240    /// Collects all remaining rows.
241    ///
242    /// # Errors
243    /// Returns [`SqlError`] when execution or value conversion fails.
244    pub async fn collect(mut self) -> Result<Vec<SqlRow>, SqlError> {
245        let mut rows = Vec::new();
246        while let Some(row) = self.next_row().await? {
247            rows.push(row);
248        }
249        Ok(rows)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use corium_core::{
256        Attribute, Cardinality, Datom, EntityId, Keyword, KeywordInterner, Partition, Schema,
257        Value, ValueType,
258    };
259    use corium_db::{Db, Idents};
260    use corium_forms::txforms::tx_items_from_edn;
261    use corium_tx::prepare;
262
263    use super::*;
264
265    #[allow(clippy::too_many_lines)]
266    fn fixture() -> Db {
267        let name = EntityId::from_raw(10);
268        let tags = EntityId::from_raw(11);
269        let release_year = EntityId::from_raw(12);
270        let status = EntityId::from_raw(13);
271        let uuid = EntityId::from_raw(14);
272        let mut schema = Schema::default();
273        schema.insert(Attribute {
274            id: name,
275            value_type: ValueType::Str,
276            cardinality: Cardinality::One,
277            unique: None,
278            is_component: false,
279            indexed: false,
280            no_history: false,
281        });
282        schema.insert(Attribute {
283            id: tags,
284            value_type: ValueType::Str,
285            cardinality: Cardinality::Many,
286            unique: None,
287            is_component: false,
288            indexed: true,
289            no_history: false,
290        });
291        schema.insert(Attribute {
292            id: release_year,
293            value_type: ValueType::Long,
294            cardinality: Cardinality::One,
295            unique: None,
296            is_component: false,
297            indexed: true,
298            no_history: false,
299        });
300        schema.insert(Attribute {
301            id: status,
302            value_type: ValueType::Str,
303            cardinality: Cardinality::One,
304            unique: None,
305            is_component: false,
306            indexed: false,
307            no_history: false,
308        });
309        schema.insert(Attribute {
310            id: uuid,
311            value_type: ValueType::Uuid,
312            cardinality: Cardinality::One,
313            unique: None,
314            is_component: false,
315            indexed: false,
316            no_history: false,
317        });
318        let mut idents = Idents::default();
319        idents.insert(Keyword::parse("artist/name"), name);
320        idents.insert(Keyword::parse("artist/tags"), tags);
321        idents.insert(Keyword::parse("artist/release-year"), release_year);
322        idents.insert(Keyword::parse("status"), status);
323        idents.insert(Keyword::parse("artist/uuid"), uuid);
324        let e = EntityId::from_raw(1_000);
325        let second = EntityId::from_raw(1_001);
326        let tx = EntityId::from_raw(1);
327        Db::new(schema)
328            .with_naming(idents, KeywordInterner::default())
329            .with_transaction(
330                1,
331                &[
332                    Datom {
333                        e,
334                        a: name,
335                        v: Value::Str("Boards of Canada".into()),
336                        tx,
337                        added: true,
338                    },
339                    Datom {
340                        e,
341                        a: tags,
342                        v: Value::Str("ambient".into()),
343                        tx,
344                        added: true,
345                    },
346                    Datom {
347                        e,
348                        a: tags,
349                        v: Value::Str("electronic".into()),
350                        tx,
351                        added: true,
352                    },
353                    Datom {
354                        e,
355                        a: release_year,
356                        v: Value::Long(1998),
357                        tx,
358                        added: true,
359                    },
360                    Datom {
361                        e: second,
362                        a: name,
363                        v: Value::Str("Tycho".into()),
364                        tx,
365                        added: true,
366                    },
367                    Datom {
368                        e: second,
369                        a: release_year,
370                        v: Value::Long(2011),
371                        tx,
372                        added: true,
373                    },
374                ],
375            )
376    }
377
378    fn apply_mutation(
379        db: &Db,
380        mutation: &SqlMutation,
381    ) -> (Db, std::collections::BTreeMap<String, EntityId>) {
382        let mut interner = db.interner().clone();
383        let items =
384            tx_items_from_edn(db, &mut interner, mutation.forms()).expect("mutation forms convert");
385        let t = db.basis_t() + 1;
386        let tx = EntityId::new(Partition::Tx as u32, t);
387        let prepared = prepare(db, items, tx, 2_000).expect("mutation prepares");
388        (
389            db.clone().with_transaction(t, &prepared.datoms),
390            prepared.tempids,
391        )
392    }
393
394    #[tokio::test]
395    async fn missing_many_attribute_is_an_empty_list() {
396        let session = SqlSession::new(&fixture()).expect("session");
397        let rows = session
398            .query("SELECT tags FROM corium.artist WHERE name = 'Tycho'")
399            .await
400            .expect("query")
401            .collect()
402            .await
403            .expect("rows");
404        assert_eq!(rows, vec![vec![SqlValue::List(Vec::new())]]);
405    }
406
407    #[tokio::test]
408    async fn entity_equality_uses_the_wide_provider_lookup_path() {
409        let session = SqlSession::new(&fixture()).expect("session");
410        let rows = session
411            .query("SELECT name FROM corium.artist WHERE e = 1001")
412            .await
413            .expect("query")
414            .collect()
415            .await
416            .expect("rows");
417        assert_eq!(rows, vec![vec![SqlValue::Text("Tycho".into())]]);
418
419        let missing = session
420            .query("SELECT name FROM corium.artist WHERE e = 9999")
421            .await
422            .expect("query")
423            .collect()
424            .await
425            .expect("rows");
426        assert!(missing.is_empty());
427    }
428
429    #[tokio::test]
430    async fn exact_identifiers_and_indexed_ranges_are_supported() {
431        let session = SqlSession::new(&fixture()).expect("session");
432        let rows = session
433            .query(
434                "SELECT name FROM corium.artist \
435                 WHERE \"release-year\" >= 2000 ORDER BY name",
436            )
437            .await
438            .expect("query")
439            .collect()
440            .await
441            .expect("rows");
442        assert_eq!(rows, vec![vec![SqlValue::Text("Tycho".into())]]);
443    }
444
445    #[tokio::test]
446    async fn untyped_query_parameters_are_coerced_by_expression_context() {
447        let session = SqlSession::new(&fixture()).expect("session");
448        let rows = session
449            .query_params(
450                "SELECT name FROM corium.artist WHERE \"release-year\" = $1",
451                &[SqlValue::Unspecified("2011".into())],
452            )
453            .await
454            .expect("contextual parameter")
455            .collect()
456            .await
457            .expect("rows");
458        assert_eq!(rows, vec![vec![SqlValue::Text("Tycho".into())]]);
459    }
460
461    #[tokio::test]
462    async fn wide_table_exposes_lists_with_set_semantics() {
463        let session = SqlSession::new(&fixture()).expect("session");
464        let query = session
465            .query(
466                "SELECT e, name, tags FROM corium.artist \
467                 WHERE array_has(tags, 'ambient')",
468            )
469            .await
470            .expect("query");
471        let rows = query.collect().await.expect("rows");
472        assert_eq!(rows.len(), 1);
473        assert_eq!(rows[0][0], SqlValue::Unsigned(1_000));
474        assert_eq!(rows[0][1], SqlValue::Text("Boards of Canada".into()));
475        assert_eq!(
476            rows[0][2],
477            SqlValue::List(vec![
478                SqlValue::Text("ambient".into()),
479                SqlValue::Text("electronic".into()),
480            ])
481        );
482    }
483
484    #[tokio::test]
485    async fn explain_reports_attribute_filter_pushdown() {
486        let session = SqlSession::new(&fixture()).expect("session");
487        let rows = session
488            .query(
489                "EXPLAIN SELECT e FROM corium.artist \
490                 WHERE name >= 'Boards of Canada' AND array_has(tags, 'ambient')",
491            )
492            .await
493            .expect("explain")
494            .collect()
495            .await
496            .expect("rows");
497        let explanation = rows
498            .iter()
499            .flatten()
500            .map(ToString::to_string)
501            .collect::<Vec<_>>()
502            .join("\n");
503        assert!(explanation.contains("partial_filters="));
504        assert!(explanation.contains("name >="));
505        assert!(explanation.contains("array_has"));
506    }
507
508    #[tokio::test]
509    async fn history_session_exposes_events_but_not_wide_tables() {
510        let session = SqlSession::new(&fixture().history()).expect("session");
511        let rows = session
512            .query("SELECT count(*) FROM corium_sys.datoms")
513            .await
514            .expect("event query")
515            .collect()
516            .await
517            .expect("rows");
518        assert_eq!(rows, vec![vec![SqlValue::Integer(6)]]);
519        assert!(session.query("SELECT * FROM corium.artist").await.is_err());
520    }
521
522    #[tokio::test]
523    async fn data_definition_and_modification_are_rejected() {
524        let session = SqlSession::new(&fixture()).expect("session");
525        assert!(
526            session
527                .query("CREATE TABLE nope AS SELECT 1")
528                .await
529                .is_err()
530        );
531        assert!(
532            session
533                .query("INSERT INTO corium.artist (e) VALUES (42)")
534                .await
535                .is_err()
536        );
537    }
538
539    #[tokio::test]
540    async fn insert_plans_transaction_forms_and_returns_generated_entity() {
541        let db = fixture();
542        let session = SqlSession::new(&db).expect("session");
543        let mutation = session
544            .mutation(
545                "INSERT INTO corium.artist (name, \"release-year\", tags) \
546                 VALUES ('Autechre', 1994, ARRAY['electronic']) \
547                 RETURNING e, name",
548            )
549            .await
550            .expect("plan")
551            .expect("mutation");
552        assert_eq!(mutation.kind(), MutationKind::Insert);
553        assert_eq!(mutation.expected_basis_t(), db.basis_t());
554        assert_eq!(mutation.affected(), 1);
555
556        let (after, tempids) = apply_mutation(&db, &mutation);
557        let returned = mutation.finish(&after, &tempids).await.expect("returning");
558        assert_eq!(returned.rows.len(), 1);
559        assert_eq!(returned.rows[0][1], SqlValue::Text("Autechre".into()));
560    }
561
562    #[tokio::test]
563    async fn insert_select_and_global_projection_writes_are_supported() {
564        let db = fixture();
565        let copied = SqlSession::new(&db)
566            .expect("session")
567            .mutation(
568                "INSERT INTO corium.artist (name, \"release-year\") \
569                 SELECT name || ' Copy', \"release-year\" \
570                 FROM corium.artist WHERE name = 'Tycho' RETURNING name",
571            )
572            .await
573            .expect("insert-select plan")
574            .expect("mutation");
575        let (after_copy, tempids) = apply_mutation(&db, &copied);
576        let returned = copied
577            .finish(&after_copy, &tempids)
578            .await
579            .expect("returning");
580        assert_eq!(
581            returned.rows,
582            vec![vec![SqlValue::Text("Tycho Copy".into())]]
583        );
584
585        let global = SqlSession::new(&after_copy)
586            .expect("session")
587            .mutation("INSERT INTO corium._global (status) VALUES ('active') RETURNING status")
588            .await
589            .expect("global plan")
590            .expect("mutation");
591        let (after_global, tempids) = apply_mutation(&after_copy, &global);
592        let returned = global
593            .finish(&after_global, &tempids)
594            .await
595            .expect("global returning");
596        assert_eq!(returned.rows, vec![vec![SqlValue::Text("active".into())]]);
597    }
598
599    #[tokio::test]
600    async fn mutation_identifiers_follow_postgres_case_and_duplicate_rules() {
601        let db = fixture();
602        let mutation = SqlSession::new(&db)
603            .expect("session")
604            .mutation(
605                "UPDATE CORIUM.ARTIST SET NAME = 'BoC' \
606                 WHERE name = 'Boards of Canada'",
607            )
608            .await
609            .expect("unquoted identifiers normalize")
610            .expect("mutation");
611        assert_eq!(mutation.affected(), 1);
612
613        assert!(
614            SqlSession::new(&db)
615                .expect("session")
616                .mutation("UPDATE corium.artist SET name = 'a', name = 'b'")
617                .await
618                .is_err()
619        );
620        assert!(
621            SqlSession::new(&db)
622                .expect("session")
623                .mutation("INSERT INTO corium.artist (name, NAME) VALUES ('a', 'b')")
624                .await
625                .is_err()
626        );
627        assert!(
628            SqlSession::new(&db)
629                .expect("session")
630                .mutation("UPDATE corium.\"Artist\" SET name = 'a'")
631                .await
632                .is_err()
633        );
634    }
635
636    #[tokio::test]
637    async fn untyped_parameters_coerce_to_targets_and_uuid_is_strict() {
638        let db = fixture();
639        let session = SqlSession::new(&db).expect("session");
640        let mutation = session
641            .mutation_params(
642                "UPDATE corium.artist SET \"release-year\" = $1, uuid = $2 \
643                 WHERE name = 'Tycho'",
644                &[
645                    SqlValue::Unspecified("2024".into()),
646                    SqlValue::Unspecified("123e4567-e89b-12d3-a456-426614174000".into()),
647                ],
648            )
649            .await
650            .expect("untyped coercion")
651            .expect("mutation");
652        assert_eq!(mutation.affected(), 1);
653
654        for malformed in ["1", "+123e4567e89b12d3a456426614174000", "123e4567"] {
655            assert!(
656                session
657                    .mutation_params(
658                        "UPDATE corium.artist SET uuid = $1 WHERE name = 'Tycho'",
659                        &[SqlValue::Unspecified(malformed.into())],
660                    )
661                    .await
662                    .is_err(),
663                "accepted malformed UUID {malformed:?}"
664            );
665        }
666    }
667
668    #[tokio::test]
669    async fn update_replaces_scalar_and_many_values() {
670        let db = fixture();
671        let session = SqlSession::new(&db).expect("session");
672        let mutation = session
673            .mutation(
674                "UPDATE corium.artist \
675                 SET name = 'BoC', tags = ARRAY['ambient'] \
676                 WHERE name = 'Boards of Canada' \
677                 RETURNING name, tags",
678            )
679            .await
680            .expect("plan")
681            .expect("mutation");
682        assert_eq!(mutation.kind(), MutationKind::Update);
683        assert_eq!(mutation.affected(), 1);
684
685        let (after, tempids) = apply_mutation(&db, &mutation);
686        let returned = mutation.finish(&after, &tempids).await.expect("returning");
687        assert_eq!(
688            returned.rows,
689            vec![vec![
690                SqlValue::Text("BoC".into()),
691                SqlValue::List(vec![SqlValue::Text("ambient".into())]),
692            ]]
693        );
694    }
695
696    #[tokio::test]
697    async fn zero_row_update_returning_preserves_result_columns() {
698        let db = fixture();
699        let mutation = SqlSession::new(&db)
700            .expect("session")
701            .mutation(
702                "UPDATE corium.artist SET name = 'Nobody' \
703                 WHERE name = 'Missing' RETURNING name",
704            )
705            .await
706            .expect("plan")
707            .expect("mutation");
708        assert_eq!(mutation.affected(), 0);
709        assert!(mutation.is_empty());
710
711        let returned = mutation
712            .finish(&db, &std::collections::BTreeMap::new())
713            .await
714            .expect("returning");
715        assert_eq!(returned.columns.len(), 1);
716        assert_eq!(returned.columns[0].name, "name");
717        assert!(returned.rows.is_empty());
718    }
719
720    #[tokio::test]
721    async fn delete_retracts_only_namespace_attributes_and_returns_old_row() {
722        let db = fixture();
723        let session = SqlSession::new(&db).expect("session");
724        let mutation = session
725            .mutation(
726                "DELETE FROM corium.artist \
727                 WHERE name = 'Tycho' RETURNING e, name",
728            )
729            .await
730            .expect("plan")
731            .expect("mutation");
732        assert_eq!(mutation.kind(), MutationKind::Delete);
733        assert_eq!(mutation.affected(), 1);
734
735        let returned = mutation
736            .finish(&db, &std::collections::BTreeMap::new())
737            .await
738            .expect("pre-delete returning");
739        assert_eq!(returned.rows[0][1], SqlValue::Text("Tycho".into()));
740
741        let (after, _) = apply_mutation(&db, &mutation);
742        let rows = SqlSession::new(&after)
743            .expect("session")
744            .query("SELECT name FROM corium.artist WHERE name = 'Tycho'")
745            .await
746            .expect("query")
747            .collect()
748            .await
749            .expect("rows");
750        assert!(rows.is_empty());
751    }
752}