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
This commit is contained in:
Álvaro Tejero Cantero 2026-08-07 00:47:34 +02:00
parent 47be384bef
commit 0f09b25a2e
No known key found for this signature in database

View File

@ -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 "<prepared>"))
(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]