A SQL-first, safe-by-default data access approach for Clojure.
This library provides:
.sql files are the source of truth)next.jdbc as the execution backendAll queries are defined in SQL files.
No DSL or query builder replaces SQL.
Reasoning:
Only a small set of comment-based constructs are supported.
Reasoning:
Reasoning:
Generated queries follow database index structures.
Reasoning:
This library is not intended to replace the JDBC access layer.
Initial implementation assumes next.jdbc as the execution backend.
Reasoning:
SQL files are placed under the logical sql/ base path on the classpath.
In practice, that usually means src/sql/ or resources/sql/.
Recommended layout:
sql/<database>/<schema>/<table>/<function-name>.sql
Example:
src/sql/postgresql/public/users/get-by-id.sql
postgresql as the default database segmentpublic is the default schemaReasoning:
/*$name*/Prepared statement parameter.
?user.profile.statuskeyword, string, symbolSELECT * FROM users WHERE id = /*$id*/1
→
SELECT * FROM users WHERE id = ?
binds:
[123]
DEFAULTWhen a scalar bind variable receives bisql/DEFAULT, it is rendered as SQL DEFAULT instead of ?.
{:status bisql/DEFAULT}
INSERT INTO users (status) VALUES (/*$status*/'active')
→
INSERT INTO users (status) VALUES (DEFAULT)
ALLWhen a scalar bind variable receives bisql/ALL, it is rendered as SQL ALL
instead of ?.
{:limit bisql/ALL}
Example:
SELECT * FROM users LIMIT ALL
Initial implementation notes:
$ bindingsIN /*$ids*/(...)IN)WHERE id IN /*$ids*/(1,2,3)
{:ids [10 20 30]}
→
WHERE id IN (?, ?, ?)
binds:
[10 20 30]
IN (...)Reasoning:
/*%if*/WHERE 1 = 1
/*%if name */
AND name = /*$name*/'foo'
/*%else */
AND status = 'inactive'
/*%end */
xif / elseif / else / end/*%elseif condition => <fragment> *//*%else => <fragment> */nil and false are treated as falsenilelseif uses the first truthy branch after the initial ifelseif uses => <fragment>, that inline fragment becomes the elseif bodyelse uses => <fragment>, that inline fragment becomes the else bodyelseif => <fragment> or else => <fragment> also has block body content before the next directive, the template is rejected as invalidelseif is not allowed after elseAND or OR, that trailing operator is also removedAND or OR and is immediately preceded by WHERE or HAVING, that clause keyword is also removedif and elseifelse does not take an expression/*%for*/UPDATE users
SET
/*%for item in items separating , */
/*!item.name*/column_name = /*$item.value*/'sample'
/*%end */
WHERE id = /*$id*/1
/*%for item in items */ ... /*%end *//*%for item in items separating , */ ... /*%end */item is a loop-local variable nameitem.name, item.value, or user.profile.name are supportedkeyword, string, symbolfor block is immediately followed by AND or OR, that trailing operator should also be removedfor block is not followed by AND or OR and is immediately preceded by WHERE or HAVING, that clause keyword should also be removedseparating, the separator is emitted before the second and subsequent iterations,, AND, or OR are not trimmed from the repeated body anymore; use separating instead when separators are neededfor blocks are not supported in the initial implementationif and for are intended for local SQL assembly, not for turning SQL files into a general-purpose programming language.
ifWHERE / HAVINGSELECT *
FROM users
WHERE
/*%if active */
active = true
/*%end */
SETUPDATE users
SET
/*%if display-name */
display_name = /*$display-name*/'Alice'
/*%else */
display_name = display_name
/*%end */
WHERE id = /*$id*/1
else FragmentSELECT *
FROM users
WHERE
/*%if active */
active = true
/*%else => status = 'inactive' */
/*%end */
elseif BranchSELECT *
FROM users
WHERE
/*%if active */
active = true
/*%elseif pending */
status = 'pending'
/*%else */
status = 'inactive'
/*%end */
elseif FragmentSELECT *
FROM users
WHERE
/*%if active */
active = true
/*%elseif pending => status = 'pending' */
/*%else => status = 'inactive' */
/*%end */
ORDER BY / LIMITSELECT *
FROM users
/*%if sort-by-created-at */
ORDER BY created_at DESC
/*%end */
/*%if limit */
LIMIT /*$limit*/100
/*%end */
forWHERE ConditionsSELECT *
FROM users
WHERE
/*%for item in filters separating AND */
/*!item.column*/column_name = /*$item.value*/'sample'
/*%end */
SET ItemsUPDATE users
SET
/*%for item in items separating , */
/*!item.name*/column_name = /*$item.value*/'sample'
/*%end */
WHERE id = /*$id*/1
INSERT Columns and ValuesINSERT INTO users (
/*%for column in columns separating , */
/*!column.name*/column_name
/*%end */
) VALUES (
/*%for column in columns separating , */
/*$column.value*/'sample'
/*%end */
)
VALUESINSERT INTO users (email, status)
VALUES
/*%for row in rows separating , */
(/*$row.email*/'user@example.com', /*$row.status*/'active')
/*%end */
LIKE is handled via typed values.
WHERE name LIKE /*$name*/'foo%'
{:name (sql/like-prefix "smith")}
→
WHERE name LIKE ?
binds:
["smith%"]
/*^name*/ (Optional)Directly embeds a SQL literal.
WHERE type = /*^type*/'A'
→
WHERE type = 'BOOK'
String values are embedded as single-quoted SQL string literalsString values must not contain 'Reasoning:
/*!name*/ (Advanced)Injects raw SQL fragments as an explicit escape hatch.
ORDER BY /*!order-by*/column_name DESC
/*!name*/ also requires an adjacent sample token, just like /*$name*/ and /*^name*//*!name*/ is not safe by default/*$name*/ or /*^name*/ unless raw SQL injection is truly requiredReasoning:
Declaration comments provide template metadata.
/*:<name>
<edn>
*/
/*:doc */ falls back to a trimmed plain string when EDN parsing fails:metaExample:
/*:doc
Find orders by customer ID.
*/
/*:tags
[:orders :list]
*/
→
{:meta {:doc "Find orders by customer ID."
:tags [:orders :list]}}
/*:name */Defines a template-local query name.
/*:name find-user-by-email */
SELECT * FROM users WHERE email = /*$email*/'user@example.com'
/*:name *//*:name */ for each templateload-query only supports single-template filesload-queries returns templates keyed by query-name:name is used to resolve query-name and is also kept inside returned :meta.sql) or /*:name */ are treated as namespace separatorscore is used by defaultInitial implementation should expose a small public API.
(load-query "postgresql/public/users/get-by-id.sql")
Loads a SQL file from sql/... on the classpath and returns its parsed representation.
(render-query query {:id 1})
Renders template SQL into:
{:sql "SELECT * FROM users WHERE id = ?"
:params [1]}
The initial compiler foundation also exposes:
(def parsed-template
(parse-template "SELECT * FROM users WHERE id = /*$id*/1"))
(renderer-plan parsed-template)
(emit-renderer-form parsed-template)
(compile-renderer parsed-template)
(evaluate-renderer parsed-template {:id 1})
parse-template converts a declaration-free SQL template string into an
parsed template. renderer-plan converts that parsed template into an
execution-oriented plan. emit-renderer-form emits a reusable renderer
function form from that plan. compile-renderer compiles the parsed template
into a reusable renderer function at runtime. evaluate-renderer evaluates the
parsed template directly and returns the same rendered SQL shape as the
internal renderer step:
{:sql "SELECT * FROM users WHERE id = ?"
:bind-params [1]}
The parsed-template layer is the parser output. It annotates nodes with
statement kind (:select, :insert, :update, :delete) and clause-level
context such as :where, :having, :set, :values, :limit, and
:offset. The renderer-plan layer is the execution-oriented intermediate form
used by later code generation and by the interpreter-backed renderer path.
renderer-plan currently has this stable shape:
{:op :renderer-plan
:statement-kind :select
:steps [...]}
The top-level :steps vector contains execution-oriented step maps. The
current step kinds are:
:append-text:append-variable:branch:for-each:append-text carries :sql, :context, and :statement-kind.
:append-variable carries :sigil, :parameter-name, :collection?,
:context, and :statement-kind.
:branch carries :branches, where each branch has:
{:expr "active" ;; or nil for else
:steps [...]}
:for-each carries the loop contract:
{:op :for-each
:item-name "item"
:collection-name "items"
:separator ","
:context :set
:statement-kind :update
:steps [...]}
The exact emitted Clojure form is an implementation detail, but the
parsed-template -> renderer-plan -> renderer-form layering is now the
intended compiler boundary.
On CLJ, compile-renderer still compiles the emitted renderer form with
eval. On CLJS, compile-renderer uses the same renderer-plan as the input
to a small interpreter and returns a reusable renderer function backed by that
interpreter.
The current primary path is emit-renderer-form: defrender and defquery
embed the emitted renderer form at macro expansion time, while
compile-renderer remains as a thin runtime convenience wrapper around eval.
(defrender)
(defrender "admin")
(defrender "/sql/postgresql/public/users/get-by-id.sql")
(defrender "/sql/postgresql/public/users")
defrender defines one rendering function per query found in a SQL file.
With no arguments, it resolves the current namespace to a classpath directory
and loads every .sql file under that directory recursively. When a relative
path is passed, it is resolved under that namespace-derived directory. When a
path starts with /, it is resolved from the classpath root. If a directory is
passed instead of a file, it loads every .sql file under that directory
recursively and defines all queries found there. The current namespace is only
used to discover files. Each discovered SQL file defines its functions into the
namespace derived from that SQL file path.
defrender resolves query-name with this priority:
/*:name */If /*:name */ does not provide a namespace suffix, the file name can still
provide one. The generated var name is always the last segment of the resolved
query-name, while the preceding segments become a namespace suffix under the
SQL file's parent path.
Examples:
sql/postgresql/public/users/get-by-id.sql
-> sql.postgresql.public.users.core/get-by-idsql/postgresql/public/users/hoge.list-order-by-created-at.sql
-> sql.postgresql.public.users.hoge/list-order-by-created-atsql/postgresql/public/users/crud.sql with /*:name crud.get-by-id */
-> sql.postgresql.public.users.crud/get-by-idWhen loading a directory, files are processed recursively in sorted path order. Var name collisions are errors.
defquery is the higher-level facade that defines executable query functions.
By default it delegates to the :next-jdbc adapter implementation.
Execution lives behind adapter namespaces.
Example:
(ns sql.postgresql.public.users.core
(:require [bisql.core :as bisql]
[bisql.adapter.next-jdbc :as bisql.jdbc]))
(bisql/defquery "/sql/postgresql/public/users/get-by-id.sql")
(bisql.jdbc/exec! datasource get-by-id {:id 42})
bisql.adapter.next-jdbc/exec! chooses next.jdbc/execute-one! or next.jdbc/execute!
based on the query function metadata's :cardinality value. When :cardinality is not
specified, it defaults to :many.
This keeps bisql.core focused on loading, analyzing, rendering, and function generation.
(generate-crud datasource {:schema "public"})
Generates query definitions, SQL template files, and per-table query namespace files from PostgreSQL schema metadata.
Example:
(-> (generate-crud datasource {:schema "public"})
(write-crud-files! {:output-root "src/sql"}))
(-> (generate-crud datasource {:schema "public"})
(write-declaration-files! {:output-root "src/sql"}))
Each generated namespace file declares the generated query vars with docstrings derived from the SQL templates:
(ns sql.postgresql.public.users.crud
(declare ^{:arglists '([datasource] [datasource template-params])
:doc "..."}
get-by-id)
The same generation flow can be exposed as a CLI:
clojure -M -m bisql.cli gen-config
clojure -M -m bisql.cli gen-crud --config bisql.edn
clojure -M -m bisql.cli gen-declarations --config bisql.edn
The config file is an EDN map with :db and :generate sections. Generated templates show default values as commented examples. Commands still work without a config file because the precedence order is CLI options > environment variables > config file > defaults.
gen-declarations is an optional helper. It is useful for projects that prefer explicit
namespace files and want IDE/REPL navigation stubs with docstrings, without
letting a shallow (defquery) define functions into namespaces that were not
declared in source ahead of time. By default those docstrings include the
project-relative SQL file path and line number; --include-sql-template can be
used when the SQL template body should also be embedded.
Reasoning:
CRUD functions are generated from database schema:
public(insert! db row)
Generated for:
Uses PostgreSQL INSERT ... ON CONFLICT ON CONSTRAINT ... DO UPDATE RETURNING *.
(upsert-by-id! db row)
Composite key example:
(upsert-by-user-id-and-device-identifier! db row)
Generated upsert queries expect insertion values under :inserting. They may also
accept :non-updating-cols to preserve selected columns from the conflicting row:
(users.crud/upsert-by-id
datasource
{:inserting {:email "alice@example.com"
:display-name "Alice"
:status "active"
:created-at #inst "2026-04-12T00:00:00Z"}
:non-updating-cols {:created-at true}})
This renders INSERT INTO ... AS t and uses t.<column> instead of
EXCLUDED.<column> for columns whose :non-updating-cols.<column> value is truthy,
so the existing value is kept unchanged for those columns.
Generated only for:
(update-by-id! db {:id ...
:set {...}})
Composite key example:
(update-by-account-id-and-user-id! db {:account-id ...
:user-id ...
:set {...}})
Generated only for:
(delete-by-id! db {:id ...})
Composite key example:
(delete-by-account-id-and-user-id! db {:account-id ...
:user-id ...})
get-by-*Generated for:
(get-by-id db {:id ...})
Composite key example:
(get-by-account-id-and-user-id db {:account-id ...
:user-id ...})
list-by-*Generated for:
For index (a, b, c):
Generated prefixes:
(a)(a, b)(a, b, c)Not generated:
(b)(b, c)(a, c)Reasoning:
Remaining index columns are used as ORDER BY.
Example index:
(customer_id, created_at, id)
customer_idWHERE customer_id = ?
ORDER BY created_at, id
customer_id, created_atWHERE customer_id = ?
AND created_at = ?
ORDER BY id
WHERE customer_id = ?
AND created_at = ?
AND id = ?
(no ORDER BY)
Reasoning:
All list-by-* functions require a limit and an offset.
(list-by-customer-id db {:customer-id 10
:limit 100
:offset 0})
offset must be zero or a positive integerReasoning:
Generated names:
list-by-customer-id-and-created-at
Rules:
andReasoning:
The following are intentionally excluded:
>=, <=)Reasoning:
Testing should be separated into layers:
Reasoning:
This system provides:
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |