Follow-up: split the monolith!

This was a little more tricky than might be expected because the
initialization process uses the transactor to bootstrap the database.
Since Clojure doesn't accept mutually recursive modules, this
necessitated a third module, namely "db-factory", which uses both "db"
and "transact".  While I was here, I started an "api" module, to paper
over the potentially complicated internal module structure for external
consumers.  In time, this "api" module may also grow CLJS-specific JS
transformations.
This commit is contained in:
Nick Alexander 2016-08-04 17:40:38 -07:00
parent 1853d57cba
commit d8c976c3ad
6 changed files with 1052 additions and 993 deletions

View file

@ -12,8 +12,6 @@
[datomish.query.projection :as projection]
[datomish.query.source :as source]
[datomish.query :as query]
[honeysql.core :as sql]
[datomish.transact.bootstrap :as bootstrap]
[datomish.datom :as dd :refer [datom datom? #?@(:cljs [Datom])]]
[datomish.util :as util #?(:cljs :refer-macros :clj :refer) [raise raise-str cond-let]]
[datomish.schema :as ds]
@ -36,13 +34,6 @@
(uncaughtException [_ thread ex]
(println ex "Uncaught exception on" (.getName thread))))))
;; Setting this to something else will make your output more readable,
;; but not automatically safe for use.
(def sql-quoting-style :ansi)
(defn- sql-format [args]
(sql/format args :quoting :ansi))
(defprotocol IClock
(now
[clock]
@ -152,7 +143,7 @@
{:select [:e :a :v :tx [1 :added]] ;; TODO: generalize columns.
:from [:all_datoms]
:where (cons :and (map #(vector := %1 %2) [:e :a :v] (take-while (comp not nil?) [e a v])))} ;; Must drop nils.
(sql-format)
(s/format)
(s/all-rows (:sqlite-connection db))
(<?)
@ -167,7 +158,7 @@
{:select [:e :a :v :tx [1 :added]] ;; TODO: generalize columns.
:from [:all_datoms]
:where [:and [:= :a a] [:= :v v] [:= :index_avet 1]]}
(sql-format)
(s/format)
(s/all-rows (:sqlite-connection db))
(<?)
@ -247,378 +238,7 @@
:cljs
(.getTime (js/Date.)))))
(defprotocol IConnection
(close
[conn]
"Close this connection. Returns a pair channel of [nil error].")
(db
[conn]
"Get the current DB associated with this connection.")
(history
[conn]
"Get the full transaction history DB associated with this connection."))
(defrecord Connection [current-db]
IConnection
(close [conn] (close-db @(:current-db conn)))
(db [conn] @(:current-db conn))
(history [conn]
(raise "Datomic's history is not yet supported." {})))
(defn conn? [x]
(and (satisfies? IConnection x)))
;; ----------------------------------------------------------------------------
;; define data-readers to be made available to EDN readers. in CLJS
;; they're magically available. in CLJ, data_readers.clj may or may
;; not work, but you can always simply do
;;
;; (clojure.edn/read-string {:readers datomish/data-readers} "...")
;;
(defonce -id-literal-idx (atom -1000000))
(defrecord TempId [part idx])
(defn id-literal
([part]
(if (sequential? part)
(apply id-literal part)
(->TempId part (swap! -id-literal-idx dec))))
([part idx]
(->TempId part idx)))
(defn id-literal? [x]
(and (instance? TempId x)))
(defrecord TxReport [db-before ;; The DB before the transaction.
db-after ;; The DB after the transaction.
current-tx ;; The tx ID represented by the transaction in this report.
entities ;; The set of entities (like [:db/add e a v tx]) processed.
tx-data ;; The set of datoms applied to the database, like (Datom. e a v tx added).
tempids ;; The map from id-literal -> numeric entid.
added-parts ;; The set of parts added during the transaction via :db.part/db :db.install/part.
added-idents ;; The map of idents -> entid added during the transaction, via e :db/ident ident.
added-attributes ;; The map of schema attributes (ident -> schema fragment) added during the transaction, via :db.part/db :db.install/attribute.
])
(defn- report? [x]
(and (instance? TxReport x)))
(defonce -eid (atom (- 0x200 1)))
;; TODO: better here.
(defn- next-eid [db]
(swap! -eid inc))
(defn- allocate-eid
[report id-literal eid]
{:pre [(report? report) (id-literal? id-literal) (and (integer? eid) (not (neg? eid)))]}
(assoc-in report [:tempids id-literal] eid))
;; (def data-readers {'db/id id-literal})
;; #?(:cljs
;; (doseq [[tag cb] data-readers] (cljs.reader/register-tag-parser! tag cb)))
;; TODO: implement support for DB parts?
(def tx0 0x2000000)
(defn <idents [sqlite-connection]
"Read the ident map materialized view from the given SQLite store.
Returns a map (keyword ident) -> (integer entid), like {:db/ident 0}."
(let [<-SQLite (get-in ds/value-type-map [:db.type/keyword :<-SQLite])] ;; TODO: make this a protocol.
(go-pair
(let [rows (<? (->>
{:select [:ident :entid] :from [:idents]}
(sql-format)
(s/all-rows sqlite-connection)))]
(into {} (map (fn [row] [(<-SQLite (:ident row)) (:entid row)])) rows)))))
(defn <current-tx [sqlite-connection]
"Find the largest tx written to the SQLite store.
Returns an integer, -1 if no transactions have been written yet."
(go-pair
(let [rows (<? (s/all-rows sqlite-connection ["SELECT COALESCE(MAX(tx), -1) AS current_tx FROM transactions"]))]
(:current_tx (first rows)))))
(defn <symbolic-schema [sqlite-connection]
"Read the schema map materialized view from the given SQLite store.
Returns a map (keyword ident) -> (map (keyword attribute -> keyword value)), like
{:db/ident {:db/cardinality :db.cardinality/one}}."
(let [<-SQLite (get-in ds/value-type-map [:db.type/keyword :<-SQLite])] ;; TODO: make this a protocol.
(go-pair
(->>
(->>
{:select [:ident :attr :value] :from [:schema]}
(sql-format)
(s/all-rows sqlite-connection))
(<?)
(group-by (comp <-SQLite :ident))
(map (fn [[ident rows]]
[ident
(into {} (map (fn [row]
[(<-SQLite (:attr row)) (<-SQLite (:value row))]) rows))]))
(into {})))))
(declare <with-internal)
(defn <db-with-sqlite-connection
[sqlite-connection]
(go-pair
(when-not (= sqlite-schema/current-version (<? (sqlite-schema/<ensure-current-version sqlite-connection)))
(raise "Could not ensure current SQLite schema version."))
(let [current-tx (<? (<current-tx sqlite-connection))
bootstrapped (>= current-tx 0)
current-tx (max current-tx tx0)]
(when-not bootstrapped
;; We need to bootstrap the DB.
(let [fail-alter-ident (fn [old new] (if-not (= old new)
(raise "Altering idents is not yet supported, got " new " altering existing ident " old
{:error :schema/alter-idents :old old :new new})
new))
fail-alter-attr (fn [old new] (if-not (= old new)
(raise "Altering schema attributes is not yet supported, got " new " altering existing schema attribute " old
{:error :schema/alter-schema :old old :new new})
new))]
(-> (map->DB
{:sqlite-connection sqlite-connection
:idents bootstrap/idents
:symbolic-schema bootstrap/symbolic-schema
:schema (ds/schema (into {} (map (fn [[k v]] [(k bootstrap/idents) v]) bootstrap/symbolic-schema))) ;; TODO: fail if ident missing.
:current-tx current-tx})
;; We use <with rather than <transact! to apply the bootstrap transaction data but to
;; not follow the regular schema application process. We can't apply the schema
;; changes, since the applied datoms would conflict with the bootstrapping idents and
;; schema. (The bootstrapping idents and schema are required to be able to write to
;; the database conveniently; without them, we'd have to manually write datoms to the
;; store. It's feasible but awkward.) After bootstrapping, we read back the idents
;; and schema, just like when we re-open.
(<with-internal (bootstrap/tx-data) fail-alter-ident fail-alter-attr)
(<?))))
;; We just bootstrapped, or we are returning to an already bootstrapped DB.
(let [idents (<? (<idents sqlite-connection))
symbolic-schema (<? (<symbolic-schema sqlite-connection))]
(when-not bootstrapped
(when (not (= idents bootstrap/idents))
(raise "After bootstrapping database, expected new materialized idents and old bootstrapped idents to be identical"
{:error :bootstrap/bad-idents,
:new idents :old bootstrap/idents
}))
(when (not (= symbolic-schema bootstrap/symbolic-schema))
(raise "After bootstrapping database, expected new materialized symbolic schema and old bootstrapped symbolic schema to be identical"
{:error :bootstrap/bad-symbolic-schema,
:new symbolic-schema :old bootstrap/symbolic-schema
})))
(map->DB
{:sqlite-connection sqlite-connection
:idents idents
:symbolic-schema symbolic-schema
:schema (ds/schema (into {} (map (fn [[k v]] [(k idents) v]) symbolic-schema))) ;; TODO: fail if ident missing.
:current-tx (inc current-tx)})))))
(defn connection-with-db [db]
(map->Connection {:current-db (atom db)}))
;; ;; TODO: persist max-tx and max-eid in SQLite.
(defn maybe-datom->entity [entity]
(cond
(datom? entity)
(->
(let [[e a v tx added] entity]
(if added
[:db/add [e a v tx]]
[:db/retract [e a v tx]]))
(with-meta (get (meta entity) :source)))
true
entity))
(defn- #?@(:clj [^Boolean reverse-ref?]
:cljs [^boolean reverse-ref?]) [attr]
(if (keyword? attr)
(= \_ (nth (name attr) 0))
(raise "Bad attribute type: " attr ", expected keyword"
{:error :transact/syntax, :attribute attr})))
(defn- reverse-ref [attr]
(if (keyword? attr)
(if (reverse-ref? attr)
(keyword (namespace attr) (subs (name attr) 1))
(keyword (namespace attr) (str "_" (name attr))))
(raise "Bad attribute type: " attr ", expected keyword"
{:error :transact/syntax, :attribute attr})))
(declare explode-entity)
(defn- explode-entity-a-v [db entity eid a v]
;; a should be symbolic at this point. Map it. TODO: use ident/entid to ensure we have a symbolic attr.
(let [reverse? (reverse-ref? a)
straight-a (if reverse? (reverse-ref a) a)
straight-a* (get-in db [:idents straight-a] straight-a)
_ (when (and reverse? (not (ds/ref? (schema db) straight-a*)))
(raise "Bad attribute " a ": reverse attribute name requires {:db/valueType :db.type/ref} in schema"
{:error :transact/syntax, :attribute a, :op entity}))
a* (get-in db [:idents a] a)]
(cond
reverse?
(explode-entity-a-v db entity v straight-a eid)
(and (map? v)
(not (id-literal? v)))
;; Another entity is given as a nested map.
(if (ds/ref? (schema db) straight-a*)
(let [other (assoc v (reverse-ref a) eid
;; TODO: make the new ID have the same part as the original eid.
;; TODO: make the new ID not show up in the tempids map. (Does Datomic exposed the new ID this way?)
:db/id (id-literal :db.part/user))]
(explode-entity db other))
(raise "Bad attribute " a ": nested map " v " given but attribute name requires {:db/valueType :db.type/ref} in schema"
{:error :transact/entity-map-type-ref
:op entity }))
(sequential? v)
(if (ds/multival? (schema db) a*) ;; dm/schema
(mapcat (partial explode-entity-a-v db entity eid a) v) ;; Allow sequences of nested maps, etc. This does mean [[1]] will work.
(raise "Sequential values " v " but attribute " a " is :db.cardinality/one"
{:error :transact/entity-sequential-cardinality-one
:op entity }))
true
[[:db/add eid a* v]])))
(defn- explode-entity [db entity]
(if (map? entity)
(if-let [eid (:db/id entity)]
(mapcat (partial apply explode-entity-a-v db entity eid) (dissoc entity :db/id))
(raise "Map entity missing :db/id, got " entity
{:error :transact/entity-missing-db-id
:op entity }))
[entity]))
(defn explode-entities [db entities]
"Explode map shorthand, such as {:db/id e :attr value :_reverse ref}, to a list of vectors,
like [[:db/add e :attr value] [:db/add ref :reverse e]]."
(mapcat (partial explode-entity db) entities))
(defn maybe-ident->entid [db [op e a v tx :as orig]]
(let [e (get (idents db) e e) ;; TODO: use ident, entid here.
a (get (idents db) a a)
v (if (ds/kw? (schema db) a) ;; TODO: decide if this is best. We could also check for ref and numeric types.
v
(get (idents db) v v))]
[op e a v tx]))
(defrecord Transaction [db tempids entities])
(defn- tx-entity [db report]
{:pre [(db? db) (report? report)]}
(let [tx (:tx report)
txInstant (:txInstant report)]
;; n.b., this must not be symbolic -- it's inserted after we map idents -> entids.
[:db/add tx (get-in db [:idents :db/txInstant]) txInstant]))
(defn ensure-entity-form [[op e a v & rest :as entity]]
(cond
(not (sequential? entity))
(raise "Bad entity " entity ", should be sequential at this point"
{:error :transact/bad-entity, :entity entity})
(not (contains? #{:db/add :db/retract} op))
(raise "Unrecognized operation " op " expected one of :db/add :db/retract at this point"
{:error :transact/bad-operation :entity entity })
(nil? e)
(raise "Bad entity: nil e in " entity
{:error :transact/bad-entity :entity entity })
(nil? a)
(raise "Bad entity: nil a in " entity
{:error :transact/bad-entity :entity entity })
(nil? v)
(raise "Bad entity: nil v in " entity
{:error :transact/bad-entity :entity entity })
(some? rest)
(raise "Bad entity: too long " entity
{:error :transact/bad-entity :entity entity })
true
entity))
(defn- tx-instant? [db [op e a & _]]
(and (= op :db/add)
(= e (get-in db [:idents :db/tx]))
(= a (get-in db [:idents :db/txInstant]))))
(defn- update-txInstant [db report]
"Extract [:db/add :db/tx :db/txInstant ...], and update :txInstant with that value."
{:pre [(db? db) (report? report)]}
;; TODO: be more efficient here: don't iterate all entities.
(if-let [[_ _ _ txInstant] (first (filter (partial tx-instant? db) (:entities report)))]
(assoc report :txInstant txInstant)
report))
(defn preprocess [db report]
{:pre [(db? db) (report? report)]}
(let [initial-es (or (:entities report) [])
;; :db/tx is a "dynamic enum ident" that maps to the current transaction ID. This approach
;; mimics DataScript's :db/current-tx. (We don't follow DataScript because
;; current-txInstant is awkward.) It's much simpler than Datomic's approach, which appears
;; to unify all id-literals in :db.part/tx to the current transaction value, but also seems
;; inconsistent.
tx (:tx report)
db* (assoc-in db [:idents :db/tx] tx)]
(when-not (sequential? initial-es)
(raise "Bad transaction data " initial-es ", expected sequential collection"
{:error :transact/syntax, :tx-data initial-es}))
;; TODO: find an approach that generates less garbage.
(->
report
;; Normalize Datoms into :db/add or :db/retract vectors.
(update :entities (partial map maybe-datom->entity))
(update :entities (partial explode-entities db))
(update :entities (partial map ensure-entity-form))
;; Replace idents with entids where possible, using db* to capture :db/tx.
(update :entities (partial map (partial maybe-ident->entid db*)))
;; If an explicit [:db/add :db/tx :db/txInstant] is not given, add one. Use db* to
;; capture :db/tx.
(update :entities (fn [entities]
(if (first (filter (partial tx-instant? db*) entities))
entities
(conj entities (tx-entity db report)))))
;; Extract the current txInstant for the report.
(->> (update-txInstant db*)))))
(defn- lookup-ref? [x]
"Return true if `x` is like [:attr value]."
(and (sequential? x)
(= (count x) 2)
(or (keyword? (first x))
(integer? (first x)))))
;; TODO: factor this into the overall design.
(defn <?run
"Execute the provided query on the provided DB.
Returns a transduced channel of [result err] pairs.
@ -648,372 +268,3 @@
[db find args]
(a/reduce (partial reduce-error-pair conj) [[] nil]
(<?run db find args)))
(defn <resolve-lookup-refs [db report]
{:pre [(db? db) (report? report)]}
(go-pair
(->>
(vec (for [[op & entity] (:entities report)]
(into [op] (for [field entity]
(if (lookup-ref? field)
(first (<? (<eavt db field))) ;; TODO improve this -- this should be avet, shouldn't it?
field)))))
(assoc-in report [:entities])))) ;; TODO: meta.
(declare <resolve-id-literals)
(defn <retry-with-tempid [db report es tempid upserted-eid]
(if (contains? (:tempids report) tempid)
(go-pair
(raise "Conflicting upsert: " tempid " resolves"
" both to " upserted-eid " and " (get (:tempids report) tempid)
{ :error :transact/upsert }))
;; try to re-run from the beginning
;; but remembering that `old-eid` will resolve to `upserted-eid`
(<resolve-id-literals db
(->
report
(assoc-in [:tempids tempid] upserted-eid)
(assoc-in [:entities] es)))))
(defn- transact-entity [report entity]
(update-in report [:entities] conj entity))
(defn <resolve-id-literals
"Upsert uniquely identified literals when possible and allocate new entids for all other id literals.
It's worth noting that some amount of trial and error is probably
necessary here, since [[-1 :ref -2] [-2 :ref -1]] is a valid input.
It's my belief that no graph algorithm can correctly order the
id-literals in quasi-linear time, since that algorithm will need to
accept all permutations of the id-literals. Therefore, we simplify
by accepting that we may process the input multiple times, and we
regain some efficiency by sorting so that upserts happen earlier and
we are most likely to find a successful entid allocation without
multiple trials.
Concretely, we sort [-1 a v] < [-1 a -2] < [e a -1] < [e a v]. This
means simple upserts will be processed early, followed by entities
with multiple id-literals that we hope will reduce to simple upserts
based on the earlier upserts. After that, we handle what should be
simple allocations."
[db report]
{:pre [(db? db) (report? report)]}
(go-pair
(let [keyfn (fn [[op e a v]]
(if (and (id-literal? e)
(not-any? id-literal? [a v]))
(- 5)
(- (count (filter id-literal? [e a v])))))
initial-report (assoc report :entities []) ;; TODO.
initial-entities (sort-by keyfn (:entities report))]
(loop [report initial-report
es initial-entities]
(let [[[op e a v :as entity] & entities] es]
(cond
(nil? entity)
report
(and (not= op :db/add)
(not (empty? (filter id-literal? [e a v]))))
(raise "id-literals are resolved for :db/add only"
{:error :transact/syntax
:op entity })
;; Upsert!
(and (id-literal? e)
(ds/unique-identity? (schema db) a)
(not-any? id-literal? [a v]))
(let [upserted-eid (:e (first (<? (<avet db [a v]))))
allocated-eid (get-in report [:tempids e])]
(if (and upserted-eid allocated-eid (not= upserted-eid allocated-eid))
(<? (<retry-with-tempid db initial-report initial-entities e upserted-eid)) ;; TODO: not initial report, just the sorted entities here.
(let [eid (or upserted-eid allocated-eid (next-eid db))]
(recur (allocate-eid report e eid) (cons [op eid a v] entities)))))
;; Start allocating and retrying. We try with e last, so as to eventually upsert it.
(id-literal? v)
;; We can't fail with unbound literals here, since we could have multiple.
(let [eid (or (get-in report [:tempids v]) (next-eid db))]
(recur (allocate-eid report v eid) (cons [op e a eid] entities)))
(id-literal? a)
;; TODO: should we even allow id-literal attributes? Datomic fails in some cases here.
(let [eid (or (get-in report [:tempids a]) (next-eid db))]
(recur (allocate-eid report a eid) (cons [op e eid v] entities)))
(id-literal? e)
(let [eid (or (get-in report [:tempids e]) (next-eid db))]
(recur (allocate-eid report e eid) (cons [op eid a v] entities)))
true
(recur (transact-entity report entity) entities)
))))))
(defn- transact-report [report datom]
(update-in report [:tx-data] conj datom))
(defn- <ensure-schema-constraints
"Throw unless all entities in :entities obey the schema constraints."
[db report]
{:pre [(db? db) (report? report)]}
;; TODO: consider accumulating errors to show more meaningful error reports.
;; TODO: constrain entities; constrain attributes.
(go-pair
(let [schema (schema db)]
(doseq [[op e a v] (:entities report)]
(ds/ensure-valid-value schema a v)))
report))
(defn- <ensure-unique-constraints
"Throw unless all datoms in :tx-data obey the uniqueness constraints."
[db report]
{:pre [(db? db) (report? report)]}
;; TODO: consider accumulating errors to show more meaningful error reports.
;; TODO: constrain entities; constrain attributes.
(go-pair
;; TODO: comment on applying datoms that violate uniqueness.
(let [schema (schema db)
unique-datoms (transient {})] ;; map (nil, a, v)|(e, a, nil)|(e, a, v) -> datom.
(doseq [[e a v tx added :as datom] (:tx-data report)]
(when added
;; Check for violated :db/unique constraint between datom and existing store.
(when (ds/unique? schema a)
(when-let [found (first (<? (<avet db [a v])))]
(raise "Cannot add " datom " because of unique constraint: " found
{:error :transact/unique
:attribute a ;; TODO: map attribute back to ident.
:entity datom})))
;; Check for violated :db/unique constraint between datoms.
(when (ds/unique? schema a)
(let [key [nil a v]]
(when-let [other (get unique-datoms key)]
(raise "Cannot add " datom " and " other " because together they violate unique constraint"
{:error :transact/unique
:attribute a ;; TODO: map attribute back to ident.
:entity datom}))
(assoc! unique-datoms key datom)))
;; Check for violated :db/cardinality :db.cardinality/one constraint between datoms.
(when-not (ds/multival? schema a)
(let [key [e a nil]]
(when-let [other (get unique-datoms key)]
(raise "Cannot add " datom " and " other " because together they violate cardinality constraint"
{:error :transact/unique
:entity datom}))
(assoc! unique-datoms key datom)))
;; Check for duplicated datoms. Datomic doesn't allow overlapping writes, and we don't
;; want to guarantee order, so we don't either.
(let [key [e a v]]
(when-let [other (get unique-datoms key)]
(raise "Cannot add duplicate " datom
{:error :transact/unique
:entity datom}))
(assoc! unique-datoms key datom)))))
report))
(defn <entities->tx-data [db report]
{:pre [(db? db) (report? report)]}
(go-pair
(let [initial-report report
{tx :tx} report
schema (schema db)]
(loop [report initial-report
es (:entities initial-report)]
(let [[[op e a v :as entity] & entities] es]
(cond
(nil? entity)
report
(= op :db/add)
(if (ds/multival? schema a)
(if (empty? (<? (<eavt db [e a v])))
(recur (transact-report report (datom e a v tx true)) entities)
(recur report entities))
(if-let [^Datom old-datom (first (<? (<eavt db [e a])))]
(if (= (.-v old-datom) v)
(recur report entities)
(recur (-> report
(transact-report (datom e a (.-v old-datom) tx false))
(transact-report (datom e a v tx true)))
entities))
(recur (transact-report report (datom e a v tx true)) entities)))
(= op :db/retract)
(if (first (<? (<eavt db [e a v])))
(recur (transact-report report (datom e a v tx false)) entities)
(recur report entities))
true
(raise "Unknown operation at " entity ", expected :db/add, :db/retract"
{:error :transact/syntax, :operation op, :tx-data entity})))))))
(defn <transact-tx-data
[db report]
{:pre [(db? db) (report? report)]}
(go-pair
(->>
report
(preprocess db)
(<resolve-lookup-refs db)
(<?)
(<resolve-id-literals db)
(<?)
(<ensure-schema-constraints db)
(<?)
(<entities->tx-data db)
(<?)
(<ensure-unique-constraints db)
(<?))))
;; Normalize as [op int|id-literal int|id-literal value|id-literal]. ;; TODO: mention lookup-refs.
;; Replace lookup-refs with entids where possible.
;; Upsert or allocate id-literals.
(defn- is-ident? [db [_ a & _]]
(= a (get-in db [:idents :db/ident])))
(defn collect-db-ident-assertions
"Transactions may add idents, install new partitions, and install new schema attributes.
Collect :db/ident assertions into :added-idents here."
[db report]
{:pre [(db? db) (report? report)]}
;; TODO: use q to filter the report!
(let [original-report report
tx-data (:tx-data report)
original-ident-assertions (filter (partial is-ident? db) tx-data)]
(loop [report original-report
ident-assertions original-ident-assertions]
(let [[ia & ias] ident-assertions]
(cond
(nil? ia)
report
(not (:added ia))
(raise "Retracting a :db/ident is not yet supported, got " ia
{:error :schema/idents
:op ia })
:else
;; Added.
(let [ident (:v ia)]
(if (keyword? ident)
(recur (assoc-in report [:added-idents ident] (:e ia)) ias)
(raise "Cannot assert a :db/ident with a non-keyword value, got " ia
{:error :schema/idents
:op ia }))))))))
(defn- symbolicate-datom [db [e a v added]]
(let [entids (zipmap (vals (idents db)) (keys (idents db)))
symbolicate (fn [x]
(get entids x x))]
(datom
(symbolicate e)
(symbolicate a)
(symbolicate v)
added)))
(defn collect-db-install-assertions
"Transactions may add idents, install new partitions, and install new schema attributes.
Collect [:db.part/db :db.install/attribute] assertions here."
[db report]
{:pre [(db? db) (report? report)]}
;; TODO: be more efficient; symbolicating each datom is expensive!
(let [datoms (map (partial symbolicate-datom db) (:tx-data report))
schema-fragment (datomish.schema-changes/datoms->schema-fragment datoms)]
(assoc-in report [:added-attributes] schema-fragment)))
(defn- <with-internal [db tx-data merge-ident merge-attr]
(go-pair
(let [report (->>
(map->TxReport
{:db-before db
:db-after db
;; This mimics DataScript. It's convenient to be able to extract the
;; transaction ID and transaction timestamp directly from the report; Datomic
;; makes this surprisingly difficult: one needs a :db.part/tx temporary and an
;; explicit upsert of that temporary.
:tx (current-tx db)
:txInstant (now db)
:entities tx-data
:tx-data []
:tempids {}
:added-parts {}
:added-idents {}
:added-attributes {}
})
(<transact-tx-data db)
(<?)
(collect-db-ident-assertions db)
(collect-db-install-assertions db))
idents (merge-with merge-ident (:idents db) (:added-idents report))
symbolic-schema (merge-with merge-attr (:symbolic-schema db) (:added-attributes report))
schema (ds/schema (into {} (map (fn [[k v]] [(k idents) v]) symbolic-schema)))
db-after (->
db
(<apply-datoms (:tx-data report))
(<?)
(<apply-db-ident-assertions (:added-idents report))
(<?)
(<apply-db-install-assertions (:added-attributes report))
(<?)
(assoc :idents idents
:symbolic-schema symbolic-schema
:schema schema)
(<advance-tx)
(<?))]
(-> report
(assoc-in [:db-after] db-after)))))
(defn- <with [db tx-data]
(let [fail-touch-ident (fn [old new] (raise "Altering idents is not yet supported, got " new " altering existing ident " old
{:error :schema/alter-idents :old old :new new}))
fail-touch-attr (fn [old new] (raise "Altering schema attributes is not yet supported, got " new " altering existing schema attribute " old
{:error :schema/alter-schema :old old :new new}))]
(<with-internal db tx-data fail-touch-ident fail-touch-attr)))
(defn <db-with [db tx-data]
(go-pair
(:db-after (<? (<with db tx-data)))))
(defn <transact!
[conn tx-data]
{:pre [(conn? conn)]}
(let [db (db conn)] ;; TODO: be careful with swapping atoms.
(in-transaction!
db
#(go-pair
(let [report (<? (<with db tx-data))]
(reset! (:current-db conn) (:db-after report))
report)))))

View file

@ -0,0 +1,124 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
(ns datomish.db-factory
#?(:cljs
(:require-macros
[datomish.pair-chan :refer [go-pair <?]]
[cljs.core.async.macros :refer [go]]))
(:require
[datomish.db :as db]
[datomish.transact :as transact]
[datomish.transact.bootstrap :as bootstrap]
[datomish.util :as util #?(:cljs :refer-macros :clj :refer) [raise raise-str cond-let]]
[datomish.schema :as ds]
[datomish.sqlite :as s]
[datomish.sqlite-schema :as sqlite-schema]
#?@(:clj [[datomish.pair-chan :refer [go-pair <?]]
[clojure.core.async :as a :refer [chan go <! >!]]])
#?@(:cljs [[datomish.pair-chan]
[cljs.core.async :as a :refer [chan <! >!]]]))
#?(:clj
(:import
[datomish.datom Datom])))
;; TODO: implement support for DB parts?
(def tx0 0x2000000)
(defn <idents [sqlite-connection]
"Read the ident map materialized view from the given SQLite store.
Returns a map (keyword ident) -> (integer entid), like {:db/ident 0}."
(let [<-SQLite (get-in ds/value-type-map [:db.type/keyword :<-SQLite])] ;; TODO: make this a protocol.
(go-pair
(let [rows (<? (->>
{:select [:ident :entid] :from [:idents]}
(s/format)
(s/all-rows sqlite-connection)))]
(into {} (map (fn [row] [(<-SQLite (:ident row)) (:entid row)])) rows)))))
(defn <current-tx [sqlite-connection]
"Find the largest tx written to the SQLite store.
Returns an integer, -1 if no transactions have been written yet."
(go-pair
(let [rows (<? (s/all-rows sqlite-connection ["SELECT COALESCE(MAX(tx), -1) AS current_tx FROM transactions"]))]
(:current_tx (first rows)))))
(defn <symbolic-schema [sqlite-connection]
"Read the schema map materialized view from the given SQLite store.
Returns a map (keyword ident) -> (map (keyword attribute -> keyword value)), like
{:db/ident {:db/cardinality :db.cardinality/one}}."
(let [<-SQLite (get-in ds/value-type-map [:db.type/keyword :<-SQLite])] ;; TODO: make this a protocol.
(go-pair
(->>
(->>
{:select [:ident :attr :value] :from [:schema]}
(s/format)
(s/all-rows sqlite-connection))
(<?)
(group-by (comp <-SQLite :ident))
(map (fn [[ident rows]]
[ident
(into {} (map (fn [row]
[(<-SQLite (:attr row)) (<-SQLite (:value row))]) rows))]))
(into {})))))
(defn <db-with-sqlite-connection
[sqlite-connection]
(go-pair
(when-not (= sqlite-schema/current-version (<? (sqlite-schema/<ensure-current-version sqlite-connection)))
(raise "Could not ensure current SQLite schema version."))
(let [current-tx (<? (<current-tx sqlite-connection))
bootstrapped (>= current-tx 0)
current-tx (max current-tx tx0)]
(when-not bootstrapped
;; We need to bootstrap the DB.
(let [fail-alter-ident (fn [old new] (if-not (= old new)
(raise "Altering idents is not yet supported, got " new " altering existing ident " old
{:error :schema/alter-idents :old old :new new})
new))
fail-alter-attr (fn [old new] (if-not (= old new)
(raise "Altering schema attributes is not yet supported, got " new " altering existing schema attribute " old
{:error :schema/alter-schema :old old :new new})
new))]
(-> (db/map->DB
{:sqlite-connection sqlite-connection
:idents bootstrap/idents
:symbolic-schema bootstrap/symbolic-schema
:schema (ds/schema (into {} (map (fn [[k v]] [(k bootstrap/idents) v]) bootstrap/symbolic-schema))) ;; TODO: fail if ident missing.
:current-tx current-tx})
;; We use <with-internal rather than <transact! to apply the bootstrap transaction
;; data but to not follow the regular schema application process. We can't apply the
;; schema changes, since the applied datoms would conflict with the bootstrapping
;; idents and schema. (The bootstrapping idents and schema are required to be able to
;; write to the database conveniently; without them, we'd have to manually write
;; datoms to the store. It's feasible but awkward.) After bootstrapping, we read
;; back the idents and schema, just like when we re-open.
(transact/<with-internal (bootstrap/tx-data) fail-alter-ident fail-alter-attr)
(<?))))
;; We just bootstrapped, or we are returning to an already bootstrapped DB.
(let [idents (<? (<idents sqlite-connection))
symbolic-schema (<? (<symbolic-schema sqlite-connection))]
(when-not bootstrapped
(when (not (= idents bootstrap/idents))
(raise "After bootstrapping database, expected new materialized idents and old bootstrapped idents to be identical"
{:error :bootstrap/bad-idents,
:new idents :old bootstrap/idents
}))
(when (not (= symbolic-schema bootstrap/symbolic-schema))
(raise "After bootstrapping database, expected new materialized symbolic schema and old bootstrapped symbolic schema to be identical"
{:error :bootstrap/bad-symbolic-schema,
:new symbolic-schema :old bootstrap/symbolic-schema
})))
(db/map->DB
{:sqlite-connection sqlite-connection
:idents idents
:symbolic-schema symbolic-schema
:schema (ds/schema (into {} (map (fn [[k v]] [(k idents) v]) symbolic-schema))) ;; TODO: fail if ident missing.
:current-tx (inc current-tx)})))))

View file

@ -3,19 +3,29 @@
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
(ns datomish.sqlite
(:refer-clojure :exclude [format])
#?(:cljs
(:require-macros
[datomish.pair-chan :refer [go-pair go-safely <?]]
[cljs.core.async.macros :refer [go]]))
#?(:clj
(:require
[honeysql.core]
[datomish.pair-chan :refer [go-pair go-safely <?]]
[clojure.core.async :refer [go <! >! chan put! take! close!]])
:cljs
(:require
[honeysql.core]
[datomish.pair-chan]
[cljs.core.async :as a :refer [<! >! chan put! take! close!]])))
;; Setting this to something else will make your output more readable,
;; but not automatically safe for use.
(def sql-quoting-style :ansi)
(defn format [args]
(honeysql.core/format args :quoting :ansi))
(defprotocol ISQLiteConnection
(-execute!
[db sql bindings]

671
src/datomish/transact.cljc Normal file
View file

@ -0,0 +1,671 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
(ns datomish.transact
#?(:cljs
(:require-macros
[datomish.pair-chan :refer [go-pair <?]]
[cljs.core.async.macros :refer [go]]))
(:require
[datomish.query.context :as context]
[datomish.query.projection :as projection]
[datomish.query.source :as source]
[datomish.query :as query]
[datomish.db :as db]
[datomish.datom :as dd :refer [datom datom? #?@(:cljs [Datom])]]
[datomish.util :as util #?(:cljs :refer-macros :clj :refer) [raise raise-str cond-let]]
[datomish.schema :as ds]
[datomish.schema-changes]
[datomish.sqlite :as s]
[datomish.sqlite-schema :as sqlite-schema]
[datomish.transact.bootstrap :as bootstrap]
#?@(:clj [[datomish.pair-chan :refer [go-pair <?]]
[clojure.core.async :as a :refer [chan go <! >!]]])
#?@(:cljs [[datomish.pair-chan]
[cljs.core.async :as a :refer [chan <! >!]]]))
#?(:clj
(:import
[datomish.datom Datom])))
(defprotocol IConnection
(close
[conn]
"Close this connection. Returns a pair channel of [nil error].")
(db
[conn]
"Get the current DB associated with this connection.")
(history
[conn]
"Get the full transaction history DB associated with this connection."))
(defrecord Connection [current-db]
IConnection
(close [conn] (db/close-db @(:current-db conn)))
(db [conn] @(:current-db conn))
(history [conn]
(raise "Datomic's history is not yet supported." {})))
(defn conn? [x]
(and (satisfies? IConnection x)))
;; ----------------------------------------------------------------------------
;; define data-readers to be made available to EDN readers. in CLJS
;; they're magically available. in CLJ, data_readers.clj may or may
;; not work, but you can always simply do
;;
;; (clojure.edn/read-string {:readers datomish/data-readers} "...")
;;
(defonce -id-literal-idx (atom -1000000))
(defrecord TempId [part idx])
(defn id-literal
([part]
(if (sequential? part)
(apply id-literal part)
(->TempId part (swap! -id-literal-idx dec))))
([part idx]
(->TempId part idx)))
(defn id-literal? [x]
(and (instance? TempId x)))
(defrecord TxReport [db-before ;; The DB before the transaction.
db-after ;; The DB after the transaction.
current-tx ;; The tx ID represented by the transaction in this report.
entities ;; The set of entities (like [:db/add e a v tx]) processed.
tx-data ;; The set of datoms applied to the database, like (Datom. e a v tx added).
tempids ;; The map from id-literal -> numeric entid.
added-parts ;; The set of parts added during the transaction via :db.part/db :db.install/part.
added-idents ;; The map of idents -> entid added during the transaction, via e :db/ident ident.
added-attributes ;; The map of schema attributes (ident -> schema fragment) added during the transaction, via :db.part/db :db.install/attribute.
])
(defn- report? [x]
(and (instance? TxReport x)))
(defonce -eid (atom (- 0x200 1)))
;; TODO: better here.
(defn- next-eid [db]
(swap! -eid inc))
(defn- allocate-eid
[report id-literal eid]
{:pre [(report? report) (id-literal? id-literal) (and (integer? eid) (not (neg? eid)))]}
(assoc-in report [:tempids id-literal] eid))
;; (def data-readers {'db/id id-literal})
;; #?(:cljs
;; (doseq [[tag cb] data-readers] (cljs.reader/register-tag-parser! tag cb)))
;; TODO: implement support for DB parts?
(defn connection-with-db [db]
(map->Connection {:current-db (atom db)}))
;; ;; TODO: persist max-tx and max-eid in SQLite.
(defn maybe-datom->entity [entity]
(cond
(datom? entity)
(->
(let [[e a v tx added] entity]
(if added
[:db/add [e a v tx]]
[:db/retract [e a v tx]]))
(with-meta (get (meta entity) :source)))
true
entity))
(defn- #?@(:clj [^Boolean reverse-ref?]
:cljs [^boolean reverse-ref?]) [attr]
(if (keyword? attr)
(= \_ (nth (name attr) 0))
(raise "Bad attribute type: " attr ", expected keyword"
{:error :transact/syntax, :attribute attr})))
(defn- reverse-ref [attr]
(if (keyword? attr)
(if (reverse-ref? attr)
(keyword (namespace attr) (subs (name attr) 1))
(keyword (namespace attr) (str "_" (name attr))))
(raise "Bad attribute type: " attr ", expected keyword"
{:error :transact/syntax, :attribute attr})))
(declare explode-entity)
(defn- explode-entity-a-v [db entity eid a v]
;; a should be symbolic at this point. Map it. TODO: use ident/entid to ensure we have a symbolic attr.
(let [reverse? (reverse-ref? a)
straight-a (if reverse? (reverse-ref a) a)
straight-a* (get-in db [:idents straight-a] straight-a)
_ (when (and reverse? (not (ds/ref? (db/schema db) straight-a*)))
(raise "Bad attribute " a ": reverse attribute name requires {:db/valueType :db.type/ref} in schema"
{:error :transact/syntax, :attribute a, :op entity}))
a* (get-in db [:idents a] a)]
(cond
reverse?
(explode-entity-a-v db entity v straight-a eid)
(and (map? v)
(not (id-literal? v)))
;; Another entity is given as a nested map.
(if (ds/ref? (db/schema db) straight-a*)
(let [other (assoc v (reverse-ref a) eid
;; TODO: make the new ID have the same part as the original eid.
;; TODO: make the new ID not show up in the tempids map. (Does Datomic exposed the new ID this way?)
:db/id (id-literal :db.part/user))]
(explode-entity db other))
(raise "Bad attribute " a ": nested map " v " given but attribute name requires {:db/valueType :db.type/ref} in schema"
{:error :transact/entity-map-type-ref
:op entity }))
(sequential? v)
(if (ds/multival? (db/schema db) a*) ;; dm/schema
(mapcat (partial explode-entity-a-v db entity eid a) v) ;; Allow sequences of nested maps, etc. This does mean [[1]] will work.
(raise "Sequential values " v " but attribute " a " is :db.cardinality/one"
{:error :transact/entity-sequential-cardinality-one
:op entity }))
true
[[:db/add eid a* v]])))
(defn- explode-entity [db entity]
(if (map? entity)
(if-let [eid (:db/id entity)]
(mapcat (partial apply explode-entity-a-v db entity eid) (dissoc entity :db/id))
(raise "Map entity missing :db/id, got " entity
{:error :transact/entity-missing-db-id
:op entity }))
[entity]))
(defn explode-entities [db entities]
"Explode map shorthand, such as {:db/id e :attr value :_reverse ref}, to a list of vectors,
like [[:db/add e :attr value] [:db/add ref :reverse e]]."
(mapcat (partial explode-entity db) entities))
(defn maybe-ident->entid [db [op e a v tx :as orig]]
(let [e (get (db/idents db) e e) ;; TODO: use ident, entid here.
a (get (db/idents db) a a)
v (if (ds/kw? (db/schema db) a) ;; TODO: decide if this is best. We could also check for ref and numeric types.
v
(get (db/idents db) v v))]
[op e a v tx]))
(defrecord Transaction [db tempids entities])
(defn- tx-entity [db report]
{:pre [(db/db? db) (report? report)]}
(let [tx (:tx report)
txInstant (:txInstant report)]
;; n.b., this must not be symbolic -- it's inserted after we map idents -> entids.
[:db/add tx (get-in db [:idents :db/txInstant]) txInstant]))
(defn ensure-entity-form [[op e a v & rest :as entity]]
(cond
(not (sequential? entity))
(raise "Bad entity " entity ", should be sequential at this point"
{:error :transact/bad-entity, :entity entity})
(not (contains? #{:db/add :db/retract} op))
(raise "Unrecognized operation " op " expected one of :db/add :db/retract at this point"
{:error :transact/bad-operation :entity entity })
(nil? e)
(raise "Bad entity: nil e in " entity
{:error :transact/bad-entity :entity entity })
(nil? a)
(raise "Bad entity: nil a in " entity
{:error :transact/bad-entity :entity entity })
(nil? v)
(raise "Bad entity: nil v in " entity
{:error :transact/bad-entity :entity entity })
(some? rest)
(raise "Bad entity: too long " entity
{:error :transact/bad-entity :entity entity })
true
entity))
(defn- tx-instant? [db [op e a & _]]
(and (= op :db/add)
(= e (get-in db [:idents :db/tx]))
(= a (get-in db [:idents :db/txInstant]))))
(defn- update-txInstant [db report]
"Extract [:db/add :db/tx :db/txInstant ...], and update :txInstant with that value."
{:pre [(db/db? db) (report? report)]}
;; TODO: be more efficient here: don't iterate all entities.
(if-let [[_ _ _ txInstant] (first (filter (partial tx-instant? db) (:entities report)))]
(assoc report :txInstant txInstant)
report))
(defn preprocess [db report]
{:pre [(db/db? db) (report? report)]}
(let [initial-es (or (:entities report) [])
;; :db/tx is a "dynamic enum ident" that maps to the current transaction ID. This approach
;; mimics DataScript's :db/current-tx. (We don't follow DataScript because
;; current-txInstant is awkward.) It's much simpler than Datomic's approach, which appears
;; to unify all id-literals in :db.part/tx to the current transaction value, but also seems
;; inconsistent.
tx (:tx report)
db* (assoc-in db [:idents :db/tx] tx)]
(when-not (sequential? initial-es)
(raise "Bad transaction data " initial-es ", expected sequential collection"
{:error :transact/syntax, :tx-data initial-es}))
;; TODO: find an approach that generates less garbage.
(->
report
;; Normalize Datoms into :db/add or :db/retract vectors.
(update :entities (partial map maybe-datom->entity))
(update :entities (partial explode-entities db))
(update :entities (partial map ensure-entity-form))
;; Replace idents with entids where possible, using db* to capture :db/tx.
(update :entities (partial map (partial maybe-ident->entid db*)))
;; If an explicit [:db/add :db/tx :db/txInstant] is not given, add one. Use db* to
;; capture :db/tx.
(update :entities (fn [entities]
(if (first (filter (partial tx-instant? db*) entities))
entities
(conj entities (tx-entity db report)))))
;; Extract the current txInstant for the report.
(->> (update-txInstant db*)))))
(defn- lookup-ref? [x]
"Return true if `x` is like [:attr value]."
(and (sequential? x)
(= (count x) 2)
(or (keyword? (first x))
(integer? (first x)))))
(defn <resolve-lookup-refs [db report]
{:pre [(db/db? db) (report? report)]}
(go-pair
(->>
(vec (for [[op & entity] (:entities report)]
(into [op] (for [field entity]
(if (lookup-ref? field)
(first (<? (db/<eavt db field))) ;; TODO improve this -- this should be avet, shouldn't it?
field)))))
(assoc-in report [:entities])))) ;; TODO: meta.
(declare <resolve-id-literals)
(defn <retry-with-tempid [db report es tempid upserted-eid]
(if (contains? (:tempids report) tempid)
(go-pair
(raise "Conflicting upsert: " tempid " resolves"
" both to " upserted-eid " and " (get (:tempids report) tempid)
{ :error :transact/upsert }))
;; try to re-run from the beginning
;; but remembering that `old-eid` will resolve to `upserted-eid`
(<resolve-id-literals db
(->
report
(assoc-in [:tempids tempid] upserted-eid)
(assoc-in [:entities] es)))))
(defn- transact-entity [report entity]
(update-in report [:entities] conj entity))
(defn <resolve-id-literals
"Upsert uniquely identified literals when possible and allocate new entids for all other id literals.
It's worth noting that some amount of trial and error is probably
necessary here, since [[-1 :ref -2] [-2 :ref -1]] is a valid input.
It's my belief that no graph algorithm can correctly order the
id-literals in quasi-linear time, since that algorithm will need to
accept all permutations of the id-literals. Therefore, we simplify
by accepting that we may process the input multiple times, and we
regain some efficiency by sorting so that upserts happen earlier and
we are most likely to find a successful entid allocation without
multiple trials.
Concretely, we sort [-1 a v] < [-1 a -2] < [e a -1] < [e a v]. This
means simple upserts will be processed early, followed by entities
with multiple id-literals that we hope will reduce to simple upserts
based on the earlier upserts. After that, we handle what should be
simple allocations."
[db report]
{:pre [(db/db? db) (report? report)]}
(go-pair
(let [keyfn (fn [[op e a v]]
(if (and (id-literal? e)
(not-any? id-literal? [a v]))
(- 5)
(- (count (filter id-literal? [e a v])))))
initial-report (assoc report :entities []) ;; TODO.
initial-entities (sort-by keyfn (:entities report))]
(loop [report initial-report
es initial-entities]
(let [[[op e a v :as entity] & entities] es]
(cond
(nil? entity)
report
(and (not= op :db/add)
(not (empty? (filter id-literal? [e a v]))))
(raise "id-literals are resolved for :db/add only"
{:error :transact/syntax
:op entity })
;; Upsert!
(and (id-literal? e)
(ds/unique-identity? (db/schema db) a)
(not-any? id-literal? [a v]))
(let [upserted-eid (:e (first (<? (db/<avet db [a v]))))
allocated-eid (get-in report [:tempids e])]
(if (and upserted-eid allocated-eid (not= upserted-eid allocated-eid))
(<? (<retry-with-tempid db initial-report initial-entities e upserted-eid)) ;; TODO: not initial report, just the sorted entities here.
(let [eid (or upserted-eid allocated-eid (next-eid db))]
(recur (allocate-eid report e eid) (cons [op eid a v] entities)))))
;; Start allocating and retrying. We try with e last, so as to eventually upsert it.
(id-literal? v)
;; We can't fail with unbound literals here, since we could have multiple.
(let [eid (or (get-in report [:tempids v]) (next-eid db))]
(recur (allocate-eid report v eid) (cons [op e a eid] entities)))
(id-literal? a)
;; TODO: should we even allow id-literal attributes? Datomic fails in some cases here.
(let [eid (or (get-in report [:tempids a]) (next-eid db))]
(recur (allocate-eid report a eid) (cons [op e eid v] entities)))
(id-literal? e)
(let [eid (or (get-in report [:tempids e]) (next-eid db))]
(recur (allocate-eid report e eid) (cons [op eid a v] entities)))
true
(recur (transact-entity report entity) entities)
))))))
(defn- transact-report [report datom]
(update-in report [:tx-data] conj datom))
(defn- <ensure-schema-constraints
"Throw unless all entities in :entities obey the schema constraints."
[db report]
{:pre [(db/db? db) (report? report)]}
;; TODO: consider accumulating errors to show more meaningful error reports.
;; TODO: constrain entities; constrain attributes.
(go-pair
(let [schema (db/schema db)]
(doseq [[op e a v] (:entities report)]
(ds/ensure-valid-value schema a v)))
report))
(defn- <ensure-unique-constraints
"Throw unless all datoms in :tx-data obey the uniqueness constraints."
[db report]
{:pre [(db/db? db) (report? report)]}
;; TODO: consider accumulating errors to show more meaningful error reports.
;; TODO: constrain entities; constrain attributes.
(go-pair
;; TODO: comment on applying datoms that violate uniqueness.
(let [schema (db/schema db)
unique-datoms (transient {})] ;; map (nil, a, v)|(e, a, nil)|(e, a, v) -> datom.
(doseq [[e a v tx added :as datom] (:tx-data report)]
(when added
;; Check for violated :db/unique constraint between datom and existing store.
(when (ds/unique? schema a)
(when-let [found (first (<? (db/<avet db [a v])))]
(raise "Cannot add " datom " because of unique constraint: " found
{:error :transact/unique
:attribute a ;; TODO: map attribute back to ident.
:entity datom})))
;; Check for violated :db/unique constraint between datoms.
(when (ds/unique? schema a)
(let [key [nil a v]]
(when-let [other (get unique-datoms key)]
(raise "Cannot add " datom " and " other " because together they violate unique constraint"
{:error :transact/unique
:attribute a ;; TODO: map attribute back to ident.
:entity datom}))
(assoc! unique-datoms key datom)))
;; Check for violated :db/cardinality :db.cardinality/one constraint between datoms.
(when-not (ds/multival? schema a)
(let [key [e a nil]]
(when-let [other (get unique-datoms key)]
(raise "Cannot add " datom " and " other " because together they violate cardinality constraint"
{:error :transact/unique
:entity datom}))
(assoc! unique-datoms key datom)))
;; Check for duplicated datoms. Datomic doesn't allow overlapping writes, and we don't
;; want to guarantee order, so we don't either.
(let [key [e a v]]
(when-let [other (get unique-datoms key)]
(raise "Cannot add duplicate " datom
{:error :transact/unique
:entity datom}))
(assoc! unique-datoms key datom)))))
report))
(defn <entities->tx-data [db report]
{:pre [(db/db? db) (report? report)]}
(go-pair
(let [initial-report report
{tx :tx} report
schema (db/schema db)]
(loop [report initial-report
es (:entities initial-report)]
(let [[[op e a v :as entity] & entities] es]
(cond
(nil? entity)
report
(= op :db/add)
(if (ds/multival? schema a)
(if (empty? (<? (db/<eavt db [e a v])))
(recur (transact-report report (datom e a v tx true)) entities)
(recur report entities))
(if-let [^Datom old-datom (first (<? (db/<eavt db [e a])))]
(if (= (.-v old-datom) v)
(recur report entities)
(recur (-> report
(transact-report (datom e a (.-v old-datom) tx false))
(transact-report (datom e a v tx true)))
entities))
(recur (transact-report report (datom e a v tx true)) entities)))
(= op :db/retract)
(if (first (<? (db/<eavt db [e a v])))
(recur (transact-report report (datom e a v tx false)) entities)
(recur report entities))
true
(raise "Unknown operation at " entity ", expected :db/add, :db/retract"
{:error :transact/syntax, :operation op, :tx-data entity})))))))
(defn <transact-tx-data
[db report]
{:pre [(db/db? db) (report? report)]}
(go-pair
(->>
report
(preprocess db)
(<resolve-lookup-refs db)
(<?)
(<resolve-id-literals db)
(<?)
(<ensure-schema-constraints db)
(<?)
(<entities->tx-data db)
(<?)
(<ensure-unique-constraints db)
(<?))))
;; Normalize as [op int|id-literal int|id-literal value|id-literal]. ;; TODO: mention lookup-refs.
;; Replace lookup-refs with entids where possible.
;; Upsert or allocate id-literals.
(defn- is-ident? [db [_ a & _]]
(= a (get-in db [:idents :db/ident])))
(defn collect-db-ident-assertions
"Transactions may add idents, install new partitions, and install new schema attributes.
Collect :db/ident assertions into :added-idents here."
[db report]
{:pre [(db/db? db) (report? report)]}
;; TODO: use q to filter the report!
(let [original-report report
tx-data (:tx-data report)
original-ident-assertions (filter (partial is-ident? db) tx-data)]
(loop [report original-report
ident-assertions original-ident-assertions]
(let [[ia & ias] ident-assertions]
(cond
(nil? ia)
report
(not (:added ia))
(raise "Retracting a :db/ident is not yet supported, got " ia
{:error :schema/idents
:op ia })
:else
;; Added.
(let [ident (:v ia)]
(if (keyword? ident)
(recur (assoc-in report [:added-idents ident] (:e ia)) ias)
(raise "Cannot assert a :db/ident with a non-keyword value, got " ia
{:error :schema/idents
:op ia }))))))))
(defn- symbolicate-datom [db [e a v added]]
(let [entids (zipmap (vals (db/idents db)) (keys (db/idents db)))
symbolicate (fn [x]
(get entids x x))]
(datom
(symbolicate e)
(symbolicate a)
(symbolicate v)
added)))
(defn collect-db-install-assertions
"Transactions may add idents, install new partitions, and install new schema attributes.
Collect [:db.part/db :db.install/attribute] assertions here."
[db report]
{:pre [(db/db? db) (report? report)]}
;; TODO: be more efficient; symbolicating each datom is expensive!
(let [datoms (map (partial symbolicate-datom db) (:tx-data report))
schema-fragment (datomish.schema-changes/datoms->schema-fragment datoms)]
(assoc-in report [:added-attributes] schema-fragment)))
;; TODO: expose this in a more appropriate way.
(defn <with-internal [db tx-data merge-ident merge-attr]
(go-pair
(let [report (->>
(map->TxReport
{:db-before db
:db-after db
;; This mimics DataScript. It's convenient to be able to extract the
;; transaction ID and transaction timestamp directly from the report; Datomic
;; makes this surprisingly difficult: one needs a :db.part/tx temporary and an
;; explicit upsert of that temporary.
:tx (db/current-tx db)
:txInstant (db/now db)
:entities tx-data
:tx-data []
:tempids {}
:added-parts {}
:added-idents {}
:added-attributes {}
})
(<transact-tx-data db)
(<?)
(collect-db-ident-assertions db)
(collect-db-install-assertions db))
idents (merge-with merge-ident (:idents db) (:added-idents report))
symbolic-schema (merge-with merge-attr (:symbolic-schema db) (:added-attributes report))
schema (ds/schema (into {} (map (fn [[k v]] [(k idents) v]) symbolic-schema)))
db-after (->
db
(db/<apply-datoms (:tx-data report))
(<?)
(db/<apply-db-ident-assertions (:added-idents report))
(<?)
(db/<apply-db-install-assertions (:added-attributes report))
(<?)
;; TODO: abstract this.
(assoc :idents idents
:symbolic-schema symbolic-schema
:schema schema)
(db/<advance-tx)
(<?))]
(-> report
(assoc-in [:db-after] db-after)))))
(defn- <with [db tx-data]
(let [fail-touch-ident (fn [old new] (raise "Altering idents is not yet supported, got " new " altering existing ident " old
{:error :schema/alter-idents :old old :new new}))
fail-touch-attr (fn [old new] (raise "Altering schema attributes is not yet supported, got " new " altering existing schema attribute " old
{:error :schema/alter-schema :old old :new new}))]
(<with-internal db tx-data fail-touch-ident fail-touch-attr)))
(defn <db-with [db tx-data]
(go-pair
(:db-after (<? (<with db tx-data)))))
(defn <transact!
[conn tx-data]
{:pre [(conn? conn)]}
(let [db (db conn)] ;; TODO: be careful with swapping atoms.
(db/in-transaction!
db
#(go-pair
(let [report (<? (<with db tx-data))]
(reset! (:current-db conn) (:db-after report))
report)))))

40
test/datomish/api.cljc Normal file
View file

@ -0,0 +1,40 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
(ns datomish.api
#?(:cljs
(:require-macros
[datomish.pair-chan :refer [go-pair <?]]
[cljs.core.async.macros :as a :refer [go]]))
(:require
[datomish.db :as db]
[datomish.db-factory :as db-factory]
[datomish.sqlite :as sqlite]
[datomish.transact :as transact]
#?@(:clj [[datomish.pair-chan :refer [go-pair <?]]
[clojure.core.async :refer [go <! >!]]])
#?@(:cljs [[datomish.pair-chan]
[cljs.core.async :as a :refer [<! >!]]])))
(defn <connect [uri]
;; Eventually, URI. For now, just a plain path (no file://).
(go-pair
(->
(sqlite/<sqlite-connection uri)
(<?)
(db-factory/<db-with-sqlite-connection)
(<?)
(transact/connection-with-db))))
(def <transact! transact/<transact!)
;; TODO: use Potemkin, or a subset of Potemkin that is CLJS friendly (like
;; https://github.com/ztellman/potemkin/issues/31) to improve this re-exporting process.
(def <close transact/close)
(def id-literal transact/id-literal)
(def db transact/db)

View file

@ -9,15 +9,12 @@
[datomish.node-tempfile-macros :refer [with-tempfile]]
[cljs.core.async.macros :as a :refer [go]]))
(:require
[datomish.api :as d]
[datomish.util :as util #?(:cljs :refer-macros :clj :refer) [raise cond-let]]
[datomish.sqlite :as s]
[datomish.sqlite-schema]
[datomish.datom]
[datascript.core :as d]
[datascript.db :as db]
[datomish.db :as dm]
[datomish.db :as db]
#?@(:clj [[datomish.pair-chan :refer [go-pair <?]]
[tempfile.core :refer [tempfile with-tempfile]]
[datomish.test-macros :refer [deftest-async]]
@ -36,8 +33,11 @@
#?(:cljs
(def Throwable js/Error))
(defn- tempids [tx]
(into {} (map (juxt (comp :idx first) second) (:tempids tx))))
(defn- <datoms-after [db tx]
(let [entids (zipmap (vals (dm/idents db)) (keys (dm/idents db)))]
(let [entids (zipmap (vals (db/idents db)) (keys (db/idents db)))]
(go-pair
(->>
(s/all-rows (:sqlite-connection db) ["SELECT e, a, v, tx FROM datoms WHERE tx > ?" tx])
@ -51,7 +51,7 @@
(defn- <shallow-entity [db eid]
;; TODO: make this actually be <entity. Handle :db.cardinality/many and :db/isComponent.
(let [entids (zipmap (vals (dm/idents db)) (keys (dm/idents db)))]
(let [entids (zipmap (vals (db/idents db)) (keys (db/idents db)))]
(go-pair
(->>
(s/all-rows (:sqlite-connection db) ["SELECT a, v FROM datoms WHERE e = ?" eid])
@ -60,7 +60,7 @@
(reduce conj {})))))
(defn- <transactions-after [db tx]
(let [entids (zipmap (vals (dm/idents db)) (keys (dm/idents db)))]
(let [entids (zipmap (vals (db/idents db)) (keys (db/idents db)))]
(go-pair
(->>
(s/all-rows (:sqlite-connection db) ["SELECT e, a, v, tx, added FROM transactions WHERE tx > ? ORDER BY tx ASC, e, a, v, added" tx])
@ -79,80 +79,76 @@
;; TODO: use reverse refs!
(def test-schema
[{:db/id (dm/id-literal :test -1)
[{:db/id (d/id-literal :test -1)
:db/ident :x
:db/unique :db.unique/identity
:db/valueType :db.type/integer}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -1)}
{:db/id (dm/id-literal :test -2)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -1)}
{:db/id (d/id-literal :test -2)
:db/ident :name
:db/unique :db.unique/identity
:db/valueType :db.type/string}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -2)}
{:db/id (dm/id-literal :test -3)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -2)}
{:db/id (d/id-literal :test -3)
:db/ident :y
:db/cardinality :db.cardinality/many
:db/valueType :db.type/integer}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -3)}
{:db/id (dm/id-literal :test -5)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -3)}
{:db/id (d/id-literal :test -5)
:db/ident :aka
:db/cardinality :db.cardinality/many
:db/valueType :db.type/string}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -5)}
{:db/id (dm/id-literal :test -6)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -5)}
{:db/id (d/id-literal :test -6)
:db/ident :age
:db/valueType :db.type/integer}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -6)}
{:db/id (dm/id-literal :test -7)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -6)}
{:db/id (d/id-literal :test -7)
:db/ident :email
:db/unique :db.unique/identity
:db/valueType :db.type/string}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -7)}
{:db/id (dm/id-literal :test -8)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -7)}
{:db/id (d/id-literal :test -8)
:db/ident :spouse
:db/unique :db.unique/value
:db/valueType :db.type/string}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -8)}
{:db/id (dm/id-literal :test -9)
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -8)}
{:db/id (d/id-literal :test -9)
:db/ident :friends
:db/cardinality :db.cardinality/many
:db/valueType :db.type/ref}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :test -9)}
{:db/id :db.part/db :db.install/attribute (d/id-literal :test -9)}
])
(deftest-async test-add-one
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [{tx0 :tx} (<? (dm/<transact! conn test-schema))]
(let [{:keys [tx txInstant]} (<? (dm/<transact! conn [[:db/add 0 :name "valuex"]]))]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [{tx0 :tx} (<? (d/<transact! conn test-schema))]
(let [{:keys [tx txInstant]} (<? (d/<transact! conn [[:db/add 0 :name "valuex"]]))]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[0 :name "valuex"]}))
(is (= (<? (<transactions-after (dm/db conn) tx0))
(is (= (<? (<transactions-after (d/db conn) tx0))
[[0 :name "valuex" tx 1] ;; TODO: true, not 1.
[tx :db/txInstant txInstant tx 1]]))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-add-two
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [{tx0 :tx} (<? (dm/<transact! conn test-schema))
{tx1 :tx txInstant1 :txInstant} (<? (dm/<transact! conn [[:db/add 1 :name "Ivan"]]))
{tx2 :tx txInstant2 :txInstant} (<? (dm/<transact! conn [[:db/add 1 :name "Petr"]]))
{tx3 :tx txInstant3 :txInstant} (<? (dm/<transact! conn [[:db/add 1 :aka "Tupen"]]))
{tx4 :tx txInstant4 :txInstant} (<? (dm/<transact! conn [[:db/add 1 :aka "Devil"]]))]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [{tx0 :tx} (<? (d/<transact! conn test-schema))
{tx1 :tx txInstant1 :txInstant} (<? (d/<transact! conn [[:db/add 1 :name "Ivan"]]))
{tx2 :tx txInstant2 :txInstant} (<? (d/<transact! conn [[:db/add 1 :name "Petr"]]))
{tx3 :tx txInstant3 :txInstant} (<? (d/<transact! conn [[:db/add 1 :aka "Tupen"]]))
{tx4 :tx txInstant4 :txInstant} (<? (d/<transact! conn [[:db/add 1 :aka "Devil"]]))]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[1 :name "Petr"]
[1 :aka "Tupen"]
[1 :aka "Devil"]}))
(is (= (<? (<transactions-after (dm/db conn) tx0))
(is (= (<? (<transactions-after (d/db conn) tx0))
[[1 :name "Ivan" tx1 1] ;; TODO: true, not 1.
[tx1 :db/txInstant txInstant1 tx1 1]
[1 :name "Ivan" tx2 0]
@ -164,280 +160,259 @@
[tx4 :db/txInstant txInstant4 tx4 1]])))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-retract
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [{tx0 :tx} (<? (dm/<transact! conn test-schema))
{tx1 :tx txInstant1 :txInstant} (<? (dm/<transact! conn [[:db/add 0 :x 123]]))
{tx2 :tx txInstant2 :txInstant} (<? (dm/<transact! conn [[:db/retract 0 :x 123]]))]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [{tx0 :tx} (<? (d/<transact! conn test-schema))
{tx1 :tx txInstant1 :txInstant} (<? (d/<transact! conn [[:db/add 0 :x 123]]))
{tx2 :tx txInstant2 :txInstant} (<? (d/<transact! conn [[:db/retract 0 :x 123]]))]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{}))
(is (= (<? (<transactions-after (dm/db conn) tx0))
(is (= (<? (<transactions-after (d/db conn) tx0))
[[0 :x 123 tx1 1]
[tx1 :db/txInstant txInstant1 tx1 1]
[0 :x 123 tx2 0]
[tx2 :db/txInstant txInstant2 tx2 1]])))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-id-literal-1
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [tx0 (:tx (<? (dm/<transact! conn test-schema)))
report (<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user -1) :x 0]
[:db/add (dm/id-literal :db.part/user -1) :y 1]
[:db/add (dm/id-literal :db.part/user -2) :y 2]
[:db/add (dm/id-literal :db.part/user -2) :y 3]]))]
(let [tx0 (:tx (<? (d/<transact! conn test-schema)))
report (<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user -1) :x 0]
[:db/add (d/id-literal :db.part/user -1) :y 1]
[:db/add (d/id-literal :db.part/user -2) :y 2]
[:db/add (d/id-literal :db.part/user -2) :y 3]]))]
(is (= (keys (:tempids report)) ;; TODO: include values.
[(dm/id-literal :db.part/user -1)
(dm/id-literal :db.part/user -2)]))
[(d/id-literal :db.part/user -1)
(d/id-literal :db.part/user -2)]))
(let [eid1 (get-in report [:tempids (dm/id-literal :db.part/user -1)])
eid2 (get-in report [:tempids (dm/id-literal :db.part/user -2)])]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [eid1 (get-in report [:tempids (d/id-literal :db.part/user -1)])
eid2 (get-in report [:tempids (d/id-literal :db.part/user -2)])]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[eid1 :x 0]
[eid1 :y 1]
[eid2 :y 2]
[eid2 :y 3]}))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-unique
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [tx0 (:tx (<? (dm/<transact! conn test-schema)))]
(let [tx0 (:tx (<? (d/<transact! conn test-schema)))]
(testing "Multiple :db/unique values in tx-data violate unique constraint, no tempid"
(is (thrown-with-msg?
ExceptionInfo #"unique constraint"
(<? (dm/<transact! conn [[:db/add 1 :x 0]
[:db/add 2 :x 0]])))))
(<? (d/<transact! conn [[:db/add 1 :x 0]
[:db/add 2 :x 0]])))))
(testing "Multiple :db/unique values in tx-data violate unique constraint, tempid"
(is (thrown-with-msg?
ExceptionInfo #"unique constraint"
(<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user -1) :spouse "Dana"]
[:db/add (dm/id-literal :db.part/user -2) :spouse "Dana"]]))))))
(<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user -1) :spouse "Dana"]
[:db/add (d/id-literal :db.part/user -2) :spouse "Dana"]]))))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-valueType-keyword
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [tx0 (:tx (<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1)
:db/ident :test/kw
:db/unique :db.unique/identity
:db/valueType :db.type/keyword}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :db.part/user -1)}])))]
(let [tx0 (:tx (<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1)
:db/ident :test/kw
:db/unique :db.unique/identity
:db/valueType :db.type/keyword}
{:db/id :db.part/db :db.install/attribute (d/id-literal :db.part/user -1)}])))]
(let [report (<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user -1) :test/kw :test/kw1]]))
eid (get-in report [:tempids (dm/id-literal :db.part/user -1)])]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [report (<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user -1) :test/kw :test/kw1]]))
eid (get-in report [:tempids (d/id-literal :db.part/user -1)])]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[eid :test/kw ":test/kw1"]})) ;; Value is raw.
(testing "Adding the same value compares existing values correctly."
(<? (dm/<transact! conn [[:db/add eid :test/kw :test/kw1]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(<? (d/<transact! conn [[:db/add eid :test/kw :test/kw1]]))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[eid :test/kw ":test/kw1"]}))) ;; Value is raw.
(testing "Upserting retracts existing value correctly."
(<? (dm/<transact! conn [[:db/add eid :test/kw :test/kw2]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(<? (d/<transact! conn [[:db/add eid :test/kw :test/kw2]]))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[eid :test/kw ":test/kw2"]}))) ;; Value is raw.
(testing "Retracting compares values correctly."
(<? (dm/<transact! conn [[:db/retract eid :test/kw :test/kw2]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(<? (d/<transact! conn [[:db/retract eid :test/kw :test/kw2]]))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{})))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-vector-upsert
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
tempids (fn [tx] (into {} (map (juxt (comp :idx first) second) (:tempids tx))))]
(let [conn (<? (d/<connect t))]
(try
;; Not having DB-as-value really hurts us here. This test only works because all upserts
;; succeed on top of each other, so we never need to reset the underlying store.
(<? (dm/<transact! conn test-schema))
(let [tx0 (:tx (<? (dm/<transact! conn [{:db/id 101 :name "Ivan" :email "@1"}
{:db/id 102 :name "Petr" :email "@2"}])))]
(<? (d/<transact! conn test-schema))
(let [tx0 (:tx (<? (d/<transact! conn [{:db/id 101 :name "Ivan" :email "@1"}
{:db/id 102 :name "Petr" :email "@2"}])))]
(testing "upsert with tempid"
(let [report (<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user -1) :name "Ivan"]
[:db/add (dm/id-literal :db.part/user -1) :age 12]]))]
(is (= (<? (<shallow-entity (dm/db conn) 101))
(let [report (<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user -1) :name "Ivan"]
[:db/add (d/id-literal :db.part/user -1) :age 12]]))]
(is (= (<? (<shallow-entity (d/db conn) 101))
{:name "Ivan" :age 12 :email "@1"}))
(is (= (tempids report)
{-1 101}))))
(testing "upsert with tempid, order does not matter"
(let [report (<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user -1) :age 13]
[:db/add (dm/id-literal :db.part/user -1) :name "Petr"]]))]
(is (= (<? (<shallow-entity (dm/db conn) 102))
(let [report (<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user -1) :age 13]
[:db/add (d/id-literal :db.part/user -1) :name "Petr"]]))]
(is (= (<? (<shallow-entity (d/db conn) 102))
{:name "Petr" :age 13 :email "@2"}))
(is (= (tempids report)
{-1 102}))))
(testing "Conflicting upserts fail"
(is (thrown-with-msg? Throwable #"Conflicting upsert: #datomish.db.TempId\{:part :db.part/user, :idx -\d+\} resolves both to \d+ and \d+"
(<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user -1) :name "Ivan"]
[:db/add (dm/id-literal :db.part/user -1) :age 35]
[:db/add (dm/id-literal :db.part/user -1) :name "Petr"]
[:db/add (dm/id-literal :db.part/user -1) :age 36]]))))))
(is (thrown-with-msg? Throwable #"Conflicting upsert"
(<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user -1) :name "Ivan"]
[:db/add (d/id-literal :db.part/user -1) :age 35]
[:db/add (d/id-literal :db.part/user -1) :name "Petr"]
[:db/add (d/id-literal :db.part/user -1) :age 36]]))))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-map-upsert
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
tempids (fn [tx] (into {} (map (juxt (comp :idx first) second) (:tempids tx))))]
(let [conn (<? (d/<connect t))]
(try
;; Not having DB-as-value really hurts us here. This test only works because all upserts
;; succeed on top of each other, so we never need to reset the underlying store.
(<? (dm/<transact! conn test-schema))
(let [tx0 (:tx (<? (dm/<transact! conn [{:db/id 101 :name "Ivan" :email "@1"}
{:db/id 102 :name "Petr" :email "@2"}])))]
(<? (d/<transact! conn test-schema))
(let [tx0 (:tx (<? (d/<transact! conn [{:db/id 101 :name "Ivan" :email "@1"}
{:db/id 102 :name "Petr" :email "@2"}])))]
(testing "upsert with tempid"
(let [tx (<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :age 35}]))]
(is (= (<? (<shallow-entity (dm/db conn) 101))
(let [tx (<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :age 35}]))]
(is (= (<? (<shallow-entity (d/db conn) 101))
{:name "Ivan" :email "@1" :age 35}))
(is (= (tempids tx)
{-1 101}))))
(testing "upsert by 2 attrs with tempid"
(let [tx (<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :email "@1" :age 35}]))]
(is (= (<? (<shallow-entity (dm/db conn) 101))
(let [tx (<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :email "@1" :age 35}]))]
(is (= (<? (<shallow-entity (d/db conn) 101))
{:name "Ivan" :email "@1" :age 35}))
(is (= (tempids tx)
{-1 101}))))
(testing "upsert with existing id"
(let [tx (<? (dm/<transact! conn [{:db/id 101 :name "Ivan" :age 36}]))]
(is (= (<? (<shallow-entity (dm/db conn) 101))
(let [tx (<? (d/<transact! conn [{:db/id 101 :name "Ivan" :age 36}]))]
(is (= (<? (<shallow-entity (d/db conn) 101))
{:name "Ivan" :email "@1" :age 36}))
(is (= (tempids tx)
{}))))
(testing "upsert by 2 attrs with existing id"
(let [tx (<? (dm/<transact! conn [{:db/id 101 :name "Ivan" :email "@1" :age 37}]))]
(is (= (<? (<shallow-entity (dm/db conn) 101))
(let [tx (<? (d/<transact! conn [{:db/id 101 :name "Ivan" :email "@1" :age 37}]))]
(is (= (<? (<shallow-entity (d/db conn) 101))
{:name "Ivan" :email "@1" :age 37}))
(is (= (tempids tx)
{}))))
(testing "upsert to two entities, resolve to same tempid, fails due to overlapping writes"
(is (thrown-with-msg? Throwable #"cardinality constraint"
(<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :age 35}
{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :age 36}])))))
(<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :age 35}
{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :age 36}])))))
(testing "upsert to two entities, two tempids, fails due to overlapping writes"
(is (thrown-with-msg? Throwable #"cardinality constraint"
(<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :age 35}
{:db/id (dm/id-literal :db.part/user -2) :name "Ivan" :age 36}]))))))
(<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :age 35}
{:db/id (d/id-literal :db.part/user -2) :name "Ivan" :age 36}]))))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-map-upsert-conflicts
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
tempids (fn [tx] (into {} (map (juxt (comp :idx first) second) (:tempids tx))))]
(let [conn (<? (d/<connect t))]
(try
;; Not having DB-as-value really hurts us here. This test only works because all upserts
;; fail until the final one, so we never need to reset the underlying store.
(<? (dm/<transact! conn test-schema))
(let [tx0 (:tx (<? (dm/<transact! conn [{:db/id 101 :name "Ivan" :email "@1"}
{:db/id 102 :name "Petr" :email "@2"}])))]
(<? (d/<transact! conn test-schema))
(let [tx0 (:tx (<? (d/<transact! conn [{:db/id 101 :name "Ivan" :email "@1"}
{:db/id 102 :name "Petr" :email "@2"}])))]
;; TODO: improve error message to refer to upsert inputs.
(testing "upsert conficts with existing id"
(is (thrown-with-msg? Throwable #"unique constraint"
(<? (dm/<transact! conn [{:db/id 102 :name "Ivan" :age 36}])))))
(<? (d/<transact! conn [{:db/id 102 :name "Ivan" :age 36}])))))
;; TODO: improve error message to refer to upsert inputs.
(testing "upsert conficts with non-existing id"
(is (thrown-with-msg? Throwable #"unique constraint"
(<? (dm/<transact! conn [{:db/id 103 :name "Ivan" :age 36}])))))
(<? (d/<transact! conn [{:db/id 103 :name "Ivan" :age 36}])))))
;; TODO: improve error message to refer to upsert inputs.
(testing "upsert by 2 conflicting fields"
(is (thrown-with-msg? Throwable #"Conflicting upsert: #datomish.db.TempId\{:part :db.part/user, :idx -\d+\} resolves both to \d+ and \d+"
(<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :email "@2" :age 35}])))))
(is (thrown-with-msg? Throwable #"Conflicting upsert"
(<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :email "@2" :age 35}])))))
(testing "upsert by non-existing value resolves as update"
(let [report (<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user -1) :name "Ivan" :email "@3" :age 35}]))]
(is (= (<? (<shallow-entity (dm/db conn) 101))
(let [report (<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user -1) :name "Ivan" :email "@3" :age 35}]))]
(is (= (<? (<shallow-entity (d/db conn) 101))
{:name "Ivan" :email "@3" :age 35}))
(is (= (tempids report)
{-1 101})))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-add-ident
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [report (<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/db -1) :db/ident :test/ident]]))
(let [report (<? (d/<transact! conn [[:db/add (d/id-literal :db.part/db -1) :db/ident :test/ident]]))
db-after (:db-after report)
tx (:tx db-after)]
(is (= (:test/ident (dm/idents db-after)) (get-in report [:tempids (dm/id-literal :db.part/db -1)]))))
(is (= (:test/ident (db/idents db-after)) (get-in report [:tempids (d/id-literal :db.part/db -1)]))))
;; TODO: This should fail, but doesn't, due to stringification of :test/ident.
;; (is (thrown-with-msg?
;; ExceptionInfo #"Retracting a :db/ident is not yet supported, got "
;; (<? (dm/<transact! conn [[:db/retract 44 :db/ident :test/ident]]))))
;; (<? (d/<transact! conn [[:db/retract 44 :db/ident :test/ident]]))))
;; ;; Renaming looks like retraction and then assertion.
;; (is (thrown-with-msg?
;; ExceptionInfo #"Retracting a :db/ident is not yet supported, got"
;; (<? (dm/<transact! conn [[:db/add 44 :db/ident :other-name]]))))
;; (<? (d/<transact! conn [[:db/add 44 :db/ident :other-name]]))))
;; (is (thrown-with-msg?
;; ExceptionInfo #"Re-asserting a :db/ident is not yet supported, got"
;; (<? (dm/<transact! conn [[:db/add 55 :db/ident :test/ident]]))))
;; (<? (d/<transact! conn [[:db/add 55 :db/ident :test/ident]]))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-add-schema
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)]
(let [conn (<? (d/<connect t))]
(try
(let [es [[:db/add :db.part/db :db.install/attribute (dm/id-literal :db.part/db -1)]
{:db/id (dm/id-literal :db.part/db -1)
(let [es [[:db/add :db.part/db :db.install/attribute (d/id-literal :db.part/db -1)]
{:db/id (d/id-literal :db.part/db -1)
:db/ident :test/attr
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}]
report (<? (dm/<transact! conn es))
report (<? (d/<transact! conn es))
db-after (:db-after report)
tx (:tx db-after)]
@ -450,155 +425,147 @@
:db/cardinality :db.cardinality/one})))
(testing "Schema is used in subsequent transaction"
(<? (dm/<transact! conn [{:db/id 100 :test/attr "value 1"}]))
(<? (dm/<transact! conn [{:db/id 100 :test/attr "value 2"}]))
(is (= (<? (<shallow-entity (dm/db conn) 100))
(<? (d/<transact! conn [{:db/id 100 :test/attr "value 1"}]))
(<? (d/<transact! conn [{:db/id 100 :test/attr "value 2"}]))
(is (= (<? (<shallow-entity (d/db conn) 100))
{:test/attr "value 2"}))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-fulltext
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
schema [{:db/id (dm/id-literal :db.part/db -1)
(let [conn (<? (d/<connect t))
schema [{:db/id (d/id-literal :db.part/db -1)
:db/ident :test/fulltext
:db/valueType :db.type/string
:db/fulltext true
:db/unique :db.unique/identity}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :db.part/db -1)}
{:db/id (dm/id-literal :db.part/db -2)
{:db/id :db.part/db :db.install/attribute (d/id-literal :db.part/db -1)}
{:db/id (d/id-literal :db.part/db -2)
:db/ident :test/other
:db/valueType :db.type/string
:db/fulltext true
:db/cardinality :db.cardinality/one}
{:db/id :db.part/db :db.install/attribute (dm/id-literal :db.part/db -2)}
{:db/id :db.part/db :db.install/attribute (d/id-literal :db.part/db -2)}
]
tx0 (:tx (<? (dm/<transact! conn schema)))]
tx0 (:tx (<? (d/<transact! conn schema)))]
(try
(testing "Can add fulltext indexed datoms"
(let [r (<? (dm/<transact! conn [[:db/add 101 :test/fulltext "test this"]]))]
(is (= (<? (<fulltext-values (dm/db conn)))
(let [r (<? (d/<transact! conn [[:db/add 101 :test/fulltext "test this"]]))]
(is (= (<? (<fulltext-values (d/db conn)))
[[1 "test this"]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :test/fulltext 1]})) ;; Values are raw; 1 is the rowid into fulltext_values.
))
(testing "Can replace fulltext indexed datoms"
(let [r (<? (dm/<transact! conn [[:db/add 101 :test/fulltext "alternate thing"]]))]
(is (= (<? (<fulltext-values (dm/db conn)))
(let [r (<? (d/<transact! conn [[:db/add 101 :test/fulltext "alternate thing"]]))]
(is (= (<? (<fulltext-values (d/db conn)))
[[1 "test this"]
[2 "alternate thing"]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :test/fulltext 2]})) ;; Values are raw; 2 is the rowid into fulltext_values.
))
(testing "Can upsert keyed by fulltext indexed datoms"
(let [r (<? (dm/<transact! conn [{:db/id (dm/id-literal :db.part/user) :test/fulltext "alternate thing" :test/other "other"}]))]
(is (= (<? (<fulltext-values (dm/db conn)))
(let [r (<? (d/<transact! conn [{:db/id (d/id-literal :db.part/user) :test/fulltext "alternate thing" :test/other "other"}]))]
(is (= (<? (<fulltext-values (d/db conn)))
[[1 "test this"]
[2 "alternate thing"]
[3 "other"]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :test/fulltext 2] ;; Values are raw; 2, 3 are the rowids into fulltext_values.
[101 :test/other 3]}))
))
(testing "Can re-use fulltext indexed datoms"
(let [r (<? (dm/<transact! conn [[:db/add 102 :test/other "test this"]]))]
(is (= (<? (<fulltext-values (dm/db conn)))
(let [r (<? (d/<transact! conn [[:db/add 102 :test/other "test this"]]))]
(is (= (<? (<fulltext-values (d/db conn)))
[[1 "test this"]
[2 "alternate thing"]
[3 "other"]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :test/fulltext 2]
[101 :test/other 3]
[102 :test/other 1]})) ;; Values are raw; 1, 2, 3 are the rowids into fulltext_values.
))
(testing "Can retract fulltext indexed datoms"
(let [r (<? (dm/<transact! conn [[:db/retract 101 :test/fulltext "alternate thing"]]))]
(is (= (<? (<fulltext-values (dm/db conn)))
(let [r (<? (d/<transact! conn [[:db/retract 101 :test/fulltext "alternate thing"]]))]
(is (= (<? (<fulltext-values (d/db conn)))
[[1 "test this"]
[2 "alternate thing"]
[3 "other"]]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :test/other 3]
[102 :test/other 1]})) ;; Values are raw; 1, 3 are the rowids into fulltext_values.
))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-txInstant
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
{tx0 :tx} (<? (dm/<transact! conn test-schema))]
(let [conn (<? (d/<connect t))
{tx0 :tx} (<? (d/<transact! conn test-schema))]
(try
(let [{txa :tx txInstantA :txInstant} (<? (dm/<transact! conn []))]
(let [{txa :tx txInstantA :txInstant} (<? (d/<transact! conn []))]
(testing ":db/txInstant is set by default"
(is (= (<? (<transactions-after (dm/db conn) tx0))
(is (= (<? (<transactions-after (d/db conn) tx0))
[[txa :db/txInstant txInstantA txa 1]])))
;; TODO: range check txInstant values against DB clock.
(testing ":db/txInstant can be set explicitly"
(let [{txb :tx txInstantB :txInstant} (<? (dm/<transact! conn [[:db/add :db/tx :db/txInstant (+ txInstantA 1)]]))]
(let [{txb :tx txInstantB :txInstant} (<? (d/<transact! conn [[:db/add :db/tx :db/txInstant (+ txInstantA 1)]]))]
(is (= txInstantB (+ txInstantA 1)))
(is (= (<? (<transactions-after (dm/db conn) txa))
(is (= (<? (<transactions-after (d/db conn) txa))
[[txb :db/txInstant txInstantB txb 1]]))
(testing ":db/txInstant can be set explicitly, with additional datoms"
(let [{txc :tx txInstantC :txInstant} (<? (dm/<transact! conn [[:db/add :db/tx :db/txInstant (+ txInstantB 2)]
[:db/add :db/tx :x 123]]))]
(let [{txc :tx txInstantC :txInstant} (<? (d/<transact! conn [[:db/add :db/tx :db/txInstant (+ txInstantB 2)]
[:db/add :db/tx :x 123]]))]
(is (= txInstantC (+ txInstantB 2)))
(is (= (<? (<transactions-after (dm/db conn) txb))
(is (= (<? (<transactions-after (d/db conn) txb))
[[txc :db/txInstant txInstantC txc 1]
[txc :x 123 txc 1]]))
(testing "additional datoms can be added, without :db/txInstant explicitly"
(let [{txd :tx txInstantD :txInstant} (<? (dm/<transact! conn [[:db/add :db/tx :x 456]]))]
(is (= (<? (<transactions-after (dm/db conn) txc))
(let [{txd :tx txInstantD :txInstant} (<? (d/<transact! conn [[:db/add :db/tx :x 456]]))]
(is (= (<? (<transactions-after (d/db conn) txc))
[[txd :db/txInstant txInstantD txd 1]
[txd :x 456 txd 1]])))))))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-no-tx
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
{tx0 :tx} (<? (dm/<transact! conn test-schema))]
(let [conn (<? (d/<connect t))
{tx0 :tx} (<? (d/<transact! conn test-schema))]
(try
(testing "Cannot specificy an explicit tx"
(is (thrown-with-msg?
ExceptionInfo #"Bad entity: too long"
(<? (dm/<transact! conn [[:db/add (dm/id-literal :db.part/user) :x 0 10101]])))))
(<? (d/<transact! conn [[:db/add (d/id-literal :db.part/user) :x 0 10101]])))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-explode-sequences
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
{tx0 :tx} (<? (dm/<transact! conn test-schema))]
(let [conn (<? (d/<connect t))
{tx0 :tx} (<? (d/<transact! conn test-schema))]
(try
(testing ":db.cardinality/many sequences are accepted"
(<? (dm/<transact! conn [{:db/id 101 :aka ["first" "second"]}]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(<? (d/<transact! conn [{:db/id 101 :aka ["first" "second"]}]))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :aka "first"]
[101 :aka "second"]})))
(testing ":db.cardinality/many sequences are recursively applied, allowing unexpected sequence nesting"
(<? (dm/<transact! conn [{:db/id 102 :aka [[["first"]] ["second"]]}]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(<? (d/<transact! conn [{:db/id 102 :aka [[["first"]] ["second"]]}]))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :aka "first"]
[101 :aka "second"]
[102 :aka "first"]
@ -607,32 +574,30 @@
(testing ":db.cardinality/one sequences fail"
(is (thrown-with-msg?
ExceptionInfo #"Sequential values"
(<? (dm/<transact! conn [{:db/id 101 :email ["@1" "@2"]}])))))
(<? (d/<transact! conn [{:db/id 101 :email ["@1" "@2"]}])))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-explode-maps
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
{tx0 :tx} (<? (dm/<transact! conn test-schema))]
(let [conn (<? (d/<connect t))
{tx0 :tx} (<? (d/<transact! conn test-schema))]
(try
(testing "nested maps are accepted"
(<? (dm/<transact! conn [{:db/id 101 :friends {:name "Petr"}}]))
(<? (d/<transact! conn [{:db/id 101 :friends {:name "Petr"}}]))
;; TODO: this works only because we have a single friend.
(let [{petr :friends} (<? (<shallow-entity (dm/db conn) 101))]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [{petr :friends} (<? (<shallow-entity (d/db conn) 101))]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :friends petr]
[petr :name "Petr"]}))))
(testing "recursively nested maps are accepted"
(<? (dm/<transact! conn [{:db/id 102 :friends {:name "Ivan" :friends {:name "Petr"}}}]))
(<? (d/<transact! conn [{:db/id 102 :friends {:name "Ivan" :friends {:name "Petr"}}}]))
;; This would be much easier with `entity` and lookup refs.
(let [{ivan :friends} (<? (<shallow-entity (dm/db conn) 102))
{petr :friends} (<? (<shallow-entity (dm/db conn) ivan))]
(is (= (<? (<datoms-after (dm/db conn) tx0))
(let [{ivan :friends} (<? (<shallow-entity (d/db conn) 102))
{petr :friends} (<? (<shallow-entity (d/db conn) ivan))]
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :friends petr]
[petr :name "Petr"]
[102 :friends ivan]
@ -642,22 +607,20 @@
(testing "nested maps without :db.type/ref fail"
(is (thrown-with-msg?
ExceptionInfo #"\{:db/valueType :db.type/ref\}"
(<? (dm/<transact! conn [{:db/id 101 :aka {:name "Petr"}}])))))
(<? (d/<transact! conn [{:db/id 101 :aka {:name "Petr"}}])))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))
(deftest-async test-explode-reverse-refs
(with-tempfile [t (tempfile)]
(let [c (<? (s/<sqlite-connection t))
db (<? (dm/<db-with-sqlite-connection c))
conn (dm/connection-with-db db)
{tx0 :tx} (<? (dm/<transact! conn test-schema))]
(let [conn (<? (d/<connect t))
{tx0 :tx} (<? (d/<transact! conn test-schema))]
(try
(testing "reverse refs are accepted"
(<? (dm/<transact! conn [{:db/id 101 :name "Igor"}]))
(<? (dm/<transact! conn [{:db/id 102 :name "Oleg" :_friends 101}]))
(is (= (<? (<datoms-after (dm/db conn) tx0))
(<? (d/<transact! conn [{:db/id 101 :name "Igor"}]))
(<? (d/<transact! conn [{:db/id 102 :name "Oleg" :_friends 101}]))
(is (= (<? (<datoms-after (d/db conn) tx0))
#{[101 :name "Igor"]
[102 :name "Oleg"]
[101 :friends 102]})))
@ -665,7 +628,7 @@
(testing "reverse refs without :db.type/ref fail"
(is (thrown-with-msg?
ExceptionInfo #"\{:db/valueType :db.type/ref\}"
(<? (dm/<transact! conn [{:db/id 101 :_aka 102}])))))
(<? (d/<transact! conn [{:db/id 101 :_aka 102}])))))
(finally
(<? (dm/close-db db)))))))
(<? (d/<close conn)))))))