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

)

Author: Younghwan Nam

Created: 2025-04-26 Sat 03:48

Emacs 27.2 (Org mode 9.4.4)

Validate