From 0f09b25a2ec686c82e6b586752b2889e4dd85ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Fri, 7 Aug 2026 00:47:34 +0200 Subject: [PATCH] :sparkles: Add a prepared-statement surface to the graph connection `app.graph.ladybug` could only run Cypher as text. Every value the sync path writes is therefore concatenated into the statement, and nothing can ask the engine whether a statement is even valid without running it. Add the four functions that close both gaps. `prepare-on-connection!` parses and binds without executing. `execute-prepared!` binds a parameter map and runs it. `exec-prepared-on-connection!` prepares every statement in a batch before executing any of them, so a parse or bind failure aborts before the first mutation. `validate-on-connection!` returns `{:ok? :error :read-only?}` instead of raising, which is what a gate wants. `->param-value` is the only `Value` constructor on the write path. It is unconditional: on lbug 0.18.2 an unwrapped parameter does not raise, it SIGSEGVs the JVM inside `lbug_value_clone`. Parameters are scalars only, because the JNI `Value` constructor takes no list or map, so `MAP`, `STRUCT` and `T[]` columns stay literal-rendered and the `:else` branch raises rather than crashing. Two departures from the design, both closing a JNI-handle leak on the error path: `prepare-on-connection!` closes the failed `PreparedStatement` before raising, and `execute-prepared!` closes every `Value` it built, including the ones built before a later parameter was rejected. `as-statement` accepts a bare string, so the sync builders can convert to bound parameters one family at a time rather than in one commit. AI-assisted-by: mixed models --- backend/src/app/graph/ladybug.clj | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj index d9779eecfb..15fa276520 100644 --- a/backend/src/app/graph/ladybug.clj +++ b/backend/src/app/graph/ladybug.clj @@ -18,6 +18,7 @@ com.ladybugdb.Connection com.ladybugdb.Database com.ladybugdb.FlatTuple + com.ladybugdb.PreparedStatement com.ladybugdb.QueryResult com.ladybugdb.Value)) @@ -356,6 +357,116 @@ (assert (sequential? statements) "statements should be a sequential collection") (run-statements! conn statements)) +;; --- prepared statements + +(defn- ->param-value + "Clojure scalar → `Value` for prepared-statement binding. + + Every parameter goes through here, unconditionally: an unwrapped value does + not raise, it SIGSEGVs the JVM in `lbug_value_clone`. Lists and maps are not + supported by the JNI `Value` constructor at all, so `MAP`, `STRUCT` and + `T[]` columns stay literal-rendered (`format-typed-value`) — the `:else` + raise below means a caller tried to bind one." + ^Value [v] + (cond + (nil? v) (Value/createNull) ; no explicit type needed + (uuid? v) (Value. ^Object v) ; native UUID + (string? v) (Value. ^Object v) + (boolean? v) (Value. ^Object v) + (integer? v) (Value. ^Object (long v)) + (number? v) (Value. ^Object (double v)) + (keyword? v) (Value. ^Object (name v)) + + (instance? java.time.Instant v) ; native TIMESTAMP + (Value. ^Object v) + + (instance? java.util.Date v) + (Value. ^Object (.toInstant ^java.util.Date v)) + + :else + (ex/raise :type :internal + :code :ladybug-unsupported-param + :hint (str "cannot bind a " (type v) " as a Ladybug parameter; " + "compound columns must be literal-rendered") + :value v))) + +(defn- as-statement + "Normalize a statement to `{:cypher … :params …}`. + + A bare string binds nothing, so the sync builders can convert to bound + parameters one family at a time." + [stmt] + (if (map? stmt) + (update stmt :params #(or % {})) + {:cypher stmt :params {}})) + +(defn prepare-on-connection! + "Parse and bind `statement` on `conn` without executing it. + + The returned `PreparedStatement` is a JNI resource: the caller closes it." + ^PreparedStatement [^Connection conn statement] + (let [cypher (ensure-semicolon statement) + ps (.prepare conn cypher)] + (when-not (.isSuccess ps) + (let [err (.getErrorMessage ps)] + (.close ps) + (ex/raise :type :internal + :code :ladybug-prepare-failed + :hint (str "Ladybug prepare failed: " err) + :statement cypher + :err err))) + ps)) + +(defn execute-prepared! + "Bind `params` into `ps` and execute it on `conn`. + + `params` keys are parameter names without the `$` (keyword or string); + values are scalars. Every bound `Value` is closed, including the ones built + before a later parameter is rejected." + [^Connection conn ^PreparedStatement ps params] + (let [vmap (java.util.HashMap.)] + (try + (doseq [[k v] params] + (.put vmap (name k) (->param-value v))) + (with-open [^QueryResult result (.execute conn ps vmap)] + (check-success! result "")) + (finally + (run! #(.close ^Value %) (.values vmap)))))) + +(defn exec-prepared-on-connection! + "Prepare all statements, then execute all of them. + + A parse or bind failure in *any* statement aborts the batch before the first + mutation runs — the bind-level batch gate. Statements are + `{:cypher … :params {…}}` maps or bare strings." + [^Connection conn stmts] + (assert (sequential? stmts) "statements should be a sequential collection") + (let [prepared (volatile! [])] + (try + (doseq [stmt stmts] + (let [{:keys [cypher params]} (as-statement stmt)] + (vswap! prepared conj {:ps (prepare-on-connection! conn cypher) + :params params}))) + (doseq [{:keys [ps params]} @prepared] + (execute-prepared! conn ps params)) + (finally + (run! #(.close ^PreparedStatement (:ps %)) @prepared))))) + +(defn validate-on-connection! + "Binder gate: parse and semantic-check `statement` against the live schema, + without executing it. + + Returns `{:ok? … :error … :read-only? …}`. Unlike `prepare-on-connection!` + a failure is a return value rather than a raise: the callers are gates (the + CI binder gate, the console read-only gate) that report it. `:read-only?` is + the engine's own read/write analysis." + [^Connection conn statement] + (with-open [^PreparedStatement ps (.prepare conn (ensure-semicolon statement))] + (let [ok? (.isSuccess ps)] + {:ok? ok? + :error (when-not ok? (.getErrorMessage ps)) + :read-only? (when ok? (.isReadOnly ps))}))) + (defn query-scalar-on-connection! "Execute a query expected to return a single scalar value on `conn`." [^Connection conn statement]