From afafcd64a042d51ba5f8ac44e258e708b5922f6a Mon Sep 17 00:00:00 2001 From: Nick Alexander Date: Wed, 8 Feb 2017 14:04:32 -0800 Subject: [PATCH] [tx] Start implementing bulk SQL insertion algorithms (#214). r=rnewman,jsantell * Pre: Add some value conversion tests. This is follow-up to earlier work. Turn TypedValue::Keyword into edn::Value::NamespacedKeyword. Don't take a reference to value_type_tag. * Pre: Add repeat_values. Requires itertools, so this commit is not stand-alone. * Pre: Expose the first transaction ID as bootstrap::TX0. This is handy for testing. * Pre: Improve debug module. * Pre: Bump rusqlite version for https://github.com/jgallagher/rusqlite/issues/211. * Pre: Use itertools. * Start implementing bulk SQL insertion algorithms. (#214) This is slightly simpler re-expression of the existing Clojure implementation. * Post: Start generic data-driven transaction testing. (#188) * Review comment: `use ::{SYMBOL}` instead of `use {SYMBOL}`. * Review comment: Prefer bindings_per_statement to values_per_statement. --- Cargo.toml | 3 +- db/Cargo.toml | 9 +- db/src/bootstrap.rs | 11 +- db/src/db.rs | 510 +++++++++++++++++++++++++++++++---- db/src/debug.rs | 198 ++++++++++++-- db/src/lib.rs | 32 +++ db/tests/value_tests.rs | 54 ++++ tx/fixtures/test_add.edn | 66 +++++ tx/fixtures/test_retract.edn | 59 ++++ 9 files changed, 862 insertions(+), 80 deletions(-) create mode 100644 db/tests/value_tests.rs create mode 100644 tx/fixtures/test_add.edn create mode 100644 tx/fixtures/test_retract.edn diff --git a/Cargo.toml b/Cargo.toml index 4c8b55a8..12157818 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,10 @@ nickel = "0.9.0" slog = "1.4.0" slog-scope = "0.2.2" slog-term = "1.3.4" +time = "0.1.35" [dependencies.rusqlite] -version = "0.9.3" +version = "0.9.5" # System sqlite might be very old. features = ["bundled"] diff --git a/db/Cargo.toml b/db/Cargo.toml index 71bc39bb..9e85a272 100644 --- a/db/Cargo.toml +++ b/db/Cargo.toml @@ -4,10 +4,13 @@ version = "0.0.1" [dependencies] error-chain = "0.8.0" +itertools = "0.5.9" lazy_static = "0.2.2" +ordered-float = "0.4.0" +time = "0.1.35" [dependencies.rusqlite] -version = "0.9.3" +version = "0.9.5" # System sqlite might be very old. features = ["bundled"] @@ -22,3 +25,7 @@ path = "../tx" [dependencies.mentat_tx_parser] path = "../tx-parser" + +# Should be dev-dependencies. +[dependencies.tabwriter] +version = "1.0.3" diff --git a/db/src/bootstrap.rs b/db/src/bootstrap.rs index 1891b35e..6999a7b6 100644 --- a/db/src/bootstrap.rs +++ b/db/src/bootstrap.rs @@ -10,7 +10,7 @@ #![allow(dead_code)] -use {to_namespaced_keyword}; +use ::{to_namespaced_keyword}; use edn; use edn::types::Value; use entids; @@ -20,6 +20,11 @@ use mentat_tx_parser; use types::{IdentMap, Partition, PartitionMap, Schema, TypedValue}; use values; +/// The first transaction ID applied to the knowledge base. +/// +/// This is the start of the :db.part/tx partition. +pub const TX0: i64 = 0x10000000; + lazy_static! { static ref V1_IDENTS: Vec<(&'static str, i64)> = { vec![(":db/ident", entids::DB_IDENT), @@ -70,14 +75,14 @@ lazy_static! { static ref V1_PARTS: Vec<(&'static str, i64, i64)> = { vec![(":db.part/db", 0, (1 + V1_IDENTS.len()) as i64), (":db.part/user", 0x10000, 0x10000), - (":db.part/tx", 0x10000000, 0x10000000), + (":db.part/tx", TX0, TX0), ] }; static ref V2_PARTS: Vec<(&'static str, i64, i64)> = { vec![(":db.part/db", 0, (1 + V2_IDENTS.len()) as i64), (":db.part/user", 0x10000, 0x10000), - (":db.part/tx", 0x10000000, 0x10000000), + (":db.part/tx", TX0, TX0), ] }; diff --git a/db/src/db.rs b/db/src/db.rs index 734229d3..db150f65 100644 --- a/db/src/db.rs +++ b/db/src/db.rs @@ -10,11 +10,18 @@ #![allow(dead_code)] +use std::iter::once; + +use itertools; +use itertools::Itertools; use rusqlite; use rusqlite::types::{ToSql, ToSqlOutput}; +use time; +use ::{repeat_values, to_namespaced_keyword}; use bootstrap; use edn::types::Value; +use entids; use errors::*; use mentat_tx::entities as entmod; use mentat_tx::entities::Entity; @@ -166,7 +173,7 @@ pub fn create_current_version(conn: &mut rusqlite::Connection) -> Result { } let bootstrap_db = DB::new(bootstrap_partition_map, bootstrap::bootstrap_schema()); - bootstrap_db.transact_internal(&tx, &bootstrap::bootstrap_entities()[..])?; + bootstrap_db.transact_internal(&tx, &bootstrap::bootstrap_entities()[..], bootstrap::TX0)?; set_user_version(&tx, CURRENT_VERSION)?; let user_version = get_user_version(&tx)?; @@ -285,8 +292,8 @@ pub fn ensure_current_version(conn: &mut rusqlite::Connection) -> Result { impl TypedValue { /// Given a SQLite `value` and a `value_type_tag`, return the corresponding `TypedValue`. - pub fn from_sql_value_pair(value: rusqlite::types::Value, value_type_tag: &i32) -> Result { - match (*value_type_tag, value) { + pub fn from_sql_value_pair(value: rusqlite::types::Value, value_type_tag: i32) -> Result { + match (value_type_tag, value) { (0, rusqlite::types::Value::Integer(x)) => Ok(TypedValue::Ref(x)), (1, rusqlite::types::Value::Integer(x)) => Ok(TypedValue::Boolean(0 != x)), // SQLite distinguishes integral from decimal types, allowing long and double to @@ -295,7 +302,7 @@ impl TypedValue { (5, rusqlite::types::Value::Real(x)) => Ok(TypedValue::Double(x.into())), (10, rusqlite::types::Value::Text(x)) => Ok(TypedValue::String(x)), (13, rusqlite::types::Value::Text(x)) => Ok(TypedValue::Keyword(x)), - (_, value) => bail!(ErrorKind::BadSQLValuePair(value, *value_type_tag)), + (_, value) => bail!(ErrorKind::BadSQLValuePair(value, value_type_tag)), } } @@ -338,7 +345,7 @@ impl TypedValue { &TypedValue::Long(x) => (Value::Integer(x), ValueType::Long), &TypedValue::Double(x) => (Value::Float(x), ValueType::Double), &TypedValue::String(ref x) => (Value::Text(x.clone()), ValueType::String), - &TypedValue::Keyword(ref x) => (Value::Text(x.clone()), ValueType::Keyword), + &TypedValue::Keyword(ref x) => (Value::NamespacedKeyword(to_namespaced_keyword(&x).unwrap()), ValueType::Keyword), } } } @@ -373,7 +380,7 @@ pub fn read_schema(conn: &rusqlite::Connection, ident_map: &IdentMap) -> Result< let symbolic_attr: String = row.get_checked(1)?; let v: rusqlite::types::Value = row.get_checked(2)?; let value_type_tag: i32 = row.get_checked(3)?; - let typed_value = TypedValue::from_sql_value_pair(v, &value_type_tag)?; + let typed_value = TypedValue::from_sql_value_pair(v, value_type_tag)?; Ok((symbolic_ident, symbolic_attr, typed_value)) })?.collect(); @@ -390,6 +397,15 @@ pub fn read_db(conn: &rusqlite::Connection) -> Result { Ok(DB::new(partition_map, schema)) } +/// Internal representation of an [e a v added] datom, ready to be transacted against the store. +type ReducedEntity = (i64, i64, TypedValue, bool); + +#[derive(Clone,Debug,Eq,Hash,Ord,PartialOrd,PartialEq)] +pub enum SearchType { + Exact, + Inexact, +} + impl DB { /// Do schema-aware typechecking and coercion. /// @@ -416,45 +432,372 @@ impl DB { } } + /// Create empty temporary tables for search parameters and search results. + fn create_temp_tables(&self, conn: &rusqlite::Connection) -> Result<()> { + // We can't do this in one shot, since we can't prepare a batch statement. + let statements = [ + r#"DROP TABLE IF EXISTS temp.exact_searches"#, + // TODO: compress bit flags into a single bit field, and expand when inserting into + // `datoms` and `transactions`. + // TODO: drop tx0 entirely. + r#"CREATE TABLE temp.exact_searches ( + e0 INTEGER NOT NULL, + a0 SMALLINT NOT NULL, + v0 BLOB NOT NULL, + value_type_tag0 SMALLINT NOT NULL, + tx0 INTEGER NOT NULL, + added0 TINYINT NOT NULL, + index_avet0 TINYINT NOT NULL, + index_vaet0 TINYINT NOT NULL, + index_fulltext0 TINYINT NOT NULL, + unique_value0 TINYINT NOT NULL)"#, + // There's no real need to split exact and inexact searches, so long as we keep things + // in the correct place and performant. Splitting has the advantage of being explicit + // and slightly easier to read, so we'll do that to start. + r#"DROP TABLE IF EXISTS temp.inexact_searches"#, + r#"CREATE TABLE temp.inexact_searches ( + e0 INTEGER NOT NULL, + a0 SMALLINT NOT NULL, + v0 BLOB NOT NULL, + value_type_tag0 SMALLINT NOT NULL, + tx0 INTEGER NOT NULL, + added0 TINYINT NOT NULL, + index_avet0 TINYINT NOT NULL, + index_vaet0 TINYINT NOT NULL, + index_fulltext0 TINYINT NOT NULL, + unique_value0 TINYINT NOT NULL)"#, + r#"DROP TABLE IF EXISTS temp.search_results"#, + // TODO: don't encode search_type as a STRING. This is explicit and much easier to read + // than another flag, so we'll do it to start, and optimize later. + r#"CREATE TABLE temp.search_results ( + e0 INTEGER NOT NULL, + a0 SMALLINT NOT NULL, + v0 BLOB NOT NULL, + value_type_tag0 SMALLINT NOT NULL, + tx0 INTEGER NOT NULL, + added0 TINYINT NOT NULL, + index_avet0 TINYINT NOT NULL, + index_vaet0 TINYINT NOT NULL, + index_fulltext0 TINYINT NOT NULL, + unique_value0 TINYINT NOT NULL, + search_type STRING NOT NULL, + rid INTEGER, + v BLOB)"#, + // It is an error to transact the same [e a v] twice in one transaction. This index will + // cause insertion to fail if a transaction tries to do that. (Sadly, the failure is + // opaque.) + // + // N.b.: temp goes on index name, not table name. See http://stackoverflow.com/a/22308016. + r#"CREATE UNIQUE INDEX IF NOT EXISTS temp.search_results_unique ON search_results (e0, a0, v0, value_type_tag0)"#, + ]; + + for statement in &statements { + let mut stmt = conn.prepare_cached(statement)?; + stmt.execute(&[]) + .map(|_c| ()) + .chain_err(|| "Failed to create temporary tables")?; + } + + Ok(()) + } + + /// Insert search rows into temporary search tables. + /// + /// Eventually, the details of this approach will be captured in + /// https://github.com/mozilla/mentat/wiki/Transacting:-entity-to-SQL-translation. + fn insert_non_fts_searches<'a>(&self, conn: &rusqlite::Connection, entities: &'a [ReducedEntity], tx: Entid, search_type: SearchType) -> Result<()> { + let bindings_per_statement = 10; + + let chunks: itertools::IntoChunks<_> = entities.into_iter().chunks(::SQLITE_MAX_VARIABLE_NUMBER / bindings_per_statement); + + // We'd like to flat_map here, but it's not obvious how to flat_map across Result. + let results: Result> = chunks.into_iter().map(|chunk| -> Result<()> { + let mut count = 0; + + // We must keep these computed values somewhere to reference them later, so we can't + // combine this map and the subsequent flat_map. + // (e0, a0, v0, value_type_tag0, added0, index_avet0, index_vaet0, index_fulltext0, unique_value0) + let block: Result /* value */, /* value_type_tag */ i32, + /* added0 */ bool, + /* index_avet0 */ bool, + /* index_vaet0 */ bool, + /* index_fulltext0 */ bool, + /* unique_value0 */ bool)>> = chunk.map(|&(e, a, ref typed_value, added)| { + count += 1; + let attribute: &Attribute = self.schema.require_attribute_for_entid(&a)?; + + // Now we can represent the typed value as an SQL value. + let (value, value_type_tag): (ToSqlOutput, i32) = typed_value.to_sql_value_pair(); + + Ok((e, a, value, value_type_tag, + added, + attribute.index, + attribute.value_type == ValueType::Ref, + attribute.fulltext, + attribute.unique_value)) + }).collect(); + let block = block?; + + // `params` reference computed values in `block`. + let params: Vec<&ToSql> = block.iter().flat_map(|&(ref e, ref a, ref value, ref value_type_tag, added, index_avet, index_vaet, index_fulltext, unique_value)| { + // Avoid inner heap allocation. + // TODO: extract some finite length iterator to make this less indented! + once(e as &ToSql) + .chain(once(a as &ToSql) + .chain(once(value as &ToSql) + .chain(once(value_type_tag as &ToSql) + .chain(once(&tx as &ToSql) + .chain(once(to_bool_ref(added) as &ToSql) + .chain(once(to_bool_ref(index_avet) as &ToSql) + .chain(once(to_bool_ref(index_vaet) as &ToSql) + .chain(once(to_bool_ref(index_fulltext) as &ToSql) + .chain(once(to_bool_ref(unique_value) as &ToSql)))))))))) + }).collect(); + + // TODO: cache this for selected values of count. + let values: String = repeat_values(bindings_per_statement, count); + let s: String = if search_type == SearchType::Exact { + format!("INSERT INTO temp.exact_searches (e0, a0, v0, value_type_tag0, tx0, added0, index_avet0, index_vaet0, index_fulltext0, unique_value0) VALUES {}", values) + } else { + format!("INSERT INTO temp.inexact_searches (e0, a0, v0, value_type_tag0, tx0, added0, index_avet0, index_vaet0, index_fulltext0, unique_value0) VALUES {}", values) + }; + + // TODO: consider ensuring we inserted the expected number of rows. + let mut stmt = conn.prepare_cached(s.as_str())?; + stmt.execute(¶ms) + .map(|_c| ()) + .chain_err(|| "Could not insert non-fts one statements into temporary search table!") + }).collect::>>(); + + results.map(|_| ()) + } + + /// Take search rows and complete `temp.search_results`. + /// + /// See https://github.com/mozilla/mentat/wiki/Transacting:-entity-to-SQL-translation. + fn search(&self, conn: &rusqlite::Connection) -> Result<()> { + // First is fast, only one table walk: lookup by exact eav. + // Second is slower, but still only one table walk: lookup old value by ea. + let s = r#" + INSERT INTO temp.search_results + SELECT t.e0, t.a0, t.v0, t.value_type_tag0, t.tx0, t.added0, t.index_avet0, t.index_vaet0, t.index_fulltext0, t.unique_value0, ':db.cardinality/many', d.rowid, d.v + FROM temp.exact_searches AS t + LEFT JOIN datoms AS d + ON t.e0 = d.e AND + t.a0 = d.a AND + t.value_type_tag0 = d.value_type_tag AND + t.v0 = d.v + + UNION ALL + + SELECT t.e0, t.a0, t.v0, t.value_type_tag0, t.tx0, t.added0, t.index_avet0, t.index_vaet0, t.index_fulltext0, t.unique_value0, ':db.cardinality/one', d.rowid, d.v + FROM temp.inexact_searches AS t + LEFT JOIN datoms AS d + ON t.e0 = d.e AND + t.a0 = d.a"#; + + let mut stmt = conn.prepare_cached(s)?; + stmt.execute(&[]) + .map(|_c| ()) + .chain_err(|| "Could not search!") + } + + /// Insert the new transaction into the `transactions` table. + /// + /// This turns the contents of `search_results` into a new transaction. + /// + /// See https://github.com/mozilla/mentat/wiki/Transacting:-entity-to-SQL-translation. + // TODO: capture `conn` in a `TxInternal` structure. + fn insert_transaction(&self, conn: &rusqlite::Connection, tx: Entid) -> Result<()> { + let s = r#" + INSERT INTO transactions (e, a, v, tx, added, value_type_tag) + SELECT e0, a0, v0, ?, 1, value_type_tag0 + FROM temp.search_results + WHERE added0 IS 1 AND ((rid IS NULL) OR ((rid IS NOT NULL) AND (v0 IS NOT v)))"#; + + let mut stmt = conn.prepare_cached(s)?; + stmt.execute(&[&tx]) + .map(|_c| ()) + .chain_err(|| "Could not insert transaction: failed to add datoms not already present")?; + + let s = r#" + INSERT INTO transactions (e, a, v, tx, added, value_type_tag) + SELECT e0, a0, v, ?, 0, value_type_tag0 + FROM temp.search_results + WHERE rid IS NOT NULL AND + ((added0 IS 0) OR + (added0 IS 1 AND search_type IS ':db.cardinality/one' AND v0 IS NOT v))"#; + + let mut stmt = conn.prepare_cached(s)?; + stmt.execute(&[&tx]) + .map(|_c| ()) + .chain_err(|| "Could not insert transaction: failed to retract datoms already present")?; + + Ok(()) + } + + /// Update the contents of the `datoms` materialized view with the new transaction. + /// + /// This applies the contents of `search_results` to the `datoms` table (in place). + /// + /// See https://github.com/mozilla/mentat/wiki/Transacting:-entity-to-SQL-translation. + // TODO: capture `conn` in a `TxInternal` structure. + fn update_datoms(&self, conn: &rusqlite::Connection, tx: Entid) -> Result<()> { + // Delete datoms that were retracted, or those that were :db.cardinality/one and will be + // replaced. + let s = r#" + WITH ids AS (SELECT rid + FROM temp.search_results + WHERE rid IS NOT NULL AND + ((added0 IS 0) OR + (added0 IS 1 AND search_type IS ':db.cardinality/one' AND v0 IS NOT v))) + DELETE FROM datoms WHERE rowid IN ids"#; + + let mut stmt = conn.prepare_cached(s)?; + stmt.execute(&[]) + .map(|_c| ()) + .chain_err(|| "Could not update datoms: failed to retract datoms already present")?; + + // Insert datoms that were added and not already present. + let s = r#" + INSERT INTO datoms (e, a, v, tx, value_type_tag, index_avet, index_vaet, index_fulltext, unique_value) + SELECT e0, a0, v0, ?, value_type_tag0, + index_avet0, index_vaet0, index_fulltext0, unique_value0 + FROM temp.search_results + WHERE added0 IS 1 AND ((rid IS NULL) OR ((rid IS NOT NULL) AND (v0 IS NOT v)))"#; + + let mut stmt = conn.prepare_cached(s)?; + stmt.execute(&[&tx]) + .map(|_c| ()) + .chain_err(|| "Could not update datoms: failed to add datoms not already present")?; + + Ok(()) + } + + /// Transact the given `entities` against the given SQLite `conn`, using the metadata in + /// `self.DB`. + /// + /// This approach is explained in https://github.com/mozilla/mentat/wiki/Transacting. // TODO: move this to the transactor layer. - pub fn transact_internal(&self, conn: &rusqlite::Connection, entities: &[Entity]) -> Result<()>{ - // TODO: manage :db/tx, write :db/txInstant. - let tx = 1; + pub fn transact_internal(&self, conn: &rusqlite::Connection, entities: &[Entity], tx: Entid) -> Result<()>{ + // TODO: push these into an internal transaction report? + + /// Assertions that are :db.cardinality/one and not :db.fulltext. + let mut non_fts_one: Vec = vec![]; + + /// Assertions that are :db.cardinality/many and not :db.fulltext. + let mut non_fts_many: Vec = vec![]; + + // Transact [:db/add :db/txInstant NOW :db/tx]. + // TODO: allow this to be present in the transaction data. + let now = time::get_time(); + let tx_instant = (now.sec as i64 * 1_000) + (now.nsec as i64 / (1_000_000)); + non_fts_one.push((tx, + entids::DB_TX_INSTANT, + TypedValue::Long(tx_instant), + true)); + + // Right now, this could be a for loop, saving some mapping, collecting, and type + // annotations. However, I expect it to be a multi-stage map as we start to transform the + // underlying entities, in which case this expression is more natural than for loops. let r: Vec> = entities.into_iter().map(|entity: &Entity| -> Result<()> { match *entity { Entity::Add { - e: entmod::EntidOrLookupRef::Entid(entmod::Entid::Ident(ref e_)), - a: entmod::Entid::Ident(ref a_), + e: entmod::EntidOrLookupRef::Entid(ref e_), + a: ref a_, v: entmod::ValueOrLookupRef::Value(ref v_), tx: _ } => { - // TODO: prepare and cache all these statements outside the transaction loop. - // XXX: Error types. - let mut stmt: rusqlite::Statement = conn.prepare("INSERT INTO datoms(e, a, v, tx, value_type_tag, index_avet, index_vaet, index_fulltext, unique_value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")?; - let e: i64 = *self.schema.require_entid(&e_.to_string())?; - let a: i64 = *self.schema.require_entid(&a_.to_string())?; + let e: i64 = match e_ { + &entmod::Entid::Entid(ref e__) => *e__, + &entmod::Entid::Ident(ref e__) => *self.schema.require_entid(&e__.to_string())?, + }; + + let a: i64 = match a_ { + &entmod::Entid::Entid(ref a__) => *a__, + &entmod::Entid::Ident(ref a__) => *self.schema.require_entid(&a__.to_string())?, + }; + let attribute: &Attribute = self.schema.require_attribute_for_entid(&a)?; + if attribute.fulltext { + bail!(ErrorKind::NotYetImplemented(format!("Transacting :db/fulltext entities is not yet implemented: {:?}", entity))) + } // This is our chance to do schema-aware typechecking: to either assert that the // given value is in the attribute's value set, or (in limited cases) to coerce // the value into the attribute's value set. let typed_value: TypedValue = self.to_typed_value(v_, &attribute)?; - // Now we can represent the typed value as an SQL value. - let (value, value_type_tag): (ToSqlOutput, i32) = typed_value.to_sql_value_pair(); - - // Fun times, type signatures. - let values: [&ToSql; 9] = [&e, &a, &value, &tx, &value_type_tag, &attribute.index, to_bool_ref(attribute.value_type == ValueType::Ref), &attribute.fulltext, &attribute.unique_value]; - stmt.insert(&values[..])?; + let added = true; + if attribute.multival { + non_fts_many.push((e, a, typed_value, added)); + } else { + non_fts_one.push((e, a, typed_value, added)); + } Ok(()) }, - // TODO: find a better error type for this. - _ => panic!(format!("Transacting entity not yet supported: {:?}", entity)) + + Entity::Retract { + e: entmod::EntidOrLookupRef::Entid(ref e_), + a: ref a_, + v: entmod::ValueOrLookupRef::Value(ref v_) } => { + + let e: i64 = match e_ { + &entmod::Entid::Entid(ref e__) => *e__, + &entmod::Entid::Ident(ref e__) => *self.schema.require_entid(&e__.to_string())?, + }; + + let a: i64 = match a_ { + &entmod::Entid::Entid(ref a__) => *a__, + &entmod::Entid::Ident(ref a__) => *self.schema.require_entid(&a__.to_string())?, + }; + + let attribute: &Attribute = self.schema.require_attribute_for_entid(&a)?; + if attribute.fulltext { + bail!(ErrorKind::NotYetImplemented(format!("Transacting :db/fulltext entities is not yet implemented: {:?}", entity))) + } + + // This is our chance to do schema-aware typechecking: to either assert that the + // given value is in the attribute's value set, or (in limited cases) to coerce + // the value into the attribute's value set. + let typed_value: TypedValue = self.to_typed_value(v_, &attribute)?; + + let added = false; + + if attribute.multival { + non_fts_many.push((e, a, typed_value, added)); + } else { + non_fts_one.push((e, a, typed_value, added)); + } + Ok(()) + }, + + _ => bail!(ErrorKind::NotYetImplemented(format!("Transacting this entity is not yet implemented: {:?}", entity))) } }).collect(); - let x: Result> = r.into_iter().collect(); - x.map(|_| ()) + let r: Result> = r.into_iter().collect(); + r?; + + self.create_temp_tables(conn)?; + + if !non_fts_one.is_empty() { + self.insert_non_fts_searches(conn, &non_fts_one[..], tx, SearchType::Inexact)?; + } + + if !non_fts_many.is_empty() { + self.insert_non_fts_searches(conn, &non_fts_many[..], tx, SearchType::Exact)?; + } + + self.search(conn)?; + + self.insert_transaction(conn, tx)?; + self.update_datoms(conn, tx)?; + + // TODO: update parts, idents, schema materialized views. + + Ok(()) } } @@ -463,6 +806,9 @@ mod tests { use super::*; use bootstrap; use debug; + use edn; + use edn::symbols; + use mentat_tx_parser; use rusqlite; use types::*; @@ -471,46 +817,110 @@ mod tests { // TODO: figure out how to reference the fixtures directory for real. For now, assume we're // executing `cargo test` in `db/`. let conn = rusqlite::Connection::open("../fixtures/v2empty.db").unwrap(); - // assert_eq!(ensure_current_version(&mut conn).unwrap(), CURRENT_VERSION); - - // TODO: write :db/txInstant, bump :db.part/tx. - // let partition_map = read_partition_map(&conn).unwrap(); - // assert_eq!(partition_map, bootstrap::bootstrap_partition_map()); let ident_map = read_ident_map(&conn).unwrap(); assert_eq!(ident_map, bootstrap::bootstrap_ident_map()); let schema = read_schema(&conn, &ident_map).unwrap(); - assert_eq!(schema, bootstrap::bootstrap_schema()); // Schema::default()); + assert_eq!(schema, bootstrap::bootstrap_schema()); let db = read_db(&conn).unwrap(); - let datoms = debug::datoms_after(&conn, &db, &0).unwrap(); - assert_eq!(datoms.len(), 89); // The 89th is the :db/txInstant value. + // Does not include :db/txInstant. + let datoms = debug::datoms_after(&conn, &db, 0).unwrap(); + assert_eq!(datoms.0.len(), 88); - // // TODO: fewer magic numbers! - // assert_eq!(debug::datoms_after(&conn, &db, &0x10000001).unwrap(), vec![]); + // Includes :db/txInstant. + let transactions = debug::transactions_after(&conn, &db, 0).unwrap(); + assert_eq!(transactions.0.len(), 1); + assert_eq!(transactions.0[0].0.len(), 89); + } + + /// Assert that a sequence of transactions meets expectations. + /// + /// The transactions, expectations, and optional labels, are given in a simple EDN format; see + /// https://github.com/mozilla/mentat/wiki/Transacting:-EDN-test-format. + /// + /// There is some magic here about transaction numbering that I don't want to commit to or + /// document just yet. The end state might be much more general pattern matching syntax, rather + /// than the targeted transaction ID and timestamp replacement we have right now. + fn assert_transactions(conn: &rusqlite::Connection, db: &DB, transactions: &Vec) { + for (index, transaction) in transactions.into_iter().enumerate() { + let index = index as i64; + let transaction = transaction.as_map().unwrap(); + let label: edn::Value = transaction.get(&edn::Value::NamespacedKeyword(symbols::NamespacedKeyword::new("test", "label"))).unwrap().clone(); + let assertions: edn::Value = transaction.get(&edn::Value::NamespacedKeyword(symbols::NamespacedKeyword::new("test", "assertions"))).unwrap().clone(); + // TODO: use hyphenated keywords, like :test/expected-transaction -- when the EDN parser + // supports them! + let expected_transaction: Option<&edn::Value> = transaction.get(&edn::Value::NamespacedKeyword(symbols::NamespacedKeyword::new("test", "expectedtransaction"))); + let expected_datoms: Option<&edn::Value> = transaction.get(&edn::Value::NamespacedKeyword(symbols::NamespacedKeyword::new("test", "expecteddatoms"))); + + let entities: Vec<_> = mentat_tx_parser::Tx::parse(&[assertions][..]).unwrap(); + db.transact_internal(&conn, &entities[..], bootstrap::TX0 + index + 1).unwrap(); + + if let Some(expected_transaction) = expected_transaction { + let transactions = debug::transactions_after(&conn, &db, bootstrap::TX0 + index).unwrap(); + assert_eq!(transactions.0[0].into_edn(), + *expected_transaction, + "\n{} - expected transaction:\n{}\n{}", label, transactions.0[0].into_edn(), *expected_transaction); + } + + if let Some(expected_datoms) = expected_datoms { + let datoms = debug::datoms_after(&conn, &db, bootstrap::TX0).unwrap(); + assert_eq!(datoms.into_edn(), + *expected_datoms, + "\n{} - expected datoms:\n{}\n{}", label, datoms.into_edn(), *expected_datoms); + } + + // Don't allow empty tests. This will need to change if we allow transacting schema + // fragments in a preamble, but for now it might catch malformed tests. + assert_ne!((expected_transaction, expected_datoms), (None, None), + "Transaction test must include at least one of :test/expectedtransaction or :test/expecteddatoms"); + } } #[test] - fn test_create_current_version() { - // // assert_eq!(bootstrap_schema().unwrap(), Schema::default()); - - // // Ignore result. - // use std::fs; - // let _ = fs::remove_file("/Users/nalexander/Mozilla/mentat/test.db"); - // let mut conn = rusqlite::Connection::open("file:///Users/nalexander/Mozilla/mentat/test.db").unwrap(); - + fn test_add() { let mut conn = new_connection(); - assert_eq!(ensure_current_version(&mut conn).unwrap(), CURRENT_VERSION); let bootstrap_db = DB::new(bootstrap::bootstrap_partition_map(), bootstrap::bootstrap_schema()); - // TODO: write materialized view of bootstrapped schema to SQL store. - // let db = read_db(&conn).unwrap(); - // assert_eq!(db, bootstrap_db); - let datoms = debug::datoms_after(&conn, &bootstrap_db, &0).unwrap(); - assert_eq!(datoms.len(), 88); + // Does not include :db/txInstant. + let datoms = debug::datoms_after(&conn, &bootstrap_db, 0).unwrap(); + assert_eq!(datoms.0.len(), 88); + + // Includes :db/txInstant. + let transactions = debug::transactions_after(&conn, &bootstrap_db, 0).unwrap(); + assert_eq!(transactions.0.len(), 1); + assert_eq!(transactions.0[0].0.len(), 89); + + // TODO: extract a test macro simplifying this boilerplate yet further. + let value = edn::parse::value(include_str!("../../tx/fixtures/test_add.edn")).unwrap(); + + let transactions = value.as_vector().unwrap(); + assert_transactions(&conn, &bootstrap_db, transactions); + } + + #[test] + fn test_retract() { + let mut conn = new_connection(); + assert_eq!(ensure_current_version(&mut conn).unwrap(), CURRENT_VERSION); + + let bootstrap_db = DB::new(bootstrap::bootstrap_partition_map(), bootstrap::bootstrap_schema()); + + // Does not include :db/txInstant. + let datoms = debug::datoms_after(&conn, &bootstrap_db, 0).unwrap(); + assert_eq!(datoms.0.len(), 88); + + // Includes :db/txInstant. + let transactions = debug::transactions_after(&conn, &bootstrap_db, 0).unwrap(); + assert_eq!(transactions.0.len(), 1); + assert_eq!(transactions.0[0].0.len(), 89); + + let value = edn::parse::value(include_str!("../../tx/fixtures/test_retract.edn")).unwrap(); + + let transactions = value.as_vector().unwrap(); + assert_transactions(&conn, &bootstrap_db, transactions); } } diff --git a/db/src/debug.rs b/db/src/debug.rs index 79bfc659..8fe480e6 100644 --- a/db/src/debug.rs +++ b/db/src/debug.rs @@ -12,55 +12,203 @@ /// Low-level functions for testing. -use rusqlite; +use std::collections::{BTreeSet}; +use std::io::{Write}; -use {to_namespaced_keyword}; -use edn::types::{Value}; +use itertools::Itertools; +use rusqlite; +use rusqlite::types::{ToSql}; +use tabwriter::TabWriter; + +use ::{to_namespaced_keyword}; +use bootstrap; +use edn; +use edn::symbols; +use entids; use mentat_tx::entities::{Entid}; use types::{DB, TypedValue}; use errors::Result; -/// Represents an assertion (*datom*) in the store. +/// Represents a *datom* (assertion) in the store. #[derive(Clone,Debug,Eq,Hash,Ord,PartialOrd,PartialEq)] pub struct Datom { // TODO: generalize this. e: Entid, a: Entid, - v: Value, - tx: Option, + v: edn::Value, + tx: i64, + added: Option, } -/// Return the complete set of datoms in the store, ordered by (e, a, v). -pub fn datoms(conn: &rusqlite::Connection, db: &DB) -> Result> { - // TODO: fewer magic numbers! - datoms_after(conn, db, &0x10000000) +/// Represents a set of datoms (assertions) in the store. +pub struct Datoms(pub BTreeSet); + +/// Represents an ordered sequence of transactions in the store. +pub struct Transactions(pub Vec); + +fn label_tx_id(tx: i64) -> edn::Value { + edn::Value::PlainSymbol(symbols::PlainSymbol::new(format!("?tx{}", tx - bootstrap::TX0))) } -/// Return the set of datoms in the store with transaction ID strictly -/// greater than the given `tx`, ordered by (tx, e, a, v). -pub fn datoms_after(conn: &rusqlite::Connection, db: &DB, tx: &i32) -> Result> { - let mut stmt: rusqlite::Statement = conn.prepare("SELECT e, a, v, value_type_tag FROM datoms WHERE tx > ? ORDER BY tx, e, a, v")?; +fn label_tx_instant(tx: i64) -> edn::Value { + edn::Value::PlainSymbol(symbols::PlainSymbol::new(format!("?ms{}", tx - bootstrap::TX0))) +} - // Convert numeric entid to entity Entid. - let to_entid = |x| { - db.schema.get_ident(&x).and_then(|y| to_namespaced_keyword(&y)).map(Entid::Ident).unwrap_or(Entid::Entid(x)) - }; +impl Datom { + pub fn into_edn(&self, tx_id: T, tx_instant: &U) -> edn::Value + where T: Fn(i64) -> edn::Value, U: Fn(i64) -> edn::Value { + let f = |entid: &Entid| -> edn::Value { + match *entid { + Entid::Entid(ref y) => edn::Value::Integer(y.clone()), + Entid::Ident(ref y) => edn::Value::NamespacedKeyword(y.clone()), + } + }; - let datoms = stmt.query_and_then(&[tx], |row| { + // Rewrite [E :db/txInstant V] to [?txN :db/txInstant ?t0]. + let mut v = if self.a == Entid::Entid(entids::DB_TX_INSTANT) || self.a == Entid::Ident(to_namespaced_keyword(":db/txInstant").unwrap()) { + vec![tx_id(self.tx), + f(&self.a), + tx_instant(self.tx)] + } else { + vec![f(&self.e), f(&self.a), self.v.clone()] + }; + if let Some(added) = self.added { + v.push(tx_id(self.tx)); + v.push(edn::Value::Boolean(added)); + } + + edn::Value::Vector(v) + } +} + +impl Datoms { + pub fn into_edn_raw(&self, tx_id: &T, tx_instant: &U) -> edn::Value + where T: Fn(i64) -> edn::Value, U: Fn(i64) -> edn::Value { + edn::Value::Set((&self.0).into_iter().map(|x| x.into_edn(tx_id, tx_instant)).collect()) + } + + pub fn into_edn(&self) -> edn::Value { + self.into_edn_raw(&label_tx_id, &label_tx_instant) + } +} + +impl Transactions { + pub fn into_edn_raw(&self, tx_id: &T, tx_instant: &U) -> edn::Value + where T: Fn(i64) -> edn::Value, U: Fn(i64) -> edn::Value { + edn::Value::Vector((&self.0).into_iter().map(|x| x.into_edn_raw(tx_id, tx_instant)).collect()) + } + + pub fn into_edn(&self) -> edn::Value { + self.into_edn_raw(&label_tx_id, &label_tx_instant) + } +} + +/// Convert a numeric entid to an ident `Entid` if possible, otherwise a numeric `Entid`. +fn to_entid(db: &DB, entid: i64) -> Entid { + db.schema.get_ident(&entid).and_then(|ident| to_namespaced_keyword(&ident)).map_or(Entid::Entid(entid), Entid::Ident) +} + +/// Return the set of datoms in the store, ordered by (e, a, v, tx), but not including any datoms of +/// the form [... :db/txInstant ...]. +pub fn datoms(conn: &rusqlite::Connection, db: &DB) -> Result { + datoms_after(conn, db, bootstrap::TX0 - 1) +} + +/// Return the set of datoms in the store with transaction ID strictly greater than the given `tx`, +/// ordered by (e, a, v, tx). +/// +/// The datom set returned does not include any datoms of the form [... :db/txInstant ...]. +pub fn datoms_after(conn: &rusqlite::Connection, db: &DB, tx: i64) -> Result { + let mut stmt: rusqlite::Statement = conn.prepare("SELECT e, a, v, value_type_tag, tx FROM datoms WHERE tx > ? ORDER BY e ASC, a ASC, v ASC, tx ASC")?; + + let r: Result> = stmt.query_and_then(&[&tx], |row| { let e: i64 = row.get_checked(0)?; let a: i64 = row.get_checked(1)?; + + if a == entids::DB_TX_INSTANT { + return Ok(None); + } + let v: rusqlite::types::Value = row.get_checked(2)?; let value_type_tag: i32 = row.get_checked(3)?; - let typed_value = TypedValue::from_sql_value_pair(v, &value_type_tag)?; + let typed_value = TypedValue::from_sql_value_pair(v, value_type_tag)?; let (value, _) = typed_value.to_edn_value_pair(); - Ok(Datom { - e: to_entid(e), - a: to_entid(a), + let tx: i64 = row.get_checked(4)?; + + Ok(Some(Datom { + e: to_entid(db, e), + a: to_entid(db, a), v: value, - tx: None, + tx: tx, + added: None, + })) + })?.collect(); + + Ok(Datoms(r?.into_iter().filter_map(|x| x).collect())) +} + +/// Return the sequence of transactions in the store with transaction ID strictly greater than the +/// given `tx`, ordered by (tx, e, a, v). +/// +/// Each transaction returned includes the [:db/tx :db/txInstant ...] datom. +pub fn transactions_after(conn: &rusqlite::Connection, db: &DB, tx: i64) -> Result { + let mut stmt: rusqlite::Statement = conn.prepare("SELECT e, a, v, value_type_tag, tx, added FROM transactions WHERE tx > ? ORDER BY tx ASC, e ASC, a ASC, v ASC, added ASC")?; + + let r: Result> = stmt.query_and_then(&[&tx], |row| { + let e: i64 = row.get_checked(0)?; + let a: i64 = row.get_checked(1)?; + + let v: rusqlite::types::Value = row.get_checked(2)?; + let value_type_tag: i32 = row.get_checked(3)?; + + let typed_value = TypedValue::from_sql_value_pair(v, value_type_tag)?; + let (value, _) = typed_value.to_edn_value_pair(); + + let tx: i64 = row.get_checked(4)?; + let added: bool = row.get_checked(5)?; + + Ok(Datom { + e: to_entid(db, e), + a: to_entid(db, a), + v: value, + tx: tx, + added: Some(added), }) })?.collect(); - datoms + + // Group by tx. + let r: Vec = r?.into_iter().group_by(|x| x.tx).into_iter().map(|(_key, group)| Datoms(group.collect())).collect(); + Ok(Transactions(r)) +} + +/// Execute the given `sql` query with the given `params` and format the results as a +/// tab-and-newline formatted string suitable for debug printing. +/// +/// The query is printed followed by a newline, then the returned columns followed by a newline, and +/// then the data rows and columns. All columns are aligned. +pub fn dump_sql_query(conn: &rusqlite::Connection, sql: &str, params: &[&ToSql]) -> Result { + let mut stmt: rusqlite::Statement = conn.prepare(sql)?; + + let mut tw = TabWriter::new(Vec::new()).padding(2); + write!(&mut tw, "{}\n", sql).unwrap(); + + for column_name in stmt.column_names() { + write!(&mut tw, "{}\t", column_name).unwrap(); + } + write!(&mut tw, "\n").unwrap(); + + let r: Result> = stmt.query_and_then(params, |row| { + for i in 0..row.column_count() { + let value: rusqlite::types::Value = row.get_checked(i)?; + write!(&mut tw, "{:?}\t", value).unwrap(); + } + write!(&mut tw, "\n").unwrap(); + Ok(()) + })?.collect(); + r?; + + let dump = String::from_utf8(tw.into_inner().unwrap()).unwrap(); + Ok(dump) } diff --git a/db/src/lib.rs b/db/src/lib.rs index ecd77468..c022abc6 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -10,14 +10,21 @@ #[macro_use] extern crate error_chain; +extern crate itertools; #[macro_use] extern crate lazy_static; extern crate rusqlite; +extern crate time; + +extern crate tabwriter; extern crate edn; extern crate mentat_tx; extern crate mentat_tx_parser; +use itertools::Itertools; +use std::iter::repeat; + pub use errors::*; pub use schema::*; pub use types::*; @@ -33,6 +40,8 @@ mod values; use edn::symbols; +pub const SQLITE_MAX_VARIABLE_NUMBER: usize = 999; + pub fn to_namespaced_keyword(s: &str) -> Option { let splits = [':', '/']; let mut i = s.split(&splits[..]); @@ -41,3 +50,26 @@ pub fn to_namespaced_keyword(s: &str) -> Option { _ => None } } + +/// Prepare an SQL `VALUES` block, like (?, ?, ?), (?, ?, ?). +/// +/// The number of values per tuple determines `(?, ?, ?)`. The number of tuples determines `(...), (...)`. +/// +/// # Examples +/// +/// ```rust +/// # use mentat_db::{repeat_values}; +/// assert_eq!(repeat_values(1, 3), "(?), (?), (?)".to_string()); +/// assert_eq!(repeat_values(3, 1), "(?, ?, ?)".to_string()); +/// assert_eq!(repeat_values(2, 2), "(?, ?), (?, ?)".to_string()); +/// ``` +pub fn repeat_values(values_per_tuple: usize, tuples: usize) -> String { + assert!(values_per_tuple >= 1); + assert!(tuples >= 1); + assert!(values_per_tuple * tuples < SQLITE_MAX_VARIABLE_NUMBER, "Too many values: {} * {} >= {}", values_per_tuple, tuples, SQLITE_MAX_VARIABLE_NUMBER); + // Like "(?, ?, ?)". + let inner = format!("({})", repeat("?").take(values_per_tuple).join(", ")); + // Like "(?, ?, ?), (?, ?, ?)". + let values: String = repeat(inner).take(tuples).join(", "); + values +} diff --git a/db/tests/value_tests.rs b/db/tests/value_tests.rs new file mode 100644 index 00000000..755e340a --- /dev/null +++ b/db/tests/value_tests.rs @@ -0,0 +1,54 @@ +// Copyright 2016 Mozilla +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +// CONDITIONS OF ANY KIND, either express or implied. See the License for the +// specific language governing permissions and limitations under the License. + +extern crate edn; +extern crate mentat_db; +extern crate ordered_float; +extern crate rusqlite; + +use mentat_db::{TypedValue, ValueType}; +use ordered_float::OrderedFloat; +use edn::symbols; + +// It's not possible to test to_sql_value_pair since rusqlite::ToSqlOutput doesn't implement +// PartialEq. +#[test] +fn test_from_sql_value_pair() { + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Integer(1234), 0).unwrap(), TypedValue::Ref(1234)); + + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Integer(0), 1).unwrap(), TypedValue::Boolean(false)); + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Integer(1), 1).unwrap(), TypedValue::Boolean(true)); + + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Integer(0), 5).unwrap(), TypedValue::Long(0)); + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Integer(1234), 5).unwrap(), TypedValue::Long(1234)); + + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Real(0.0), 5).unwrap(), TypedValue::Double(OrderedFloat(0.0))); + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Real(0.5), 5).unwrap(), TypedValue::Double(OrderedFloat(0.5))); + + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Text(":db/keyword".into()), 10).unwrap(), TypedValue::String(":db/keyword".into())); + assert_eq!(TypedValue::from_sql_value_pair(rusqlite::types::Value::Text(":db/keyword".into()), 13).unwrap(), TypedValue::Keyword(":db/keyword".into())); +} + +#[test] +fn test_to_edn_value_pair() { + assert_eq!(TypedValue::Ref(1234).to_edn_value_pair(), (edn::Value::Integer(1234), ValueType::Ref)); + + assert_eq!(TypedValue::Boolean(false).to_edn_value_pair(), (edn::Value::Boolean(false), ValueType::Boolean)); + assert_eq!(TypedValue::Boolean(true).to_edn_value_pair(), (edn::Value::Boolean(true), ValueType::Boolean)); + + assert_eq!(TypedValue::Long(0).to_edn_value_pair(), (edn::Value::Integer(0), ValueType::Long)); + assert_eq!(TypedValue::Long(1234).to_edn_value_pair(), (edn::Value::Integer(1234), ValueType::Long)); + + assert_eq!(TypedValue::Double(OrderedFloat(0.0)).to_edn_value_pair(), (edn::Value::Float(OrderedFloat(0.0)), ValueType::Double)); + assert_eq!(TypedValue::Double(OrderedFloat(0.5)).to_edn_value_pair(), (edn::Value::Float(OrderedFloat(0.5)), ValueType::Double)); + + assert_eq!(TypedValue::String(":db/keyword".into()).to_edn_value_pair(), (edn::Value::Text(":db/keyword".into()), ValueType::String)); + assert_eq!(TypedValue::Keyword(":db/keyword".into()).to_edn_value_pair(), (edn::Value::NamespacedKeyword(symbols::NamespacedKeyword::new("db", "keyword")), ValueType::Keyword)); +} diff --git a/tx/fixtures/test_add.edn b/tx/fixtures/test_add.edn new file mode 100644 index 00000000..0265aa11 --- /dev/null +++ b/tx/fixtures/test_add.edn @@ -0,0 +1,66 @@ +[{:test/label ":db.cardinality/one, insert" + :test/assertions + [[:db/add 100 :db/ident :keyword/value1] + [:db/add 101 :db/ident :keyword/value2]] + :test/expectedtransaction + #{[100 :db/ident :keyword/value1 ?tx1 true] + [101 :db/ident :keyword/value2 ?tx1 true] + [?tx1 :db/txInstant ?ms1 ?tx1 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value1] + [101 :db/ident :keyword/value2]}} + + {:test/label ":db.cardinality/many, insert" + :test/assertions + [[:db/add 200 :db.schema/attribute 100] + [:db/add 200 :db.schema/attribute 101]] + :test/expectedtransaction + #{[200 :db.schema/attribute 100 ?tx2 true] + [200 :db.schema/attribute 101 ?tx2 true] + [?tx2 :db/txInstant ?ms2 ?tx2 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value1] + [101 :db/ident :keyword/value2] + [200 :db.schema/attribute 100] + [200 :db.schema/attribute 101]}} + + {:test/label ":db.cardinality/one, replace" + :test/assertions + [[:db/add 100 :db/ident :keyword/value11] + [:db/add 101 :db/ident :keyword/value22]] + :test/expectedtransaction + #{[100 :db/ident :keyword/value1 ?tx3 false] + [100 :db/ident :keyword/value11 ?tx3 true] + [101 :db/ident :keyword/value2 ?tx3 false] + [101 :db/ident :keyword/value22 ?tx3 true] + [?tx3 :db/txInstant ?ms3 ?tx3 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value11] + [101 :db/ident :keyword/value22] + [200 :db.schema/attribute 100] + [200 :db.schema/attribute 101]}} + + {:test/label ":db.cardinality/one, already present" + :test/assertions + [[:db/add 100 :db/ident :keyword/value11] + [:db/add 101 :db/ident :keyword/value22]] + :test/expectedtransaction + #{[?tx4 :db/txInstant ?ms4 ?tx4 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value11] + [101 :db/ident :keyword/value22] + [200 :db.schema/attribute 100] + [200 :db.schema/attribute 101]}} + + {:test/label ":db.cardinality/many, already present" + :test/assertions + [[:db/add 200 :db.schema/attribute 100] + [:db/add 200 :db.schema/attribute 101]] + :test/expectedtransaction + #{[?tx5 :db/txInstant ?ms5 ?tx5 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value11] + [101 :db/ident :keyword/value22] + [200 :db.schema/attribute 100] + [200 :db.schema/attribute 101]}} + ] diff --git a/tx/fixtures/test_retract.edn b/tx/fixtures/test_retract.edn new file mode 100644 index 00000000..22a54f69 --- /dev/null +++ b/tx/fixtures/test_retract.edn @@ -0,0 +1,59 @@ +[{:test/label ":db.cardinality/one, insert" + :test/assertions + [[:db/add 100 :db/ident :keyword/value1] + [:db/add 101 :db/ident :keyword/value2]] + :test/expectedtransaction + #{[100 :db/ident :keyword/value1 ?tx1 true] + [101 :db/ident :keyword/value2 ?tx1 true] + [?tx1 :db/txInstant ?ms1 ?tx1 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value1] + [101 :db/ident :keyword/value2]}} + + {:test/label ":db.cardinality/many, insert" + :test/assertions + [[:db/add 200 :db.schema/attribute 100] + [:db/add 200 :db.schema/attribute 101]] + :test/expectedtransaction + #{[200 :db.schema/attribute 100 ?tx2 true] + [200 :db.schema/attribute 101 ?tx2 true] + [?tx2 :db/txInstant ?ms2 ?tx2 true]} + :test/expecteddatoms + #{[100 :db/ident :keyword/value1] + [101 :db/ident :keyword/value2] + [200 :db.schema/attribute 100] + [200 :db.schema/attribute 101]}} + + {:test/label ":db.cardinality/one, retract" + :test/assertions + [[:db/retract 100 :db/ident :keyword/value1]] + :test/expectedtransaction + #{[100 :db/ident :keyword/value1 ?tx3 false] + [?tx3 :db/txInstant ?ms3 ?tx3 true]} + :test/expecteddatoms + #{[101 :db/ident :keyword/value2] + [200 :db.schema/attribute 100] + [200 :db.schema/attribute 101]}} + + {:test/label ":db.cardinality/many, retract" + :test/assertions + [[:db/retract 200 :db.schema/attribute 100]] + :test/expectedtransaction + #{[200 :db.schema/attribute 100 ?tx4 false] + [?tx4 :db/txInstant ?ms4 ?tx4 true]} + :test/expecteddatoms + #{[101 :db/ident :keyword/value2] + [200 :db.schema/attribute 101]} + } + + {:test/label ":db.cardinality/{one,many}, not present." + :test/assertions + [[:db/retract 100 :db/ident :keyword/value1] + [:db/retract 200 :db.schema/attribute 100]] + :test/expectedtransaction + #{[?tx5 :db/txInstant ?ms5 ?tx5 true]} + :test/expecteddatoms + #{[101 :db/ident :keyword/value2] + [200 :db.schema/attribute 101]} + } + ]