diff --git a/.github/workflows/clojure.yml b/.github/workflows/clojure.yml index e5c4a0a2b9..51e5028cac 100644 --- a/.github/workflows/clojure.yml +++ b/.github/workflows/clojure.yml @@ -102,7 +102,7 @@ jobs: services: postgres: - image: ghcr.io/instantdb/postgresql:postgresql-16-pg-hint-plan + image: ghcr.io/instantdb/postgresql:postgresql-17-pg-hint-plan ports: - 5432:5432 env: diff --git a/server/dev-postgres/Dockerfile b/server/dev-postgres/Dockerfile index c48486a68e..07e6539508 100644 --- a/server/dev-postgres/Dockerfile +++ b/server/dev-postgres/Dockerfile @@ -1,10 +1,10 @@ -FROM postgres:16 +FROM postgres:17 RUN apt-get update && \ apt-get install -y \ build-essential \ - postgresql-server-dev-16 \ - postgresql-16-pg-hint-plan \ + postgresql-server-dev-17 \ + postgresql-17-pg-hint-plan \ wget && \ mkdir build && cd build && \ wget https://github.com/eulerto/wal2json/archive/refs/tags/wal2json_2_6.tar.gz && \ diff --git a/server/dev-postgres/Makefile b/server/dev-postgres/Makefile index 69a9dc8fab..1b043580c1 100644 --- a/server/dev-postgres/Makefile +++ b/server/dev-postgres/Makefile @@ -6,4 +6,4 @@ github-login: build-and-push-image: docker buildx build --platform linux/amd64,linux/arm64 --push \ - -t ghcr.io/instantdb/postgresql:postgresql-16-pg-hint-plan . + -t ghcr.io/instantdb/postgresql:postgresql-17-pg-hint-plan . diff --git a/server/src/instant/db/model/triple.clj b/server/src/instant/db/model/triple.clj index 207b3a2dc7..cc3e470ecd 100644 --- a/server/src/instant/db/model/triple.clj +++ b/server/src/instant/db/model/triple.clj @@ -401,6 +401,13 @@ (def value-lookup-error-prefix "missing-lookup-value") +;; Helper for :when-not-matched in `insert-multi +(def triple-t-values + (zipmap triple-cols + (map (fn [col] + (keyword (str "t." (name col)))) + triple-cols))) + (defn insert-multi! "Given a set of raw triples, we enhance each triple with metadata based on the triple's underlying attr and then insert these enhanced triples into @@ -586,14 +593,28 @@ {:select :* :from :enhanced-triples :where [:not :ea]} ea-index-inserts - {:insert-into [[:triples triple-cols] - {:select triple-cols - :from :ea-triples-distinct - :order-by [:app-id :entity-id :attr-id :value-md5]}] - :on-conflict [:app-id :entity-id :attr-id {:where [:= :ea true]}] - :do-update-set {:value :excluded.value - :value-md5 :excluded.value-md5} - :returning :*} + (if (not (flags/toggled? :disable-merge-into)) + {:merge-into :triples + :using [[:ea-triples-distinct :t]] + :on [:and + [:= :triples.app-id :t.app-id] + [:= :triples.entity-id :t.entity-id] + [:= :triples.attr-id :t.attr-id]] + :when-not-matched {:insert {:values [triple-t-values]}} + :when-matched [[[:= :triples.value-md5 :t.value-md5] + :do-nothing] + {:update {:set {:value :t.value + :value-md5 :t.value-md5}}}] + :returning :triples.*} + + {:insert-into [[:triples triple-cols] + {:select triple-cols + :from :ea-triples-distinct + :order-by [:app-id :entity-id :attr-id :value-md5]}] + :on-conflict [:app-id :entity-id :attr-id {:where [:= :ea true]}] + :do-update-set {:value :excluded.value + :value-md5 :excluded.value-md5} + :returning :*}) remaining-inserts {:insert-into [[:triples triple-cols] diff --git a/server/src/instant/util/hsql.clj b/server/src/instant/util/hsql.clj index e16c7f1f17..5b703a3d32 100644 --- a/server/src/instant/util/hsql.clj +++ b/server/src/instant/util/hsql.clj @@ -1,5 +1,117 @@ (ns instant.util.hsql - (:require [honey.sql :as hsql])) + (:require clojure.string + [honey.sql :as hsql])) + +;; ------------------------ +;; merge-into custom clause + +;; :merge-into + +(defn format-matched-cond [expr] + (let [[sql & params] (honey.sql/format-expr expr)] + (when-not (clojure.string/blank? sql) + (into [(str "AND " sql)] params)))) + +(defn matched-merge-action? [x] + (case x + :do-nothing :do-nothing + :delete :delete + (and (map? x) + (or (:update x) + (:insert x))))) + +(defn format-matched-merge-action [x] + (case x + :do-nothing ["DO NOTHING"] + :delete ["DELETE"] + (cond (:update x) + (let [[sql & params] (honey.sql/format-dsl (:update x))] + (into [(str "UPDATE " sql)] params)) + + (:insert x) + (let [[sql & params] (honey.sql/format-dsl (:insert x))] + (into [(str "INSERT " sql)] params))))) + +(defn matched-cases [exp] + (cond + ;; just a single action with no condition + (matched-merge-action? exp) + [exp] + + ;; single action with condition + (and (vector? exp) + (= 2 (count exp)) + (not (vector? (first exp))) + (not (matched-merge-action? (first exp))) + (matched-merge-action? (second exp))) + [exp] + + ;; A list of condition+action + (vector? exp) + exp + + :else + (throw (Exception. "Unknown match clauses for :when-matched/:when-not-matched")))) + +(honey.sql/register-clause! + :when-not-matched + (fn [_clause exp] + (reduce (fn [[sql & params] expr] + (if (matched-merge-action? expr) + (let [[expr-sql & expr-params] (format-matched-merge-action expr)] + (into [(str sql " WHEN NOT MATCHED THEN " expr-sql)] + (into params expr-params))) + (do + (assert (and (vector? expr) + (matched-merge-action? (second expr))) + "Invalid match clause for :when-not-matched") + (let [[cond-expr action-expr] expr + [case-sql & case-params] (format-matched-cond cond-expr) + [expr-sql & expr-params] (format-matched-merge-action action-expr)] + (into [(str sql " WHEN NOT MATCHED " case-sql " THEN " expr-sql)] + (concat params case-params expr-params)))) + + )) + [""] + (matched-cases exp))) + ;; Get behind of :using + :join-by) + +(honey.sql/register-clause! + :when-matched + (fn [_clause exp] + (reduce (fn [[sql & params] expr] + (if (matched-merge-action? expr) + (let [[expr-sql & expr-params] (format-matched-merge-action expr)] + (into [(str sql " WHEN MATCHED THEN " expr-sql)] + (into params expr-params))) + (do + (assert (and (vector? expr) + (matched-merge-action? (second expr))) + "Invalid match clause for :when-matched") + (let [[cond-expr action-expr] expr + [case-sql & case-params] (format-matched-cond cond-expr) + [expr-sql & expr-params] (format-matched-merge-action action-expr)] + (into [(str sql " WHEN MATCHED " case-sql " THEN " expr-sql)] + (concat params case-params expr-params)))) + + )) + [""] + (matched-cases exp))) + :when-not-matched) + +(honey.sql/register-clause! + :on + (fn [_clause expr] + (let [[sql & params] (honey.sql/format-expr expr)] + (into [(str "ON " sql)] params))) + :when-matched) + +(honey.sql/register-clause! + :merge-into + (fn [_clause tbl] + [(str "MERGE INTO " (honey.sql/format-entity tbl))]) + :using) ;; ---------------------- ;; pg-hints custom clause diff --git a/server/test/instant/jdbc/sql_test.clj b/server/test/instant/jdbc/sql_test.clj index 9ebc68cf73..1b01c4c10a 100644 --- a/server/test/instant/jdbc/sql_test.clj +++ b/server/test/instant/jdbc/sql_test.clj @@ -34,6 +34,27 @@ (sql/select (aurora/conn-pool :read) ["select 1"]) (is (= 0 (count @(:stmts in-progress))))))) +(deftest merge-into + (testing "smoke test" + (is (= ["MERGE INTO test USING a b ON (b.id = test.id) AND (b.v = test.v) WHEN MATCHED AND test.a = b.a THEN DELETE WHEN MATCHED AND test.b = b.b THEN UPDATE SET a = ? WHEN MATCHED AND test.c = b.c THEN INSERT (a) VALUES (?), (?) WHEN MATCHED THEN DO NOTHING WHEN NOT MATCHED THEN DO NOTHING RETURNING \"\".*" + 1 + 2 + 3] + (honey.sql/format {:merge-into :test + :using [[:a :b]] + :on [:and + [:= :b.id :test.id] + [:= :b.v :test.v]] + :when-matched [[[:= :test.a :b.a] + :delete] + [[:= :test.b :b.b] + {:update {:set {:a 1}}}] + [[:= :test.c :b.c] + {:insert {:values [{:a 2} {:a 3}]}}] + :do-nothing] + :when-not-matched :do-nothing + :returning :.*}))))) + (deftest cant-write-on-a-readonly-connection (is (thrown-with-msg? clojure.lang.ExceptionInfo #"read-only-sql-transaction"