1mod 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#[derive(Debug, Error)]
24pub enum SqlError {
25 #[error(transparent)]
27 DataFusion(#[from] datafusion::error::DataFusionError),
28 #[error(transparent)]
30 Arrow(#[from] arrow::error::ArrowError),
31 #[error("SQL schema error: {0}")]
33 Schema(String),
34 #[error("SQL mutation error: {0}")]
36 Mutation(String),
37 #[error(transparent)]
39 Parser(#[from] sqlparser::parser::ParserError),
40}
41
42pub struct SqlSession {
44 context: SessionContext,
45 db: Db,
46 basis_t: u64,
47 view: DbView,
48}
49
50impl SqlSession {
51 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 #[must_use]
71 pub const fn basis_t(&self) -> u64 {
72 self.basis_t
73 }
74
75 #[must_use]
77 pub const fn view(&self) -> DbView {
78 self.view
79 }
80
81 #[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 pub async fn query(&self, sql: &str) -> Result<SqlQuery, SqlError> {
111 self.query_params(sql, &[]).await
112 }
113
114 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 pub async fn mutation(&self, sql: &str) -> Result<Option<SqlMutation>, SqlError> {
160 self.mutation_params(sql, &[]).await
161 }
162
163 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 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
194pub struct SqlQuery {
196 columns: Vec<SqlColumn>,
197 stream: SendableRecordBatchStream,
198 batch: Option<RecordBatch>,
199 row: usize,
200}
201
202impl SqlQuery {
203 #[must_use]
205 pub fn columns(&self) -> &[SqlColumn] {
206 &self.columns
207 }
208
209 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 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}