next-jdbc/src/next/jdbc/result_set.clj

417 lines
16 KiB
Clojure
Raw Normal View History

2019-03-31 23:54:34 +00:00
;; copyright (c) 2018-2019 Sean Corfield, all rights reserved
(ns next.jdbc.result-set
2019-04-18 06:43:32 +00:00
"An implementation of ResultSet handling functions.
Defines the following protocols:
* ReadableColumn -- to read column values by label or index
* RowBuilder -- for materializing a row
* ResultSetBuilder -- for materializing a result set
* DatafiableRow -- for turning a row into something datafiable
Also provides the default implemenations for Executable and
the default datafy/nav behavior for rows from a result set."
2019-03-31 23:54:34 +00:00
(:require [clojure.core.protocols :as core-p]
[next.jdbc.prepare :as prepare]
[next.jdbc.protocols :as p])
(:import (java.sql PreparedStatement
ResultSet ResultSetMetaData
SQLException)))
(set! *warn-on-reflection* true)
2019-04-18 06:43:32 +00:00
(defn get-column-names
"Given ResultSetMetaData, return a vector of column names, each qualified by
the table from which it came."
[^ResultSetMetaData rsmeta opts]
(mapv (fn [^Integer i] (keyword (not-empty (.getTableName rsmeta i))
(.getColumnLabel rsmeta i)))
(range 1 (inc (.getColumnCount rsmeta)))))
2019-04-01 06:17:12 +00:00
(defn get-unqualified-column-names
"Given ResultSetMetaData, return a vector of unqualified column names."
[^ResultSetMetaData rsmeta opts]
(mapv (fn [^Integer i] (keyword (.getColumnLabel rsmeta i)))
(range 1 (inc (.getColumnCount rsmeta)))))
2019-03-31 23:54:34 +00:00
(defprotocol ReadableColumn
"Protocol for reading objects from the java.sql.ResultSet. Default
implementations (for Object and nil) return the argument, and the
Boolean implementation ensures a canonicalized true/false value,
but it can be extended to provide custom behavior for special types."
(read-column-by-label [val label]
"Function for transforming values after reading them via a column label.")
(read-column-by-index [val rsmeta idx]
"Function for transforming values after reading them via a column index."))
(extend-protocol ReadableColumn
Object
(read-column-by-label [x _] x)
(read-column-by-index [x _2 _3] x)
Boolean
(read-column-by-label [x _] (if (= true x) true false))
(read-column-by-index [x _2 _3] (if (= true x) true false))
nil
(read-column-by-label [_1 _2] nil)
(read-column-by-index [_1 _2 _3] nil))
2019-04-02 05:19:02 +00:00
(defprotocol RowBuilder
"Protocol for building rows in various representations:
->row -- called once per row to create the basis of each row
column-count -- return the number of columns in each row
with-column -- called with the row and the index of the column to be added;
this is expected to read the column value from the ResultSet!
row! -- called once per row to finalize each row once it is complete
The default implementation for building hash maps: MapResultSetBuilder"
(->row [_])
(column-count [_])
(with-column [_ row i])
(row! [_ row]))
(defprotocol ResultSetBuilder
"Protocol for building result sets in various representations:
->rs -- called to create the basis of the result set
with-row -- called with the result set and the row to be added
rs! -- called to finalize the result set once it is complete
Default implementations for building vectors of hash maps and vectors
of column names and row values: MapResultSetBuilder & ArrayResultSetBuilder"
(->rs [_])
(with-row [_ rs row])
(rs! [_ rs]))
(defrecord MapResultSetBuilder [^ResultSet rs rsmeta cols]
RowBuilder
(->row [this] (transient {}))
(column-count [this] (count cols))
(with-column [this row i]
(assoc! row
(nth cols (dec i))
(read-column-by-index (.getObject rs ^Integer i) rsmeta i)))
(row! [this row] (persistent! row))
ResultSetBuilder
(->rs [this] (transient []))
(with-row [this mrs row]
(conj! mrs row))
(rs! [this mrs] (persistent! mrs)))
(defn as-maps
"Given a ResultSet and options, return a RowBuilder / ResultSetBuilder
that produces bare vectors of hash map rows."
[^ResultSet rs opts]
(let [rsmeta (.getMetaData rs)
cols (get-column-names rsmeta opts)]
(->MapResultSetBuilder rs rsmeta cols)))
(defn as-unqualified-maps
"Given a ResultSet and options, return a RowBuilder / ResultSetBuilder
that produces bare vectors of hash map rows, with simple keys."
[^ResultSet rs opts]
(let [rsmeta (.getMetaData rs)
cols (get-unqualified-column-names rsmeta opts)]
(->MapResultSetBuilder rs rsmeta cols)))
(defrecord ArrayResultSetBuilder [^ResultSet rs rsmeta cols]
RowBuilder
(->row [this] (transient []))
(column-count [this] (count cols))
(with-column [this row i]
(conj! row (read-column-by-index (.getObject rs ^Integer i) rsmeta i)))
(row! [this row] (persistent! row))
ResultSetBuilder
(->rs [this] (transient [cols]))
(with-row [this ars row]
(conj! ars row))
(rs! [this ars] (persistent! ars)))
(defn as-arrays
2019-04-18 21:09:07 +00:00
"Given a ResultSet and options, return a RowBuilder / ResultSetBuilder
that produces a vector of column names followed by vectors of row values."
[^ResultSet rs opts]
(let [rsmeta (.getMetaData rs)
cols (get-column-names rsmeta opts)]
(->ArrayResultSetBuilder rs rsmeta cols)))
(defn as-unqualified-arrays
2019-04-18 21:09:07 +00:00
"Given a ResultSet and options, return a RowBuilder / ResultSetBuilder
that produces a vector of simple column names followed by vectors of row
values."
[^ResultSet rs opts]
(let [rsmeta (.getMetaData rs)
cols (get-unqualified-column-names rsmeta opts)]
(->ArrayResultSetBuilder rs rsmeta cols)))
(declare navize-row)
(defprotocol DatafiableRow
"Given a connectable object, return a function that knows how to turn a row
into a datafiable object that can be 'nav'igated."
(datafiable-row [this connectable opts]))
(defn- row-builder
"Given a RowBuilder -- a row materialization strategy -- produce a fully
materialized row from it."
[gen]
(->> (reduce (fn [r i] (with-column gen r i))
(->row gen)
(range 1 (inc (column-count gen))))
(row! gen)))
2019-03-31 23:54:34 +00:00
(defn- mapify-result-set
"Given a result set, return an object that wraps the current row as a hash
map. Note that a result set is mutable and the current row will change behind
this wrapper so operations need to be eager (and fairly limited).
Supports ILookup (keywords are treated as strings).
Supports Associative (again, keywords are treated as strings). If you assoc,
a full row will be realized (via `row-builder` above).
2019-03-31 23:54:34 +00:00
Supports Seqable which realizes a full row of the data."
[^ResultSet rs opts]
(let [gen (delay ((get :gen-fn opts as-maps) rs opts))]
2019-03-31 23:54:34 +00:00
(reify
clojure.lang.ILookup
(valAt [this k]
(try
(read-column-by-label (.getObject rs (name k)) (name k))
2019-03-31 23:54:34 +00:00
(catch SQLException _)))
(valAt [this k not-found]
(try
(read-column-by-label (.getObject rs (name k)) (name k))
2019-03-31 23:54:34 +00:00
(catch SQLException _
not-found)))
clojure.lang.Associative
(containsKey [this k]
(try
(.getObject rs (name k))
true
(catch SQLException _
false)))
(entryAt [this k]
(try
(clojure.lang.MapEntry. k (read-column-by-label
(.getObject rs (name k))
(name k)))
2019-03-31 23:54:34 +00:00
(catch SQLException _)))
(assoc [this k v]
(assoc (row-builder @gen) k v))
2019-03-31 23:54:34 +00:00
clojure.lang.Seqable
(seq [this]
(seq (row-builder @gen)))
DatafiableRow
(datafiable-row [this connectable opts]
(with-meta
(row-builder @gen)
{`core-p/datafy (navize-row connectable opts)})))))
(extend-protocol
DatafiableRow
clojure.lang.IObj ; assume we can "navigate" anything that accepts metadata
;; in reality, this is going to be over-optimistic and will like cause `nav`
;; to fail on attempts to navigate into result sets that are not hash maps
(datafiable-row [this connectable opts]
(with-meta this
{`core-p/datafy (navize-row connectable opts)})))
(defn- stmt->result-set
"Given a PreparedStatement and options, execute it and return a ResultSet
if possible."
^ResultSet
[^PreparedStatement stmt opts]
(if (.execute stmt)
(.getResultSet stmt)
(when (:return-keys opts)
(try
(.getGeneratedKeys stmt)
(catch Exception _)))))
2019-03-31 23:54:34 +00:00
(defn- reduce-stmt
2019-04-01 06:17:12 +00:00
"Execute the PreparedStatement, attempt to get either its ResultSet or
its generated keys (as a ResultSet), and reduce that using the supplied
function and initial value.
If the statement yields neither a ResultSet nor generated keys, return
a hash map containing :next.jdbc/update-count and the number of rows
updated, with the supplied function and initial value applied."
2019-03-31 23:54:34 +00:00
[^PreparedStatement stmt f init opts]
(if-let [rs (stmt->result-set stmt opts)]
2019-03-31 23:54:34 +00:00
(let [rs-map (mapify-result-set rs opts)]
(loop [init' init]
(if (.next rs)
(let [result (f init' rs-map)]
(if (reduced? result)
@result
(recur result)))
init')))
(f init {:next.jdbc/update-count (.getUpdateCount stmt)})))
2019-03-31 23:54:34 +00:00
(extend-protocol p/Executable
java.sql.Connection
(-execute [this sql-params opts]
(reify clojure.lang.IReduceInit
(reduce [_ f init]
(with-open [stmt (prepare/create this
(first sql-params)
(rest sql-params)
opts)]
(reduce-stmt stmt f init opts)))))
(-execute-one [this sql-params opts]
(with-open [stmt (prepare/create this
(first sql-params)
(rest sql-params)
opts)]
(if-let [rs (stmt->result-set stmt opts)]
(let [gen-fn (get opts :gen-fn as-maps)
2019-04-18 07:28:23 +00:00
gen (gen-fn rs opts)]
(when (.next rs)
(datafiable-row (row-builder gen) this opts)))
{:next.jdbc/update-count (.getUpdateCount stmt)})))
(-execute-all [this sql-params opts]
(with-open [stmt (prepare/create this
(first sql-params)
(rest sql-params)
opts)]
(if-let [rs (stmt->result-set stmt opts)]
(let [gen-fn (get opts :gen-fn as-maps)
gen (gen-fn rs opts)]
(loop [rs' (->rs gen) more? (.next rs)]
(if more?
(recur (with-row gen rs'
(datafiable-row (row-builder gen) this opts))
(.next rs))
(rs! gen rs'))))
{:next.jdbc/update-count (.getUpdateCount stmt)})))
javax.sql.DataSource
(-execute [this sql-params opts]
(reify clojure.lang.IReduceInit
(reduce [_ f init]
(with-open [con (p/get-connection this opts)]
(with-open [stmt (prepare/create con
2019-04-01 00:30:10 +00:00
(first sql-params)
(rest sql-params)
opts)]
2019-03-31 23:54:34 +00:00
(reduce-stmt stmt f init opts))))))
(-execute-one [this sql-params opts]
(with-open [con (p/get-connection this opts)]
(with-open [stmt (prepare/create con
(first sql-params)
(rest sql-params)
opts)]
(if-let [rs (stmt->result-set stmt opts)]
(when (.next rs)
(datafiable-row (row-builder (as-maps rs opts)) this opts))
{:next.jdbc/update-count (.getUpdateCount stmt)}))))
(-execute-all [this sql-params opts]
(with-open [con (p/get-connection this opts)]
(with-open [stmt (prepare/create con
(first sql-params)
(rest sql-params)
opts)]
(if-let [rs (stmt->result-set stmt opts)]
(let [gen-fn (get opts :gen-fn as-maps)
gen (gen-fn rs opts)]
(loop [rs' (->rs gen) more? (.next rs)]
(if more?
(recur (with-row gen rs'
(datafiable-row (row-builder gen) this opts))
(.next rs))
(rs! gen rs'))))
{:next.jdbc/update-count (.getUpdateCount stmt)}))))
2019-03-31 23:54:34 +00:00
java.sql.PreparedStatement
;; we can't tell if this PreparedStatement will return generated
;; keys so we pass a truthy value to at least attempt it if we
;; do not get a ResultSet back from the execute call
2019-03-31 23:54:34 +00:00
(-execute [this _ opts]
(reify clojure.lang.IReduceInit
(reduce [_ f init]
(reduce-stmt this f init (assoc opts :return-keys true)))))
(-execute-one [this _ opts]
(if-let [rs (stmt->result-set this (assoc opts :return-keys true))]
(when (.next rs)
(datafiable-row (row-builder (as-maps rs opts))
(.getConnection this) opts))
{:next.jdbc/update-count (.getUpdateCount this)}))
(-execute-all [this sql-params opts]
(if-let [rs (stmt->result-set this opts)]
(let [gen-fn (get opts :gen-fn as-maps)
gen (gen-fn rs opts)]
(loop [rs' (->rs gen) more? (.next rs)]
(if more?
(recur (with-row gen rs'
(datafiable-row (row-builder gen)
(.getConnection this) opts))
(.next rs))
(rs! gen rs'))))
{:next.jdbc/update-count (.getUpdateCount this)}))
2019-03-31 23:54:34 +00:00
Object
(-execute [this sql-params opts]
(p/-execute (p/get-datasource this) sql-params opts))
(-execute-one [this sql-params opts]
(p/-execute-one (p/get-datasource this) sql-params opts))
(-execute-all [this sql-params opts]
(p/-execute-all (p/get-datasource this) sql-params opts)))
2019-03-31 23:54:34 +00:00
(defn- default-schema
"The default schema lookup rule for column names.
If a column name ends with _id or id, it is assumed to be a foreign key
into the table identified by the first part of the column name."
[col]
(let [[_ table] (re-find #"(?i)^(.+)_?id$" (name col))]
(when table
[(keyword table) :id])))
(defn- navize-row
2019-04-01 06:17:12 +00:00
"Given a connectable object, return a function that knows how to turn a row
into a navigable object.
A `:schema` option can provide a map from qualified column names
(`:<table>/<column>`) to tuples that indicate for which table they are a
foreign key, the name of the key within that table, and (optionality) the
cardinality of that relationship (`:many`, `:one`).
2019-04-01 06:17:12 +00:00
If no `:schema` item is provided for a column, the convention of <table>id or
2019-04-01 06:17:12 +00:00
<table>_id is used, and the assumption is that such columns are foreign keys
in the <table> portion of their name, the key is called `id`, and the
2019-04-01 06:17:12 +00:00
cardinality is :one.
Rows are looked up using `-execute-all` or `-execute-one` and the `:table-fn`
option, if provided, is applied to both the assumed table name and the
2019-04-01 06:17:12 +00:00
assumed foreign key column name."
2019-03-31 23:54:34 +00:00
[connectable opts]
(fn [row]
(with-meta row
{`core-p/nav (fn [coll k v]
(try
(let [[table fk cardinality] (or (get-in opts [:schema k])
(default-schema k))]
(if fk
(let [entity-fn (:table-fn opts identity)
2019-03-31 23:54:34 +00:00
exec-fn! (if (= :many cardinality)
p/-execute-all
p/-execute-one)]
2019-03-31 23:54:34 +00:00
(exec-fn! connectable
[(str "SELECT * FROM "
(entity-fn (name table))
" WHERE "
(entity-fn (name fk))
" = ?")
v]
opts))
v))
(catch Exception _
;; assume an exception means we just cannot
;; navigate anywhere, so return just the value
2019-03-31 23:54:34 +00:00
v)))})))