diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d23decf --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.hgignore +.hg/ +.idea +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..09709c6 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ + +# Specalog +Generate composable Datalog (Datomic) queries from your `clojure.spec` definitions. + +### Why? +* Build on your shareable spec, not your Datomic API library +* Pull from Datomic using custom views defined in your spec. No code updates required. +* Validate data before it's transacted + +### How? +You probably have a spec that defines the shape of an entity in Datomic. For example, a Person entity might look like this: +```clj +(s/def :person/uuid uuid?) +(s/def :person/first-name string?) +(s/def :person/last-name string?) +(s/def :person/email string?) +(s/def :person/password string?) +(s/def :acme/person (s/keys :req [:person/uuid + :person/email + :person/password] + :opt [:person/first-name + :person/last-name])) +``` +`specalog.query/pull-thing` builds a query that finds all entities with values in the spec's `:req` key, while returnng those values and an additional ones found in the `:opt` keys. +### Example Pull +Using Specalog, generate a Datalog query that pulls entities which fit the shape of the `:acme/person` spec +```clj +(q/pull-thing :acme/person) +``` +And the result is a query with the following properties: +1. Entities returned must have a value for all attributes in the spec's :req key +2. Return all values from both the `:req`: and `:opt` keys. +```clj +; Result +{:find [(pull ?acme-person [:person/uuid + :person/email + :person/password + :person/first-name + :person/last-name])] + :in [$] + :where [[?acme-person :person/uuid] + [?acme-person :person/email] + [?acme-person :person/password]]} +``` +### Example Constraints +Specalog supports simple constraints by passing an optional constraint map to `q/pull-thing`. For example, find all people-like entities with a specific email address. +```clj +(q/pull-thing :acme/person {:person/email "jeanluc.picard@starfleet.edu"}) +``` +```clj +{:find [(pull ?acme-person [:person/uuid :person/email + :person/password + :person/first-name + :person/last-name])] + :in [$] + :where [[?acme-person :person/email "jeanluc.picard@starfleet.edu"] + [?acme-person :person/uuid] + [?acme-person :person/email] + [?acme-person :person/password]]} +``` +### Validate Transactions +Use Specalog to validate input to a transaction +```clj +(q/put-thing :acme/person {:person/first-name "Jean-Luc" + :person/last-name "Picard" + :person/email "jeanluc.picard@starfleet.edu" + :person/password "makeitso" + :person/uuid #uuid"c3a48253-0c34-4613-aca8-79749c6238bd"}) +``` +```clj +; Result +{:tx-data [#:person{:first-name "Jean-Luc", + :last-name "Picard", + :email "jeanluc.picard@starfleet.edu", + :password "makeitso", + :uuid #uuid"c3a48253-0c34-4613-aca8-79749c6238bd"}]} +``` + + + diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..f94dd8c --- /dev/null +++ b/deps.edn @@ -0,0 +1,7 @@ +{ + :paths ["src"] + :deps { + org.clojure/clojure {:mvn/version "1.9.0"} + org.clojure/spec.alpha {:mvn/version "0.1.143"} + } + } \ No newline at end of file diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..280e0c3 --- /dev/null +++ b/project.clj @@ -0,0 +1,8 @@ +(defproject specalog "0.1.0" + :description "Generate Datalog queries using Spec" + :url "https://github.com/joshkh/specalog" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :dependencies [[org.clojure/clojure "1.9.0"]] + :target-path "target/%s" + :profiles {:uberjar {:aot :all}}) diff --git a/src/specalog/jig.clj b/src/specalog/jig.clj new file mode 100644 index 0000000..df46442 --- /dev/null +++ b/src/specalog/jig.clj @@ -0,0 +1,67 @@ +(ns specalog.jig + (:require [clojure.spec.alpha :as s])) + +(def concatv (comp vec concat)) +(def conjv (comp vec conj)) + +(def kw->symbol (fn [k] (symbol (str "?" (clojure.string/join "-" ((juxt namespace name) k)))))) + +(defn form->spec + "Take the form of a map spec and turn it back into a map spec. + In other words, identity function for a map spec" + [spec] + (->> spec + ; Get the form of the spec + s/form + ; Take the good stuff + rest + ; Group the results into k/v pairs (:req, :opt) + (partition 2) + ; Pop it back into a map + (reduce (fn [total [k v]] (assoc total k v)) {}))) + +(defn make-in + "Ensure that a $ value is at the head of an :in clause" + ([] (make-in {})) + ([q] + (cond-> q + (not= '$ (get-in q [:in 0])) (update :in (comp vec (partial cons '$)))))) + +(defn make-pull + "Add all :req and :opt values from a spec'ed map to the :find clause of a query" + ([spec] + (make-pull spec {})) + ([spec q] + (update q :find conjv (list 'pull (kw->symbol spec) (->> spec form->spec vals (apply concat) vec))))) + +(defn filter-ands + [constraints] + (filter (fn [[k v]] (not (coll? v))) constraints)) + +(defn filter-ors + [constraints] + (filter (fn [[k v]] (coll? v)) constraints)) + +(defn make-where-a + [constraints] + (let [ands (filter-ands constraints) + ors (filter-ors constraints)] + (println "ands" ands) + + )) + +(defn make-where + "Add all :req values from a spec'ed map to the :find vector of a query. + This constrains the query to return only things that fully match a spec, aka match-all" + ([spec] + (make-where spec {})) + ([spec constraints] + (make-where spec constraints {})) + ([spec constraints q] + (cond-> q + ; Apply user provided constraints first. Query optimisation? ¯\_(ツ)_/¯ + constraints (update :where concatv (mapcat (fn [[k v]] + [(vector (kw->symbol spec) k v)]) constraints)) + ; Append the :req values as datalog constraints + ; ex. [?some-spec :some-spec/key] + q (update :where concatv (map (fn [w] (vector (kw->symbol spec) w)) (->> spec form->spec :req vec)))))) \ No newline at end of file diff --git a/src/specalog/query.clj b/src/specalog/query.clj new file mode 100644 index 0000000..6d01ae6 --- /dev/null +++ b/src/specalog/query.clj @@ -0,0 +1,31 @@ +(ns specalog.query + (:require [specalog.jig :as jig] + [clojure.spec.alpha :as s]) + (:import [java.util.UUID])) + +(defn ruuid [] (java.util.UUID/randomUUID)) + +; Turn on assertion checking in order to throw Exceptions +(s/check-asserts true) + +(defn pull-thing + "Returns a datalog query where by returned entities meet the minimum requirements + of a spec while pulling all potential values from a spec." + ([spec] (pull-thing spec {})) + ([spec constraints] (pull-thing spec constraints {})) + ([spec constraints query] + (->> query + (jig/make-pull spec) + (jig/make-in) + (jig/make-where spec constraints)))) + + +(defn put-thing + "Returns a datalog query" + ([data] + (put-thing nil data)) + ([spec data] + (cond-> {} + ; If we conform to the spec then return a transaction query + (s/assert spec data) (update :tx-data jig/conjv data) ))) + diff --git a/test/specalog/core_test.clj b/test/specalog/core_test.clj new file mode 100644 index 0000000..8158d93 --- /dev/null +++ b/test/specalog/core_test.clj @@ -0,0 +1,50 @@ +(ns specalog.core-test + (:require [clojure.test :refer :all] + [clojure.spec.alpha :as s] + [specalog.specs] + [specalog.jig :as jig] + [specalog.query :as q])) + +;; spec helper functions + +(deftest test-jig-hydrate-spec + (testing "Convert a spec'ed map back into its spec definition" + (is (= + (jig/form->spec :acme/person) + {:req [:person/uuid + :person/email + :person/password] + :opt [:person/first-name + :person/last-name]})))) + +;; make-in tests + +(deftest test-jig-make-in-blank + (testing "Prepend $ to an non-existing :in clause" + (is (= (get (jig/make-in) :in) + ['$])))) + +(deftest test-jig-make-in-add-missing-db-$ + (testing "Prepend $ to an existing :in clause if it's not there" + (is (= (get (jig/make-in {:in ['exists]}) :in) + ['$ 'exists])))) + +(deftest test-jig-make-in-$-already-exists + (testing "Don't prepend $ to an existing :in clause if it's already there" + (is (= (get (jig/make-in '{:in [$]}) :in) + ['$])))) + +;; pull a thing + +(deftest test-pull-thing + (testing "Pull a thing that matches a spec, returning all :req and :opt keys" + (is (= (q/pull-thing :acme/person) + '{:find [(pull ?acme-person [:person/uuid :person/email + :person/password + :person/first-name + :person/last-name])], + :in [$], + :where [[?acme-person :person/uuid] + [?acme-person :person/email] + [?acme-person :person/password]]})))) + diff --git a/test/specalog/specs.clj b/test/specalog/specs.clj new file mode 100644 index 0000000..5d00854 --- /dev/null +++ b/test/specalog/specs.clj @@ -0,0 +1,25 @@ +(ns specalog.specs + (:require [clojure.spec.alpha :as s] + [clojure.spec.gen.alpha :as gen])) + +(def full-string? (s/and string? (complement empty?))) + +; Spec the entity's attributes +(s/def :person/uuid uuid?) +(s/def :person/first-name full-string?) +(s/def :person/last-name full-string?) +(s/def :person/email full-string?) +(s/def :person/password full-string?) + +; ... and the entity itself as a map +(s/def :acme/person (s/keys :req [:person/uuid + :person/email + :person/password] + :opt [:person/first-name + :person/last-name])) + + +; TODO - spec a generic Datalog query +;(pull ?root [:something]) +;(s/def :datalog/pull-clause (s/and list? (s/tuple symbol? symbol? vector?))) +;(s/def :datalog/pull (s/coll-of :datalog/pull-clause)) \ No newline at end of file