[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"} )