Skip to content

Commit ff6bea0

Browse files
committed
Fix bug in deserializing JSON from the postgres adapter
The next.jdbc-suggested changes to make JSON decoding automatic apply to the whole JVM. If they're applied, then decoding metadata off of features will fail because the adapter has been assuming it's being handed a raw postgres object and calling `.getValue.` This patch makes the decoding more defensive, and lets it fail gently, since right now the only metadata examined at all is the `:expires-at` key. If we miss decoding this, then we just don't get expiry warnings. Fixes #3.
1 parent 7dfccf5 commit ff6bea0

File tree

3 files changed

+183
-39
lines changed

3 files changed

+183
-39
lines changed

modules/fstore-postgres/src/hyak2/postgres_store.clj

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
highly cacheable, there is optional caching provided by clojure.core.memoize
1010
to reduce the load on the database."
1111
(:require
12-
[clojure.core.memoize :as memo]
13-
[clojure.data.json :as json]
12+
[clojure.core.memoize :as memo]
13+
[clojure.data.json :as json]
14+
[clojure.tools.logging :refer [warn]]
1415
[hugsql.adapter.next-jdbc]
15-
[hugsql.core :as hugsql]
16-
[hyak2.adapter :as ha]
17-
[hyak2.platform :as hp]
16+
[hugsql.core :as hugsql]
17+
[hyak2.adapter :as ha]
18+
[hyak2.platform :as hp]
1819
[next.jdbc])
1920
(:import
2021
(java.time Instant LocalDateTime ZoneOffset)))
@@ -31,20 +32,43 @@
3132
(let [inst (Instant/parse s)]
3233
(LocalDateTime/ofInstant inst ZoneOffset/UTC))))
3334

35+
(defn- decode-feature-meta
36+
"Decode metadata off a feature-row, return as a map. The adapter may or may
37+
not have set up auto-deserialization of the JSON metadata column.
38+
39+
See https://cljdoc.org/d/com.github.seancorfield/next.jdbc/1.3.1002/doc/getting-started/tips-tricks#working-with-json-and-jsonb"
40+
[feature-row]
41+
(let [raw-meta (:metadata feature-row)]
42+
(try
43+
(cond
44+
(map? raw-meta) ;; has already been decoded by something earlier on
45+
raw-meta
46+
47+
(instance? org.postgresql.util.PGobject raw-meta) ;; is still a PGobject
48+
(json/read-str (.getValue raw-meta) :key-fn keyword)
49+
50+
(string? raw-meta)
51+
(json/read-str raw-meta :key-fn keyword)
52+
53+
:else
54+
(throw (ex-info "metadata decoding failure" {:feature-row feature-row})))
55+
(catch Exception ex
56+
(warn (ex-message ex))
57+
{}))))
58+
3459
(defn- feature-row->feature
3560
"Flatten an {:fkey :metadata} row into a flat map with expires-at parsed.
3661
3762
Just decodes JSON manually (instead of setting up auto-convert via
3863
next.jdbc) so we don't mess with library consumers. "
39-
[feature-row]
40-
(let [raw-meta (.getValue (:metadata feature-row))
41-
metadata (json/read-str raw-meta :key-fn keyword)
42-
?expires-at (:expires-at metadata)]
43-
(if ?expires-at
44-
(let [expires-at (string->ldt ?expires-at)]
45-
(merge metadata {:fkey (:fkey feature-row)
46-
:expires-at expires-at}))
47-
(merge metadata {:fkey (:fkey feature-row)}))))
64+
([feature-row]
65+
(let [metadata (decode-feature-meta feature-row)
66+
?expires-at (:expires-at metadata)]
67+
(if ?expires-at
68+
(let [expires-at (string->ldt ?expires-at)]
69+
(merge metadata {:fkey (:fkey feature-row)
70+
:expires-at expires-at}))
71+
(merge metadata {:fkey (:fkey feature-row)})))))
4872

4973
(let [opts {:adapter (hugsql.adapter.next-jdbc/hugsql-adapter-next-jdbc)
5074
:quoting :ansi}]

test/hyak2/core_test.cljc

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
[hyak2.memory-store :as memory-store]
1010
[hyak2.postgres-store :as postgres-store]
1111
[hyak2.redis-store :as redis-store]
12-
[hyak2.platform :as hp])
12+
[hyak2.platform :as hp]
13+
[hyak2.test-helpers.psql :as test.psql])
1314
:cljs
1415
(:require
1516
[clojure.core.async :as a]
@@ -38,28 +39,52 @@
3839

3940
(defn run-with-memory-store [test-fn]
4041
(binding [*fstore* (memory-store/create-fstore!)]
41-
(test-fn)))
42+
(testing "[memory-store]:"
43+
(test-fn))))
4244

43-
#?(:clj (defn run-with-redis-store [test-fn]
44-
(let [fstore (redis-store/create-fstore!
45-
"hyak-core2-test"
46-
{:spec {:uri (System/getenv "REDIS_URL")}}
47-
{:clean? true})]
48-
(binding [*fstore* fstore]
49-
(test-fn))
50-
(redis-store/destroy-fstore! fstore)))
45+
#?(:clj (defn run-with-redis-store [test-fn]
46+
(let [fstore (redis-store/create-fstore!
47+
"hyak-core2-test"
48+
{:spec {:uri (System/getenv "REDIS_URL")}}
49+
{:clean? true})]
50+
(binding [*fstore* fstore]
51+
(testing "[redis store]:"
52+
(test-fn)))
53+
(redis-store/destroy-fstore! fstore)))
5154
:cljs (defn run-with-redis-store [_]))
5255

53-
#? (:clj (defn run-with-postgres-store
56+
#?(:clj (defn run-with-bare-postgres-store
57+
;; this datasource isn't set up for JSON serialization/deserialization
58+
;; so the hyak adapter does its own internal serialization
5459
([test-fn]
55-
(run-with-postgres-store {:recreate-tables? true} test-fn))
60+
(run-with-bare-postgres-store {:recreate-tables? true}
61+
test-fn))
5662
([fstore-opts test-fn]
5763
(let [jdbc-url (System/getenv "JDBC_DATABASE_URL")
58-
prefix "hyak_core2_test_"
64+
prefix "hyak_core2_bare_test_"
5965
fstore (postgres-store/create-fstore! prefix jdbc-url fstore-opts)]
60-
(binding [*fstore* fstore]
61-
(test-fn)))))
62-
:cljs (defn run-with-postgres-store ([_]) ([_ _])))
66+
(binding [*fstore* fstore
67+
test.psql/*use-psql-auto-serialization?* false]
68+
(testing "[psql bare store]:"
69+
(test-fn))))))
70+
:cljs (defn run-with-bare-postgres-store ([_]) ([_ _])))
71+
72+
#?(:clj (defn run-with-jsonifying-postgres-store
73+
;; this datasource has been rigged to serialize/deserialize JSON
74+
;; so the hyak adapter can presume it's handled by the datasource
75+
([test-fn]
76+
(run-with-jsonifying-postgres-store {:recreate-tables? true
77+
:disable-serialization? true}
78+
test-fn))
79+
([fstore-opts test-fn]
80+
(let [jdbc-url (System/getenv "JDBC_DATABASE_URL")
81+
prefix "hyak_core2_json_test_"
82+
fstore (postgres-store/create-fstore! prefix jdbc-url fstore-opts)]
83+
(binding [*fstore* fstore
84+
test.psql/*use-psql-auto-serialization?* true]
85+
(testing "[psql json store]:"
86+
(test-fn))))))
87+
:cljs (defn run-with-jsonifying-postgres-store ([_]) ([_ _])))
6388

6489
;; }}}
6590

@@ -81,7 +106,8 @@
81106
(doto add-remove-test
82107
run-with-memory-store
83108
run-with-redis-store
84-
run-with-postgres-store))
109+
run-with-bare-postgres-store
110+
run-with-jsonifying-postgres-store))
85111

86112
(defn boolean-gate-test []
87113
(testing "boolean gates work & enable/disable are idempotent"
@@ -99,10 +125,11 @@
99125
(doto boolean-gate-test
100126
run-with-memory-store
101127
run-with-redis-store
102-
run-with-postgres-store))
128+
run-with-bare-postgres-store
129+
run-with-jsonifying-postgres-store))
103130

104131
(deftest postgres-caching-test
105-
(run-with-postgres-store
132+
(run-with-bare-postgres-store
106133
{:recreate-tables? true :ttl/threshold 50}
107134
(fn []
108135
(testing "postgres can cache feature tests"
@@ -142,7 +169,8 @@
142169
(doto actor-gate-test
143170
run-with-memory-store
144171
run-with-redis-store
145-
run-with-postgres-store))
172+
run-with-bare-postgres-store
173+
run-with-jsonifying-postgres-store))
146174

147175
(defn group-gate-test []
148176
(testing "group gates work & enable/disable/reg/dereg are idempotent"
@@ -170,7 +198,8 @@
170198
(doto group-gate-test
171199
run-with-memory-store
172200
run-with-redis-store
173-
run-with-postgres-store))
201+
run-with-bare-postgres-store
202+
run-with-jsonifying-postgres-store))
174203

175204
(defn epsilon= [eps a b] (< (abs (- a b)) eps))
176205

@@ -194,7 +223,8 @@
194223
(doto percent-of-time-test
195224
run-with-memory-store
196225
run-with-redis-store
197-
(partial run-with-postgres-store {:ttl/threshold 1500})))
226+
(partial run-with-bare-postgres-store {:ttl/threshold 1500})
227+
(partial run-with-jsonifying-postgres-store {:ttl/threshold 1500})))
198228

199229
(defn percent-of-actors-test []
200230
(testing "pct-of-actors gate works, is idempotent"
@@ -217,7 +247,8 @@
217247
(doto percent-of-actors-test
218248
run-with-memory-store
219249
run-with-redis-store
220-
(partial run-with-postgres-store {:ttl/threshold 1500})))
250+
(partial run-with-bare-postgres-store {:ttl/threshold 1500})
251+
(partial run-with-jsonifying-postgres-store {:ttl/threshold 1500})))
221252

222253
(deftest expired-test
223254
(doto
@@ -232,7 +263,8 @@
232263
(is (not (sut/expired? *fstore* fkey now)))))
233264
run-with-memory-store
234265
run-with-redis-store
235-
run-with-postgres-store))
266+
run-with-bare-postgres-store
267+
run-with-jsonifying-postgres-store))
236268

237269
(defn stale-fstore-test []
238270
(testing "an `expires-at` in the past means fstore is `stale?` and noisy"
@@ -264,6 +296,7 @@
264296
(doto stale-fstore-test
265297
run-with-memory-store
266298
run-with-redis-store
267-
run-with-postgres-store))
299+
run-with-bare-postgres-store
300+
run-with-jsonifying-postgres-store))
268301

269302
;; vi:fdm=marker

test/hyak2/test_helpers/psql.clj

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
(ns hyak2.test-helpers.psql
2+
"Deal with auto-serialization tips and tricks from next.jdbc.
3+
4+
See https://cljdoc.org/d/com.github.seancorfield/next.jdbc/1.3.1002/doc/getting-started/tips-tricks#working-with-json-and-jsonb
5+
6+
Problem: this advice proposes extending IPersistentMap, IPersistentVector
7+
and org.postgresql.util.PGobject. This happens at the whole-JVM level until
8+
restart, but I want test cases that exercise both the possibility that the
9+
extension has and hasn't happened.
10+
11+
Solution: set a dynamic var `*use-psql-auto-serialization?*` that we can
12+
flip per-test with `bind` and check it in `x->json` and `json->x` to no-op
13+
when I need to, or handle json (de-)serialization when I don't."
14+
(:require
15+
[jsonista.core :as json]
16+
[next.jdbc.prepare :as prepare]
17+
[next.jdbc.result-set :as rs])
18+
(:import
19+
(java.sql PreparedStatement)
20+
(org.postgresql.util PGobject)))
21+
22+
(def ^:dynamic *use-psql-auto-serialization?* false)
23+
24+
(def x->json (fn [x]
25+
(if *use-psql-auto-serialization?*
26+
(json/write-value-as-string x)
27+
x)))
28+
29+
(def json->x (let [mapper (json/object-mapper {:decode-key-fn keyword})]
30+
(fn [json-str-or-x]
31+
(if *use-psql-auto-serialization?*
32+
(json/read-value json-str-or-x mapper)
33+
json-str-or-x))))
34+
35+
(defn clj->pgobject
36+
"Transforms Clojure data to a PGobject that contains the data as JSON.
37+
PGObject type defaults to `jsonb` but can be changed via metadata key
38+
`:pgtype`"
39+
[x]
40+
(let [pgtype (or (:pgtype (meta x)) "jsonb")]
41+
(doto (PGobject.)
42+
(.setType pgtype)
43+
(.setValue (x->json x)))))
44+
45+
(defn pgobject->clj
46+
"Transform PGobject containing `json` or `jsonb` value to Clojure data."
47+
[^org.postgresql.util.PGobject v]
48+
(let [pg-type (.getType v)
49+
raw-value (.getValue v)
50+
value (if (#{"jsonb" "json"} pg-type)
51+
(json->x raw-value)
52+
raw-value)
53+
value-with-meta (if (instance? clojure.lang.IObj value)
54+
(with-meta value {:pgtype pg-type})
55+
value)]
56+
value-with-meta))
57+
58+
(defn init-json-write-helper []
59+
;; if a SQL parameter is a Clojure hash map or vector, it'll be transformed
60+
;; to a PGobject for JSON/JSONB:
61+
(extend-protocol next.jdbc.prepare/SettableParameter
62+
clojure.lang.IPersistentMap
63+
(set-parameter [m ^PreparedStatement s i]
64+
(.setObject s i (clj->pgobject m)))
65+
66+
clojure.lang.IPersistentVector
67+
(set-parameter [v ^PreparedStatement s i]
68+
(.setObject s i (clj->pgobject v)))))
69+
70+
(defn init-json-read-helper []
71+
;; if a row contains a PGobject then we'll convert them to Clojure data
72+
;; while reading (if column is either "json" or "jsonb" type):
73+
(extend-protocol next.jdbc.result-set/ReadableColumn
74+
org.postgresql.util.PGobject
75+
(read-column-by-label [^org.postgresql.util.PGobject v _]
76+
(pgobject->clj v))
77+
(read-column-by-index [^org.postgresql.util.PGobject v _2 _3]
78+
(pgobject->clj v))))
79+
80+
(defn extend-psql-for-auto-serialization! []
81+
(init-json-read-helper)
82+
(init-json-write-helper))
83+
84+
;; extend the relevant next.jdbc protocols unconditionally in test, and use the
85+
;; *use-psql-auto-serialization?* dynamic var to opt in and out with `(bind [,,,])`
86+
87+
(extend-psql-for-auto-serialization!)

0 commit comments

Comments
 (0)