[250426] datascript 코드 읽기
Table of Contents
Datascript는 Clojure 및 ClojureScript를 위해 설계된 불변성(immutable) 인메모리(in-memory) 데이터베이스 및 Datalog 쿼리 엔진이며, JavaScript 환경에서도 사용할 수 있습니다 주로 브라우저 환경 내에서 실행되도록 고안되었습니다. Datascript 데이터베이스는 생성 비용이 저렴하고, 쿼리가 빠르며, 일시적인(ephemeral) 특성을 가진다. 일반적으로 웹 페이지 로드 시 생성되어 데이터를 저장하고 변경 사항을 추적하며 쿼리를 수행한 후, 사용자가 페이지를 닫으면 소멸됩니다.
데이터베이스 자체는 불변성을 가지며 영속적 자료 구조(persistent data structure)에 기반합니다. 이는 데이터베이스에 변경이 가해질 때 기존 버전은 그대로 유지되면서 새로운 버전의 데이터베이스가 생성됨을 의미합니다. 이러한 불변성은 상태 관리의 복잡성을 줄이고, 애플리케이션 상태 변화 추적을 용이하게 하며, 복잡한 잠금 메커니즘 없이 실행 취소/다시 실행과 같은 기능을 지원합니다.
1 Datascript 주요 특징
- 불변성 : 데이터베이스에 변경이 가해지면 (트랜잭션을 통해), 기존 데이터베이스는 변경되지 않고 새로운 상태를 나타내는 새 데이터베이스 값이 생성 이는 Clojure의 영속적 자료 구조와 유사한 방식으로 작동하며 1, 상태 변화 추적, 시간 여행(time travel) 디버깅, 실행 취소/다시 실행 기능 구현을 단순화합니다.1 또한, 동시성 환경이나 백그라운드 동기화 작업에서 잠금(locking) 없이 일관된 상태를 유지하는 데 유리.
- Datalog 쿼리 엔진 : Datascript는 내장된 Datalog 쿼리 엔진을 제공합니다.1 Datalog는 선언적 로직 프로그래밍 언어로, 복잡한 데이터 관계나 조건을 명시하여 현재 애플리케이션 상태에 대한 질문을 효과적으로 수행할 수 있게 합니다.
- 다중 언어 지원 (Clojure/ClojureScript/JavaScript) : Datascript는 주로 Clojure와 ClojureScript 환경을 위해 개발되었지만, 순수 JavaScript 환경에서도 추가 의존성 없이 사용할 수 있습니다.
- 경량성 및 의존성 없음: Datascript는 외부 라이브러리에 대한 의존성이 없어 가볍습니다. 리소스가 제한적인 브라우저에서 장점이 될 것.
- 단순화된 스키마: Datascript의 스키마는 Datomic에 비해 단순하며 쿼리할 수 없습니다.1 속성은 미리 선언할 필요가 없으며, 특별한 동작(예: 카디널리티, 참조 타입)이 필요할 때만 스키마에 정의. 개발 초기 단계나 스키마가 유동적인 상황에서 유연성을 제공함.
2 Datascript 내부 구조
- 데이터 모델 Datom : Datascript의 기본 정보 단위.
- 엔티티 ID (E), 속성 (A), 값 (V)의 세 가지 요소로 구성된 사실(fact)을 나타내니다.
- 내부적으로 Datom 레코드는 [e a v tx added] 형태를 가지며, tx는 트랜잭션 ID (정수), added는 추가(true)/철회(false)를 나타내는 불리언 플래그입니다.
- 하지만 해시 및 동등성 비교 시에는 [e a v]만 사용되어 각 고유한 사실이 데이터베이스에 한 번만 추가되도록 보장합니다.
- 는 Datomic의 5-튜플 Datom **([entity, attribute, value, transaction, asserted-or-retracted?])**과 유사하지만, Datascript는 기본적으로 트랜잭션 시간(tx)이나 added 플래그를 적극적으로 활용하지 않으며, 특히 히스토리 추적을 내장 기능으로 제공하지 않습니다.
- 내부 데이터 구조: 인덱스: Datascript 데이터베이스(DB)는 내부적으로 Datom들의 불변 컬렉션이며, 효율적인 검색을 위해 세 가지 인덱스를 유지합니다.1 이 인덱스들은 정렬된 Datom 세트이며, 정렬 순서에 따라 이름 붙여졌습니다:
- EAVT: 엔티티(E) - 속성(A) - 값(V) - 트랜잭션(T) 순서로 정렬됩니다. 특정 엔티티에 대한 모든 속성과 값을 효율적으로 조회하는 데 사용됩니다.
- AEVT: 속성(A) - 엔티티(E) - 값(V) - 트랜잭션(T) 순서로 정렬됩니다. 특정 속성을 가진 모든 엔티티나 값을 찾는 데 유용합니다.
- AVET: 속성(A) - 값(V) - 엔티티(E) - 트랜잭션(T) 순서로 정렬됩니다. 특정 속성과 값을 기준으로 엔티티를 찾는 데 사용됩니다. 모든 인덱스는 현재 데이터베이스의 모든 Datom을 포함하며, 이 세 인덱스가 DB 내 Datom의 유일한 저장소입니다.
- BTSet
- 각 인덱스는 **datascript.btset**으로 구현된 불변의 영속적인 B+ 트리 자료 구조를 사용합니다.
- B+ 트리는 특히 범위 스캔(range scan) 성능이 뛰어나기 때문에 Datascript에서 빈번하게 사용되는 인덱스 검색 작업에 최적화되어 있습니다.
- 이는 내장된 sorted-set(Red-Black 트리 기반)보다 약 3배 빠른 범위 스캔 성능을 제공한다고 알려져 있습니다
- 트랜잭션 처리 및 TxReport
- 데이터 추가는 datascript.core/transact! 함수를 통해 이루어집니다.
- 트랜잭션 데이터는 다양한 형식(벡터, 맵)으로 제공될 수 있으며,
- 내부적으로 transact-tx-data 함수를 통해 임시 ID 해결, 약칭 함수 호출, 중첩 맵 처리 등을 거쳐 최종적으로 Datom 세트로 변환됩니다.
- 이 과정에서 TxReport 레코드가 생성되며, 이는 트랜잭션 전후의 데이터베이스 상태(db-before, db-after), 처리된 Datom(tx-data), 임시 ID 매핑(tempids), 메타데이터(tx-meta)를 포함합니다.
- tx-data는 실제 DB 변경에 사용된 Datom 목록이며, 철회된 Datom(added == false)은 TxReport에서만 확인할 수 있습니다.
3 내부 코드 읽기
3.1 Datom 레코드
(defrecord Datom [e a v tx added])
각 필드의 의미는 다음과 같습니다:
- e (Entity ID): 이 사실이 어떤 엔티티에 관한 것인지를 나타내는 식별자 (보통 Long 타입).
- a (Attribute): 엔티티의 어떤 속성에 관한 것인지를 나타내는 식별자 (보통 Keyword 타입).
- v (Value): 해당 속성의 값 (Object 타입, 어떤 값이든 가능).
- tx (Transaction ID): 이 사실이 데이터베이스에 추가된 트랜잭션의 식별자 (Long 타입). Datomic/Datascript에서는 시간(time) 또는 트랜잭션 ID를 나타냅니다.
- added (Added Flag): 이 데이터 원자가 추가(assertion, true)인지 철회(retraction, false)인지를 나타내는 불리언 플래그.
핵심은 [e a v] 세 부분으로 구성된 EAV 삼중항(triplet)이며, tx와 added는 이 사실에 대한 메타데이터를 제공합니다.
3.2 Comparator
Datom 비교는 인덱스 검색 시 중요함. 인덱스 내 정렬, 중복 제거가 이 비교 로직에 의존함.
특히 동일한 [e a v] 를 가진 Datom은 특정 트랜잭션 컨텍스트 내에서 유일해야함. 따라서 데이터 원자의 해시코드 계산과 동등성 비교는 주로 [e a v] 필드를 기준으로 이루어져야 함. defrecord의 기본 동작을 오버라이드하거나 별도의 비교자(comparator) 함수를 통해 구현할 수 있음. 이 구현에서는 **sorted-set-by**에 전달될 comparator 함수에서 이 로직을 처리할 것임.
그래서 인덱스 별로 Comparator 함수를 정의함.
;; EAV 순서로 비교하는 비교자 함수 (tx는 내림차순)
(defn datom-eav-comparator [^Datom d1 ^Datom d2]
(let [e-comp (compare (.e d1) (.e d2))]
(if (zero? e-comp)
(let [a-comp (compare (.a d1) (.a d2))]
(if (zero? a-comp)
(let [v-comp (compare (.v d1) (.v d2))]
(if (zero? v-comp)
;; E, A, V가 모두 같으면 tx를 내림차순으로 비교하여 최신 datom이 앞에 오도록 함
(compare (.tx d2) (.tx d1)) ; Note: tx descending
v-comp))
a-comp))
e-comp)))
; AEV 순서로 비교하는 비교자 함수 (tx는 내림차순)
(defn datom-aev-comparator [^Datom d1 ^Datom d2]
(let [a-comp (compare (.a d1) (.a d2))]
(if (zero? a-comp)
(let [e-comp (compare (.e d1) (.e d2))]
(if (zero? e-comp)
(let [v-comp (compare (.v d1) (.v d2))]
(if (zero? v-comp)
(compare (.tx d2) (.tx d1)) ; Note: tx descending
v-comp))
e-comp))
a-comp)))
; AVE 순서로 비교하는 비교자 함수 (tx는 내림차순)
(defn datom-ave-comparator [^Datom d1 ^Datom d2]
(let [a-comp (compare (.a d1) (.a d2))]
(if (zero? a-comp)
(let [v-comp (compare (.v d1) (.v d2))]
(if (zero? v-comp)
(let [e-comp (compare (.e d1) (.e d2))]
(if (zero? e-comp)
(compare (.tx d2) (.tx d1)) ; Note: tx descending
e-comp))
v-comp))
a-comp)))
; Datom 레코드 정의 시 비교 로직 포함 (선택적)
; Clojure 1.11+ 에서는 defrecord가 Comparable을 구현하므로,
; 별도 비교자만으로도 충분할 수 있음.
; 필요 시 -compare 메서드를 구현하여 eav 기준 비교 로직 추가 가능.
(defrecord Datom [e a v tx added]
Comparable ; Clojure 1.11+
(compareTo [this other]
(datom-eav-comparator this other))) ; 기본 비교 순서를 EAVT로 설정
이 비교자 함수들은 각 인덱스에 대한 정렬 순서를 정의하며, 특정 인덱스에 대한 비교 작업을 수행함.
이 인덱스는 이제 database 를 생성할 때, 3개의 인덱스를 모두 한번에 생성할 것임.
3.3 added 플래그
added 플래그는 데이터 원자가 사실의 추가(true)인지, 아니면 이전에 추가된 사실의 철회(false)인지를 구분함. 현재 데이터베이스 상태를 나타내는 주 인덱스들(EAVT, AEVT, AVET)은 일반적으로 현재 유효한 사실(fact), 즉 added = true인 데이터 원자들만 저장함. 하지만 트랜잭션 처리 결과(TxReport)에는 해당 트랜잭션에서 발생한 모든 데이터 원자, 즉 추가된 것과 철회된 것 모두가 포함됨. transact 함수는 트랜잭션 데이터의 :db/add 또는 :db/retract 연산에 따라 올바른 added 플래그를 가진 데이터 원자를 생성해야 함.
3.4 Datom 과 보편적 관계(Universal Relation)
데이터 원자 모델의 중요한 함의는 모든 데이터 원자의 집합이 단일한 **보편적 관계(universal relation)**를 형성한다는 점.
이는 고정된 스키마를 가진 테이블 기반의 SQL 데이터베이스 모델과 근본적으로 다름.
EAV 모델에서는 엔티티가 미리 정의된 컬럼 집합에 얽매이지 않고 임의의 속성을 가질 수 있음.
예를 들어, 어떤 '사용자' 엔티티는 :이름과 :이메일 속성을 가질 수 있고, 다른 '사용자' 엔티티는 *:이름**과 *:주소** 속성만 가질 수 있음. 이러한 유연성은 데이터 모델이 진화하거나 희소한(sparse) 데이터를 다룰 때 큰 장점이 됨. 이 보고서에서 구현하는 Datom 레코드는 이러한 유연한 모델을 실현하는 첫걸음임.
살짝 그래프 데이터베이스 모델 같고, 데이터 원자가 노드(node) 또는 엣지(edge) 역할을 하는 것 같음. 또한 Triple 데이터베이스 모델과 유사함. 더 나아가면 RDF 데이터베이스 모델과 유사함.
4 인덱스 생성 : 데이터베이스 (DB) 레코드: 인덱스와 스키마
데이터베이스(DB)는 특정 시점의 데이터베이스 상태를 나타내는 불변의 값임. 여기에는 데이터 원자들을 효율적으로 조회하기 위한 인덱스들과 스키마 정보가 포함됨. defrecord를 사용하여 DB 레코드를 정의함.
(defrecord DB [schema eavt aevt avet max-tx max-eid])
- schema: 속성 정의를 담는 맵 (예: {:likes {:db/cardinality :db.cardinality/many}}).
- eavt: EAVT 순서로 정렬된 데이터 원자들의 sorted-set.
- aevt: AEVT 순서로 정렬된 데이터 원자들의 sorted-set.
- avet: AVET 순서로 정렬된 데이터 원자들의 sorted-set.
- max-tx: 데이터베이스의 가장 최근 트랜잭션 ID (Long).
- max-eid: 데이터베이스에서 사용된 가장 큰 엔티티 ID (Long). tempid 할당에 사용됨.
4.1 인덱스 구현
- Datascript는 인덱스를 위해 자체 구현한 B+ 트리인 BTSet을 사용하며, Datahike는 hitchhiker-tree를 사용함.
- 이들은 특히 범위 검색(range scan) 성능이 우수함.
- 이 구현에서는 핵심 개념 시연을 위해 Clojure의 표준 영속 정렬 집합인 clojure.core/sorted-set-by를 사용함.
- 각 인덱스(:eavt, :aevt, :avet)는 해당 정렬 순서에 맞는 비교자 함수(datom-eav-comparator, datom-aev-comparator, datom-ave-comparator)를 사용하여 생성됨.
(defn empty-db
( (empty-db {}))
([schema]
(map->DB {:schema schema
:eavt (sorted-set-by datom-eav-comparator)
:aevt (sorted-set-by datom-aev-comparator)
:avet (sorted-set-by datom-ave-comparator)
:max-tx 0
:max-eid 0})))
앞서 정의한 비교자 함수들은 tx 필드를 내림차순으로 비교함. 이는 특정 [e a v]에 대한 가장 최신 사실(가장 큰 tx 값을 가진 데이터 원자)을 효율적으로 찾기 위함임.
실제 Datomic이나 Datascript에서는 AVET 인덱스가 :db/index true 또는 **:db/unique true**로 지정된 속성에 대해서만 데이터 원자(Datom)를 저장하는 경우가 많음. 이는 AVET 인덱스 유지 비용이 상대적으로 높기 때문임. 하지만 이 구현에서는 단순화를 위해 모든 추가된 데이터 원자를 세 인덱스 모두에 저장함 (Datascript의 EAVT/AEVT 인덱스처럼).
4.2 스키마
:schema 필드는 속성의 메타데이터를 저장하는 맵임. 예를 들어,
- 어떤 속성이 다중 값(many-valued)을 가질 수 있는지(:db/cardinality :db.cardinality/many),
- 참조 타입인지(:db/valueType :db.type/ref) 등의 정보를 담음
이 구현에서는 transact 함수가 카디널리티(cardinality)를 처리하는 데 주로 사용됨. Datascript의 스키마는 비교적 단순하며, Datomic과 달리 스키마 자체를 쿼리할 수는 없음.
4.3 불변성
DB 레코드 자체는 불변(immutable)함. 어떤 트랜잭션이 발생하면, 그 결과로 기존 DB 객체가 변경되는 것이 아니라, 업데이트된 인덱스와 새로운 max-tx 값을 가진 새로운 DB 인스턴스가 생성됨.
4.4 커버링 인덱스 (Covering Indexes)
Datascript와 Datomic의 인덱스는 **커버링 인덱스(covering index)**임. 인덱스 구조 자체가 실제 데이터 원자(datom) 정보를 포함하고 있으며, 데이터가 저장된 다른 위치를 가리키는 포인터만 가지고 있는 것이 아니라는 의미임. 따라서 데이터 읽기 작업은 종종 인덱스 구조에서 직접 발생함. 이 구현에서도 DB 레코드는 인덱스들(과 스키마/메타데이터)만을 포함하며, 쿼리(datoms 함수)는 이 인덱스 집합들로부터 직접 데이터를 읽어옴. 이는 전통적인 데이터베이스에서 인덱스가 디스크 상의 로우(row) 위치를 가리키는 방식과는 다른 중요한 아키텍처적 특징임.
5 트랜잭션 처리 (transact)
5.1 함수 시그니처 및 순수성
트랜잭션 처리는 데이터베이스에 데이터를 추가하거나 철회하는 작업임.
WIP
5.2 코드정리
(ns simple-datascript.core
(:require [clojure.set :as set])
(:import [clojure.lang PersistentQueue]))
;; --- Data Structures ---
;; Datom: The basic unit of information [e a v tx added]
;; Implements Comparable for default EAVT sorting (tx descending for latest)
(defrecord Datom [e a v ^long tx ^boolean added]
Comparable
(compareTo [this other]
(let [^Datom o other
e-cmp (compare (.e this) (.e o))]
(if (not= 0 e-cmp) e-cmp
(let [a-cmp (compare (.a this) (.a o))]
(if (not= 0 a-cmp) a-cmp
(let [v-cmp (compare (.v this) (.v o))]
(if (not= 0 v-cmp) v-cmp
;; Tx descending: latest tx comes first for same EAV
(compare (.tx o) (.tx this)))))))))))
;; Comparators for other index orders
(defn datom-aevt-comparator [^Datom d1 ^Datom d2]
(let [a-cmp (compare (.a d1) (.a d2))]
(if (not= 0 a-cmp) a-cmp
(let [e-cmp (compare (.e d1) (.e o))]
(if (not= 0 e-cmp) e-cmp
(let [v-cmp (compare (.v d1) (.v o))]
(if (not= 0 v-cmp) v-cmp
(compare (.tx o) (.tx this)))))))))
(defn datom-avet-comparator [^Datom d1 ^Datom d2]
(let [a-cmp (compare (.a d1) (.a d2))]
(if (not= 0 a-cmp) a-cmp
(let [v-cmp (compare (.v d1) (.v o))]
(if (not= 0 v-cmp) v-cmp
(let [e-cmp (compare (.e d1) (.e o))]
(if (not= 0 e-cmp) e-cmp
(compare (.tx o) (.tx this)))))))))
;; Database: Immutable snapshot containing schema and indexes
(defrecord DB [schema eavt aevt avet ^long max-tx ^long max-eid]
;; Implement ILookup for easy access to indexes? (optional)
)
;; Transaction Report: Result of a transaction
(defrecord TxReport [db-before db-after tx-data tempids])
;; --- Core Functions ---
(defn empty-db
"Creates an empty database value, optionally with a schema."
( (empty-db {}))
([schema]
(map->DB {:schema schema
:eavt (sorted-set-by datom-eav-comparator)
:aevt (sorted-set-by datom-aev-comparator)
:avet (sorted-set-by datom-ave-comparator)
:max-tx 0
:max-eid 0})))
(defn- next-eid [max-eid-atom]
(swap! max-eid-atom inc))
(defn- resolve-tempid [tempids-atom max-eid-atom eid]
"Resolves a tempid (negative integer) to a new entity ID."
(if (and (integer? eid) (neg? eid))
(or (get @tempids-atom eid)
(let [new-eid (next-eid max-eid-atom)]
(swap! tempids-atom assoc eid new-eid)
new-eid))
eid))
(defn- datoms-internal
"Internal helper for datoms lookup using subseq."
(let [index-set (get db index)
comparator (condp = index
:eavt datom-eav-comparator
:aevt datom-aevt-comparator
:avet datom-avet-comparator)]
(if (empty? components)
(seq index-set)
(let [;; Create lower bound datom for subseq
;; Use a special marker or nil for unspecified components
;; Comparator needs to handle these markers correctly (e.g., nil is smallest)
start-key-vec (vec (concat components (repeat (- 3 (count components)) nil)))
start-key (case index
:eavt (->Datom (nth start-key-vec 0) (nth start-key-vec 1) (nth start-key-vec 2) Long/MAX_VALUE true)
:aevt (->Datom nil (nth start-key-vec 0) (nth start-key-vec 1) Long/MAX_VALUE true) ; e, v are effectively nil here
:avet (->Datom nil (nth start-key-vec 0) (nth start-key-vec 1) Long/MAX_VALUE true)) ; e is nil
;; Get subseq starting from the lower bound
sub (subseq index-set >= start-key)]
;; Filter the subseq to keep only exact matches for the provided components
(lazy-seq
(when-let [s (seq sub)]
(let [^Datom first-datom (first s)]
(if (every? true? (map-indexed
(fn [idx comp]
(or (nil? comp) ;; Treat nil component as wildcard
(= comp (case index
:eavt (case idx 0 (.e first-datom) 1 (.a first-datom) 2 (.v first-datom))
:aevt (case idx 0 (.a first-datom) 1 (.e first-datom) 2 (.v first-datom))
:avet (case idx 0 (.a first-datom) 1 (.v first-datom) 2 (.e first-datom))))))
components))
(cons first-datom (datoms-internal db index components (rest s))) ; Recursive call on rest needed if subseq isn't perfect
nil)))))))) ; Mismatch, end sequence for this prefix
(defn datoms
"Index lookup. Returns a lazy sequence of datoms matching components.
Components are applied according to the index:
:eavt [e a v]
:aevt [a e v]
:avet [a v e]"
([db index] (datoms-internal db index))
([db index c1] (datoms-internal db index c1))
([db index c1 c2] (datoms-internal db index c1 c2))
([db index c1 c2 c3] (datoms-internal db index c1 c2 c3)))
(defn- resolve-lookup-ref [db lookup-ref]
"Resolves a lookup ref [unique-attr value] to an entity ID."
(when (vector? lookup-ref)
(let [[a v] lookup-ref]
;; Note: Assumes AVET index exists for the unique attribute 'a'
;; Real Datascript/Datomic might require :db/unique true in schema
(:e (first (datoms db :avet a v)))))))
(defn- resolve-eid [db tempids-atom max-eid-atom eid-or-ref]
"Resolves entity identifiers, handling tempids and lookup refs."
(cond
(vector? eid-or-ref) (or (resolve-lookup-ref db eid-or-ref)
(throw (ex-info "Lookup ref not found" {:ref eid-or-ref})))
(and (integer? eid-or-ref) (neg? eid-or-ref)) (resolve-tempid tempids-atom max-eid-atom eid-or-ref)
:else eid-or-ref))
(defn- apply-tx-op [db schema tx current-tx-id datoms-to-add datoms-to-retract [op e a v :as tx-item]]
"Processes a single transaction operation, updating datom lists."
(let [resolved-e e ; EID resolution happens before calling this
datom (->Datom resolved-e a v tx (= op :db/add))]
(if (= op :db/add)
(let [cardinality (get-in schema [a :db/cardinality])]
(if (= cardinality :db.cardinality/one)
;; Cardinality one: retract existing value first
(if-let [existing (first (datoms db :eavt resolved-e a))]
(when (not= (.v existing) v) ; Only retract if value is different
(swap! datoms-to-retract conj (assoc existing :tx tx :added false))
(swap! datoms-to-add conj datom))
(swap! datoms-to-add conj datom)) ; No existing value, just add
;; Cardinality many: just add
(swap! datoms-to-add conj datom)))
;; Retract operation
(when-let [existing (first (datoms db :eavt resolved-e a v))] ; Find the exact datom to retract
(swap! datoms-to-retract conj (assoc existing :tx tx :added false))))))
(defn- normalize-tx-data [tx-data]
"Converts map-form transactions to list-form."
(reduce
(fn [acc item]
(if (map? item)
(let [eid (:db/id item)]
(into acc (map (fn [[a v]][:db/add eid a v]) (dissoc item :db/id))))
(conj acc item)))
tx-data))
(defn transact
"Applies transaction data to a db value, returning a TxReport.
Handles tempids, lookup refs, and cardinality."
(let]
(try
[op (resolve-eid db-before tempids current-max-eid eid-or-ref) a v]
(catch Exception e
(throw (ex-info "Failed to resolve EID in transaction item"
{:item item :error (.getMessage e)} e)))))
normalized-tx-data)
;; Atoms to collect datoms generated during the transaction
datoms-to-add (atom)
datoms-to-retract (atom)]
;; Process resolved transaction data to generate datoms
(doseq [tx-item resolved-tx-data]
(apply-tx-op db-before schema next-tx datoms-to-add datoms-to-retract tx-item))
;; Apply the generated datoms to create the new DB state
(let
(reduce (fn [[eavt aevt avet] ^Datom retracted-datom]
;; Find the *added* datom with same EAV to remove
(let [e (.e retracted-datom) a (.a retracted-datom) v (.v retracted-datom)
;; Find the most recent *added* datom matching EAV
datom-to-remove (first (filter #(and (= (.e %) e) (= (.a %) a) (= (.v %) v) (.added %))
(datoms db-before :eavt e a v)))] ; Use db-before to find what to remove
(if datom-to-remove
[(disj eavt datom-to-remove)
(disj aevt datom-to-remove)
(disj avet datom-to-remove)]
[eavt aevt avet]))) ; Datom not found (already retracted?), no change
[eavt-after aevt-after avet-after]
final-datoms-retracted)
;; Apply additions
[eavt-after aevt-after avet-after]
(reduce (fn [[eavt aevt avet] ^Datom added-datom]
[(conj eavt added-datom)
(conj aevt added-datom)
(conj avet added-datom)])
[eavt-after aevt-after avet-after]
final-datoms-added)
db-after (map->DB {:schema schema
:eavt eavt-after
:aevt aevt-after
:avet avet-after
:max-tx next-tx
:max-eid @current-max-eid})]
;; Return the transaction report
(map->TxReport {:db-before db-before
:db-after db-after
:tx-data (vec (concat final-datoms-added final-datoms-retracted))
:tempids @tempids}))))
;; --- Connection Atom ---
(defn create-conn
"Creates a connection (atom) with an empty DB, optionally with schema."
( (atom (empty-db)))
([schema] (atom (empty-db schema))))
(defn transact!
"Applies transaction data to the connection atom, updating it in place.
Returns the TxReport."
[conn tx-data]
(let [report (transact @conn tx-data)]
(reset! conn (:db-after report))
report))
;; --- Entity API Sketch ---
(defprotocol IEntity
(-db [this])
(-eid [this]))
(deftype Entity [db eid attr-cache]
clojure.lang.ILookup
(valAt [this key] (.valAt this key nil))
(valAt [this key not-found]
(if (= key :db/id)
eid
(if-let [cached (find @attr-cache key)]
(val cached) ; Return cached value if found
(let
(if-not (seq datoms-seq)
(do (swap! attr-cache assoc key not-found) not-found) ; Cache miss
(let [result (if (= cardinality :db.cardinality/many)
;; Cardinality many: return set of values
(let [vals (set (map :v datoms-seq))]
(if (= value-type :db.type/ref)
(set (map #(Entity. db % (atom {})) vals)) ; Return set of Entity objects
vals))
;; Cardinality one: return single value
(let [v (:v (first datoms-seq))] ; Get value from the latest datom
(if (= value-type :db.type/ref)
(Entity. db v (atom {})) ; Return Entity object
v)))]
(swap! attr-cache assoc key result) ; Cache result
result))))))
clojure.lang.IMeta
(meta [this] {:db db :eid eid})
IEntity
(-db [this] db)
(-eid [this] eid)
;; Basic print implementation
Object
(toString [this] (str "#Entity{" :db/id " " eid "...}")))
(defn entity
"Retrieves an entity map-like structure by its ID or lookup ref."
[db eid-or-ref]
(let [eid (if (vector? eid-or-ref)
(resolve-lookup-ref db eid-or-ref)
eid-or-ref)]
(when (and eid (pos-int? eid)) ; Ensure we have a valid positive EID
(->Entity db eid (atom {})))))
;; --- Basic Datalog Query Sketch ---
;; This is a *very* simplified query engine. Real engines are much more complex.
(defn- join-relations [rel1 rel2]
(let [common-vars (set/intersection (set (keys (:vars rel1))) (set (keys (:vars rel2))))]
(if (empty? common-vars)
;; Cartesian product (inefficient, avoid if possible in real engine)
{:vars (merge (:vars rel1) (:vars rel2))
:tuples (set (for [t1 (:tuples rel1) t2 (:tuples rel2)] (merge t1 t2)))}
;; Hash join
(let
(let [join-key (select-keys t2 common-vars)]
(if-let [matching-t1s (get indexed-rel1 join-key)]
(into acc (map #(merge % t2) matching-t1s))
acc)))
#{}
(:tuples rel2))]
{:vars (merge (:vars rel1) (:vars rel2))
:tuples joined-tuples}))))
(defn- process-where-clause [db current-relation clause]
;; Very basic: only handles simple data patterns [?e :attr?v] or [?e :attr literal] etc.
;; Needs proper variable binding, index selection based on bound vars, etc.
(if (vector? clause)
(let [[e a v] clause
;; Determine bound vars based on current-relation (simplified)
bound-vars (set (keys (:vars current-relation)))
;; Generate datoms based on clause pattern and bound vars (highly simplified)
;; Real engine needs sophisticated index selection and variable unification
pattern-datoms (cond
(and (symbol? e) (not (bound-vars e)) (keyword? a) (symbol? v) (not (bound-vars v)))
(datoms db :aevt a) ; Find all e, v for a given a
(and (symbol? e) (bound-vars e) (keyword? a) (symbol? v) (not (bound-vars v)))
(mapcat #(datoms db :eavt % a) (map e (:tuples current-relation))) ; Find v for known e, a
;;... many more cases needed...
:else) ; Fallback: empty for unhandled patterns
;; Convert datoms to a relation (simplified)
new-relation {:vars (zipmap (filter symbol? [e a v]) (range)) ; Map vars to tuple index
:tuples (set (map (fn [^Datom d]
(let [vals {:e (.e d) :a (.a d) :v (.v d)}]
;; Create tuple based on which elements are vars
(->> [e a v]
(map #(if (symbol? %) (get vals (keyword (name %))) %))
(zipmap (filter symbol? [e a v])))))
pattern-datoms))}]
(join-relations current-relation new-relation))
current-relation)) ; Ignore non-vector clauses for now
(defn q
"Basic Datalog query. Handles simple :find, :where, :in. No rules, aggregates etc."
[query db & inputs]
(let
(set results))) ; Return a set of result maps
;; --- Basic Pull API Sketch ---
(defn- pull* [entity pattern]
(cond
(= pattern '*) (into {} entity) ; Basic wildcard (needs proper attribute fetching)
(vector? pattern) (select-keys (into {} entity) pattern) ; Select specific keys
;; Add more pattern handling (nested maps, defaults, etc.) later
:else {}))
(defn pull
"Basic Pull API implementation."
[db pattern eid-or-ref]
(when-let [ent (entity db eid-or-ref)]
(pull* ent pattern)))
(defn pull-many
"Basic Pull API for multiple entities."
[db pattern eids-or-refs]
(vec (keep #(pull db pattern %) eids-or-refs)))
;; --- Example Usage ---
(comment
;; Create a connection with schema
(def conn (create-conn {:name {:db/cardinality :db.cardinality/one}
:likes {:db/cardinality :db.cardinality/many}
:friend {:db/cardinality :db.cardinality/one
:db/valueType :db.type/ref}
:email {:db/unique :db.unique/identity}}))
;; Transact initial data using tempids
(def report1 (transact! conn [{:db/id -1 :name "Alice" :likes ["pizza" "fries"] :email "alice@example.com"}
{:db/id -2 :name "Bob" :likes ["sushi"]}]))
(def alice-id (get (:tempids report1) -1))
(def bob-id (get (:tempids report1) -2))
(println "Alice:" alice-id "Bob:" bob-id)
;; Add a relationship
(transact! conn [[:db/add alice-id :friend bob-id]])
;; Query using datoms
(println "Alice's likes:" (map :v (datoms @conn :eavt alice-id :likes)))
; => ("fries" "pizza")
;; Query using basic 'q'
(println "People who like pizza:"
(q '[:find?name
:where [?p :name?name]
[?p :likes "pizza"]]
@conn))
; => #{{?name "Alice"}}
;; Query using basic 'q' with join
(println "Friends of Alice:"
(q '[:find?friend-name
:where [?alice :name "Alice"]
[?alice :friend?friend-eid]
[?friend-eid :name?friend-name]]
@conn))
; => #{{?friend-name "Bob"}}
;; Use entity API
(def alice (entity @conn alice-id))
(println "Alice's name:" (:name alice)) ; => "Alice"
(println "Alice's likes:" (:likes alice)) ; => #{"pizza" "fries"}
(println "Alice's friend's name:" (:name (:friend alice))) ; => "Bob"
;; Use Pull API
(println "Pull Alice:" (pull @conn [:name :likes] alice-id))
; => {:name "Alice", :likes #{"pizza" "fries"}}
(println "Pull Alice with friend:" (pull @conn [:name {:friend [:name]}] alice-id))
; => {:name "Alice", :friend {:name "Bob"}}
(println "Pull Many:" (pull-many @conn [:name][alice-id bob-id]))
; =>
;; Update Alice's name (cardinality one)
(def report-update (transact! conn]))
(println "Update tx-data:" (:tx-data report-update))
; Should show retraction of "Alice" and addition of "Alice Smith"
(println "New name:" (:name (entity @conn alice-id))) ; => "Alice Smith"
;; Retract one of Alice's likes (cardinality many)
(transact! conn [[:db/retract alice-id :likes "fries"]])
(println "Alice's likes now:" (:likes (entity @conn alice-id))) ; => #{"pizza"}
)