diff --git a/deps.edn b/deps.edn index 0a79182..ba45ae7 100644 --- a/deps.edn +++ b/deps.edn @@ -6,10 +6,7 @@ ;; db com.datomic/local {:mvn/version "1.0.291"} - com.xtdb/xtdb-api {:mvn/version "2.0.0-beta9"} - com.github.seancorfield/next.jdbc {:mvn/version "1.3.1002"} - org.postgresql/postgresql {:mvn/version "42.7.6"} - frontmatter/frontmatter {:mvn/version "0.0.1"} + ;; logging com.taoensso/telemere {:mvn/version "1.0.0"} @@ -25,7 +22,7 @@ ;; routing: metosin/reitit {:mvn/version "0.9.1"} - ;; convenient package of "default" middleware: + ;; convenient package of "default" middleware: ;; ring/ring-defaults {:mvn/version "0.5.0"} org.clojure/clojure {:mvn/version "1.12.1"}} diff --git a/src/snippets/infra/db.clj b/src/snippets/infra/db.clj index f7ab667..e89dc52 100644 --- a/src/snippets/infra/db.clj +++ b/src/snippets/infra/db.clj @@ -1,55 +1,219 @@ (ns snippets.infra.db (:require - [taoensso.telemere :as t] + [clojure.set :as set] + [datomic.client.api :as d] + [malli.core :as m] [snippets.infra.config :as config] - [xtdb.api :as xt])) + [taoensso.telemere :as t])) -(def client - (let - [c (:xtdb (config/get-config))] - (xt/client - {:host (:host c) - :port (:port c) - :user "xtdb" - ;; :password "xtdb" - :dbname "xtdb"}))) +;; Initialize the Datomic Local client +;; :system "dev" groups your databases in the "dev" system +;; In production, you'd set :storage-dir to a persistent path +;; TODO: add save file location for prod -;; xtdb query docs: https://docs.xtdb.com/reference/main/xtql/queries.html#_limit -(defn list-snippets [{:keys [skip limit]}] - (if (nil? limit) - (xt/q client - '(-> (from :snippets [title pub-date tags slug markdown {:xt/id id}]) (order-by {:val pub-date, :dir :desc, :nulls :last}))) - (xt/q client - (eval - (read-string - (format "(quote (-> (from :snippets [title pub-date tags slug markdown {:xt/id id}]) (order-by {:val pub-date, :dir :desc, :nulls :last}) (offset %s) (limit %s)))" skip limit)))))) +(def datomic-config (:datomic (config/get-config))) -(defn get-snippet-by-id [snippet-id] - (first (xt/q client ['#(from :snippets [{:xt/id %} slug title tags {:xt/id id} markdown pub-date]) snippet-id]))) +(def client (d/client (merge {:server-type :datomic-local + :system "dev"} datomic-config))) -(defn get-snippet-by-slug [slug] - (first (xt/q client ['#(from :snippets [{:xt/id id} {:slug %} slug title tags markdown pub-date]) slug]))) +(def db-name "snippets") -(defn put-snippet [id snippet] - (t/log! {:level :info, :data {:snippet snippet :id id}} "Saving new snippet to db") - (xt/execute-tx client [[:put-docs :snippets (merge snippet {:xt/id id})]])) +;; Create the database if it doesn't exist +(defn- ensure-db + "Check if db exists, create it if not." + [] + (d/create-database client {:db-name db-name}) + (t/log! {:level :info} "Snippets database created if needed")) -(defn delete-snippet [id] - (t/log! {:level :info, :data {:id id}} "Deleting snippet") - (xt/execute-tx client [[:delete-docs :snippets id]])) +;; Get a connection to the database +(defn- get-conn [] + (d/connect client {:db-name db-name})) -(defn erase-snippet [id] - (t/log! {:level :info, :data {:id id}} "Erasing snippet, aka removing it completely through out time") - (xt/execute-tx client [[:erase-docs :snippets id]])) +;; Define the schema for snippets +;; Transact this once to set up the database structure +(def snippet-schema + [{:db/ident :snippet/title + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :snippet/slug + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value} + {:db/ident :snippet/markdown + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :snippet/tags + :db/valueType :db.type/string + :db/cardinality :db.cardinality/many} + {:db/ident :snippet/pub-date + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one}]) -(defn patch-snippet [id patch] - (t/log! {:level :info, :data {:patch patch :id id}} "Patching snippet") - (xt/execute-tx client [[:patch-docs :snippets (merge {:xt/id id} patch)]])) +(defn- ensure-schema + "Transact the schema if it doesn't exist. Call this once on startup." + [] + (let [conn (get-conn)] + (d/transact conn {:tx-data snippet-schema}) + (t/log! {:level :info} "Snippet schema created if needed"))) -(defn list-tags [] - (xt/q client '(-> (from :snippets [{:xt/id id} tags]) (unnest {:tag tags}) (without :tags) (aggregate tag {:count (count id)})))) +(defn start-up-check + "Should be run at startup to ensure the database and schema are created." + [] + (ensure-db) + (ensure-schema)) -(defn get-snippets-by-tag [tag] - (map #(dissoc % :tag) - (xt/q client (eval (read-string - (format "(quote (-> (from :snippets [title slug tags pub-date]) (unnest {:tag tags}) (without :tags) (where (= tag \"%s\"))))" tag)))))) +(defn- snippet-to-entity + "Convert a snippet map to a Datomic DB entity." + [snippet] + {:snippet/title (:title snippet) + :snippet/slug (:slug snippet) + :snippet/markdown (:markdown snippet) + :snippet/tags (:tags snippet) + :snippet/pub-date (:pub-date snippet)}) + +(defn- entity-to-snippet + "Convert a Datomic DB entity to a snippet map." + [entity] + {:title (:snippet/title entity) + :slug (:snippet/slug entity) + :markdown (:snippet/markdown entity) + :tags (:snippet/tags entity) + :pub-date (:snippet/pub-date entity)}) + +(defn- wrap-snippet-return + "Wraps an fn that returns snippet, snippet[], or nil; converting the entity to a snippet map." + [snippet-fn] + (fn [& args] + (let [res (apply snippet-fn args)] + (cond + (nil? res) nil + :else (if (sequential? res) + (map entity-to-snippet res) + (entity-to-snippet res)))))) + +;; create +(def create-schema + "Malli schema for a valid snippet entity creation." + [:map + [:snippet/title :string] + [:snippet/slug :string] + [:snippet/markdown :string] + [:snippet/tags [:vector :string]] + [:snippet/pub-date [:fn #(instance? java.util.Date %)]]]) + +(defn- valid-create? + "Check if a snippet map is a valid Datomic entity." + [entity] + (m/validate create-schema entity)) + +(defn create-snippets + "Create new snippets in the database." + [snippets] + (t/log! {:level :info, :data {:slugs (map :slug snippets)}} "Saving new snippets to db") + (let [conn (get-conn) + entities (map snippet-to-entity snippets)] + (if (every? valid-create? entities) + (d/transact conn {:tx-data entities}) + (throw (ex-info "Invalid snippet entity" {:entities entities}))))) + +;; read +(defn- get-snippet-by-slug-from-db + "Get a single snippet by its slug." + [slug] + (let [conn (get-conn) + db (d/db conn) + query '[:find (pull ?e [*]) + :in $ ?slug + :where [?e :snippet/slug ?slug]] + snippet (ffirst (d/q query db slug))] + (t/log! {:level :info, :data {:slug slug :snippet snippet}} "Got snippet by slug") + snippet)) + +(def get-snippet-by-slug + (wrap-snippet-return get-snippet-by-slug-from-db)) + +;; update +(def update-schema + "Malli schema for a valid update to a snippet entity." + [:map + [:db/id :int] + [:snippet/title {:optional true} :string] + [:snippet/slug {:optional true} :string] + [:snippet/markdown {:optional true} :string] + [:snippet/tags {:optional true} [:vector :string]]]) + +(defn- to-update [patch] + (cond-> {} + (some? (:title patch)) (assoc :snippet/title (:title patch)) + (some? (:slug patch)) (assoc :snippet/slug (:slug patch)) + (some? (:markdown patch)) (assoc :snippet/markdown (:markdown patch)) + (some? (:tags patch)) (assoc :snippet/tags (:tags patch)))) + +(defn- patch-snippet-in-db + "Update specific fields of a snippet." + [slug raw-patch] + (let [conn (get-conn) + snippet (get-snippet-by-slug-from-db slug) + eid (:db/id snippet) + new-tags (get raw-patch :tags '[]) + existing-tags (get snippet :snippet/tags '[]) + tags-to-remove (vec (set/difference (set existing-tags) (set new-tags))) + retracts (map #(vector :db/retract eid :snippet/tags %) tags-to-remove) + patch (merge (to-update raw-patch) {:db/id eid})] + (t/log! {:level :info, :data {:patch patch :retracts retracts :slug slug :eid eid}} "Patching snippet") + (when (nil? eid) + (throw (ex-info "Snippet not found" {:slug slug}))) + (when-not (m/validate update-schema patch) + (throw (ex-info "Invalid patch" {:errors (m/explain update-schema patch) :patch patch}))) + (d/transact conn {:tx-data (into [patch] retracts)}))) + +(defn update-snippet [& args] + (let [res (apply patch-snippet-in-db args)] + (t/log! {:level :info, :data {:res res :args args}} "Finished patching snippet"))) + +(defn list-snippets-in-db + "List all the snippets" + [] + (let [conn (get-conn) + db (d/db conn) + query '[:find (pull ?e [*]) + :where + [?e :snippet/slug]]] + (->> (d/q query db) + (map first)))) + +(def list-snippets (wrap-snippet-return list-snippets-in-db)) + +(defn delete-snippet-by-slug + "Soft delete a snippet (retract its entity)." + [slug] + (t/log! {:level :info, :data {:slug slug}} "Retracting snippet") + (let [conn (get-conn) + eid (:db/id (get-snippet-by-slug-from-db slug))] + (if (nil? eid) + nil + (d/transact conn {:tx-data [[:db/retractEntity eid]]})))) + +(defn list-tags + "List all tags used in snippets with their counts." + [] + (let [conn (get-conn) + db (d/db conn) + query '[:find ?tag (count ?e) + :where + [?e :snippet/tags ?tag]]] + (d/q query db))) + +(defn get-snippets-by-tag-in-db + "Get all snippets that have a specific tag." + [tag] + (let [conn (get-conn) + db (d/db conn) + query '[:find (pull ?e [*]) + :in $ ?tag + :where + [?e :snippet/tags ?tag]] + results (d/q query db tag)] + (mapv first results))) + +(def get-snippets-by-tag (wrap-snippet-return get-snippets-by-tag-in-db)) diff --git a/src/snippets/infra/db2.clj b/src/snippets/infra/db2.clj deleted file mode 100644 index 277961c..0000000 --- a/src/snippets/infra/db2.clj +++ /dev/null @@ -1,219 +0,0 @@ -(ns snippets.infra.db2 - (:require - [clojure.set :as set] - [datomic.client.api :as d] - [malli.core :as m] - [snippets.infra.config :as config] - [taoensso.telemere :as t])) - -;; Initialize the Datomic Local client -;; :system "dev" groups your databases in the "dev" system -;; In production, you'd set :storage-dir to a persistent path -;; TODO: add save file location for prod - -(def datomic-config (:datomic (config/get-config))) - -(def client (d/client (merge {:server-type :datomic-local - :system "dev"} datomic-config))) - -(def db-name "snippets") - -;; Create the database if it doesn't exist -(defn- ensure-db - "Check if db exists, create it if not." - [] - (d/create-database client {:db-name db-name}) - (t/log! {:level :info} "Snippets database created if needed")) - -;; Get a connection to the database -(defn- get-conn [] - (d/connect client {:db-name db-name})) - -;; Define the schema for snippets -;; Transact this once to set up the database structure -(def snippet-schema - [{:db/ident :snippet/title - :db/valueType :db.type/string - :db/cardinality :db.cardinality/one} - {:db/ident :snippet/slug - :db/valueType :db.type/string - :db/cardinality :db.cardinality/one - :db/unique :db.unique/value} - {:db/ident :snippet/markdown - :db/valueType :db.type/string - :db/cardinality :db.cardinality/one} - {:db/ident :snippet/tags - :db/valueType :db.type/string - :db/cardinality :db.cardinality/many} - {:db/ident :snippet/pub-date - :db/valueType :db.type/instant - :db/cardinality :db.cardinality/one}]) - -(defn- ensure-schema - "Transact the schema if it doesn't exist. Call this once on startup." - [] - (let [conn (get-conn)] - (d/transact conn {:tx-data snippet-schema}) - (t/log! {:level :info} "Snippet schema created if needed"))) - -(defn start-up-check - "Should be run at startup to ensure the database and schema are created." - [] - (ensure-db) - (ensure-schema)) - -(defn- snippet-to-entity - "Convert a snippet map to a Datomic DB entity." - [snippet] - {:snippet/title (:title snippet) - :snippet/slug (:slug snippet) - :snippet/markdown (:markdown snippet) - :snippet/tags (:tags snippet) - :snippet/pub-date (:pub-date snippet)}) - -(defn- entity-to-snippet - "Convert a Datomic DB entity to a snippet map." - [entity] - {:title (:snippet/title entity) - :slug (:snippet/slug entity) - :markdown (:snippet/markdown entity) - :tags (:snippet/tags entity) - :pub-date (:snippet/pub-date entity)}) - -(defn- wrap-snippet-return - "Wraps an fn that returns snippet, snippet[], or nil; converting the entity to a snippet map." - [snippet-fn] - (fn [& args] - (let [res (apply snippet-fn args)] - (cond - (nil? res) nil - :else (if (sequential? res) - (map entity-to-snippet res) - (entity-to-snippet res)))))) - -;; create -(def create-schema - "Malli schema for a valid snippet entity creation." - [:map - [:snippet/title :string] - [:snippet/slug :string] - [:snippet/markdown :string] - [:snippet/tags [:vector :string]] - [:snippet/pub-date [:fn #(instance? java.util.Date %)]]]) - -(defn- valid-create? - "Check if a snippet map is a valid Datomic entity." - [entity] - (m/validate create-schema entity)) - -(defn create-snippets - "Create new snippets in the database." - [snippets] - (t/log! {:level :info, :data {:slugs (map :slug snippets)}} "Saving new snippets to db") - (let [conn (get-conn) - entities (map snippet-to-entity snippets)] - (if (every? valid-create? entities) - (d/transact conn {:tx-data entities}) - (throw (ex-info "Invalid snippet entity" {:entities entities}))))) - -;; read -(defn- get-snippet-by-slug-from-db - "Get a single snippet by its slug." - [slug] - (let [conn (get-conn) - db (d/db conn) - query '[:find (pull ?e [*]) - :in $ ?slug - :where [?e :snippet/slug ?slug]] - snippet (ffirst (d/q query db slug))] - (t/log! {:level :info, :data {:slug slug :snippet snippet}} "Got snippet by slug") - snippet)) - -(def get-snippet-by-slug - (wrap-snippet-return get-snippet-by-slug-from-db)) - -;; update -(def update-schema - "Malli schema for a valid update to a snippet entity." - [:map - [:db/id :int] - [:snippet/title {:optional true} :string] - [:snippet/slug {:optional true} :string] - [:snippet/markdown {:optional true} :string] - [:snippet/tags {:optional true} [:vector :string]]]) - -(defn- to-update [patch] - (cond-> {} - (some? (:title patch)) (assoc :snippet/title (:title patch)) - (some? (:slug patch)) (assoc :snippet/slug (:slug patch)) - (some? (:markdown patch)) (assoc :snippet/markdown (:markdown patch)) - (some? (:tags patch)) (assoc :snippet/tags (:tags patch)))) - -(defn- patch-snippet-in-db - "Update specific fields of a snippet." - [slug raw-patch] - (let [conn (get-conn) - snippet (get-snippet-by-slug-from-db slug) - eid (:db/id snippet) - new-tags (get raw-patch :tags '[]) - existing-tags (get snippet :snippet/tags '[]) - tags-to-remove (vec (set/difference (set existing-tags) (set new-tags))) - retracts (map #(vector :db/retract eid :snippet/tags %) tags-to-remove) - patch (merge (to-update raw-patch) {:db/id eid})] - (t/log! {:level :info, :data {:patch patch :retracts retracts :slug slug :eid eid}} "Patching snippet") - (when (nil? eid) - (throw (ex-info "Snippet not found" {:slug slug}))) - (when-not (m/validate update-schema patch) - (throw (ex-info "Invalid patch" {:errors (m/explain update-schema patch) :patch patch}))) - (d/transact conn {:tx-data (into [patch] retracts)}))) - -(defn update-snippet [& args] - (let [res (apply patch-snippet-in-db args)] - (t/log! {:level :info, :data {:res res :args args}} "Finished patching snippet"))) - -(defn list-snippets-in-db - "List all the snippets" - [] - (let [conn (get-conn) - db (d/db conn) - query '[:find (pull ?e [*]) - :where - [?e :snippet/slug]]] - (->> (d/q query db) - (map first)))) - -(def list-snippets (wrap-snippet-return list-snippets-in-db)) - -(defn delete-snippet-by-slug - "Soft delete a snippet (retract its entity)." - [slug] - (t/log! {:level :info, :data {:slug slug}} "Retracting snippet") - (let [conn (get-conn) - eid (:db/id (get-snippet-by-slug-from-db slug))] - (if (nil? eid) - nil - (d/transact conn {:tx-data [[:db/retractEntity eid]]})))) - -(defn list-tags - "List all tags used in snippets with their counts." - [] - (let [conn (get-conn) - db (d/db conn) - query '[:find ?tag (count ?e) - :where - [?e :snippet/tags ?tag]]] - (d/q query db))) - -(defn get-snippets-by-tag-in-db - "Get all snippets that have a specific tag." - [tag] - (let [conn (get-conn) - db (d/db conn) - query '[:find (pull ?e [*]) - :in $ ?tag - :where - [?e :snippet/tags ?tag]] - results (d/q query db tag)] - (mapv first results))) - -(def get-snippets-by-tag (wrap-snippet-return get-snippets-by-tag-in-db)) diff --git a/src/snippets/main.clj b/src/snippets/main.clj index cce0890..173226a 100644 --- a/src/snippets/main.clj +++ b/src/snippets/main.clj @@ -2,7 +2,7 @@ (:require [snippets.infra.api :as api] [clojure.core.server] - [snippets.infra.db2 :refer [start-up-check]] + [snippets.infra.db :refer [start-up-check]] [taoensso.telemere :as t]) (:gen-class)) diff --git a/src/snippets/use_cases/backfill_db2.clj b/src/snippets/use_cases/backfill_db2.clj deleted file mode 100644 index c2bf21a..0000000 --- a/src/snippets/use_cases/backfill_db2.clj +++ /dev/null @@ -1,15 +0,0 @@ -(ns snippets.use-cases.backfill-db2 - (:require - [snippets.infra.db :as old-db] - [snippets.infra.db2 :as new-db] - [taoensso.telemere :as t])) - -(defn- zdt-to-date [zdt] - (java.util.Date/from (.toInstant zdt))) - -(defn backfill [] - (t/log! {:level :info} "Backfilling DB2") - (let [old-snippets (old-db/list-snippets {}) - new-snippets (map #(assoc % :pub-date (zdt-to-date (:pub-date %))) old-snippets)] - (t/log! {:level :info :data {:count (count new-snippets)}} "Creating snippets") - (new-db/create-snippets new-snippets))) diff --git a/src/snippets/use_cases/create.clj b/src/snippets/use_cases/create.clj index 244ead9..6f0b494 100644 --- a/src/snippets/use_cases/create.clj +++ b/src/snippets/use_cases/create.clj @@ -1,7 +1,7 @@ (ns snippets.use-cases.create (:require [taoensso.telemere :as t] - [snippets.infra.db2 :as db])) + [snippets.infra.db :as db])) (defn create-snippet [{:keys [title slug markdown tags]}] (let [pub-date (java.util.Date.)] diff --git a/src/snippets/use_cases/delete.clj b/src/snippets/use_cases/delete.clj index 9febf99..e11ed9d 100644 --- a/src/snippets/use_cases/delete.clj +++ b/src/snippets/use_cases/delete.clj @@ -1,6 +1,6 @@ (ns snippets.use-cases.delete (:require - [snippets.infra.db2 :as db] + [snippets.infra.db :as db] [taoensso.telemere :as t])) (defn delete-snippet [slug] diff --git a/src/snippets/use_cases/edit.clj b/src/snippets/use_cases/edit.clj index 1929822..508f88c 100644 --- a/src/snippets/use_cases/edit.clj +++ b/src/snippets/use_cases/edit.clj @@ -2,7 +2,7 @@ (:require [taoensso.telemere :as t] [malli.core :as m] - [snippets.infra.db2 :as db])) + [snippets.infra.db :as db])) (def valid-patch? (m/validator diff --git a/src/snippets/use_cases/view.clj b/src/snippets/use_cases/view.clj index 2357c32..26f8d6c 100644 --- a/src/snippets/use_cases/view.clj +++ b/src/snippets/use_cases/view.clj @@ -1,7 +1,7 @@ (ns snippets.use-cases.view (:require [taoensso.telemere :as t] - [snippets.infra.db2 :as db])) + [snippets.infra.db :as db])) (defn serialize-snippet "Converts snippet pub-date to ISO-8601 string for EDN serialization"