SQL Builder (Fennel, SQLite)
sql-builder is the canonical query builder for this repo’s Fennel runtime. It is immutable (builder-style chaining), SQLite-first, and intentionally strict: features that are not valid for SQLite raise explicit errors.
Module
fennel
(local Sql (require :sql-builder))Design
- Immutable builders: every call returns a new query/expression.
- SQLite-first SQL generation.
- Strict unsupported behavior: no silent fallbacks for non-SQLite features.
- Parameterized compilation:
:qmark(default):?+ positional params array:named::p1,:p2, ... + named param table
Query Entry Points
Sql.select ...Sql.from tableSql.with name subquerySql.with-recursive name subquerySql.into tableSql.update tableSql.delete-from tableSql.create-table table-or-nameSql.create-index nameSql.create-view nameSql.drop-table table-or-nameSql.drop-view view-or-nameSql.drop-index index-or-name
Query facade equivalents are available via Sql.Query (from_, with_, with_recursive, into, etc.).
Tables, Fields, Schemas
Sql.table "users"/Sql.Table "users"- Field access:
- dynamic:
users.id - explicit:
(users:field "id")
- dynamic:
- Star:
- dynamic:
users.star - explicit:
(users:star)
- dynamic:
- Alias:
(users:as "u") - Schema/database helpers:
(Sql.Schema "public")(Sql.Database "hospital")thendb.public.patients
Core SELECT
fennel
(local u (Sql.table "users" "u"))
(local q
(-> (Sql.from u)
(:select u.id u.name)
(:where (u.active:eq true))
(:group-by u.id)
(:having (Sql.gt (Sql.call "COUNT" u.id) 1))
(:order-by [u.id :desc])
(:limit 50)
(:offset 0)))Set operations:
:union,:union_all,:intersect,:except,:except_of,:minus
CTEs:
:with name subquery:with_recursive
Joins
Direct joins:
:join target on-expr kind:join-using target ["col"] kind- typed helpers:
:left-join,:left_outer_join,:cross_join, etc.
Joiner chaining (PyPika-style):
(q:join table):on(...)(q:join table):using(...)(q:join table):cross()
Expressions
Comparisons/operators:
eq ne gt gte lt lte like not-like not_like glob regexp- arithmetic:
add sub mul div mod - bitwise/shift:
bitwiseand lshift rshift
Membership/ranges:
in_ not-in not_in notin contains not_containsbetween- term sugar:
:slice low highandSql.slice expr low high(BETWEEN)
Null checks:
is-null is_null isnullis-not-null is_not_null notnull isnotnull
Boolean/unary:
and_ or_ not_negate(alias ofnot_)neg(arithmetic unary minus)
CASE:
(Sql.case):when(cond, value):else_(default)(Sql.case expr):when(value, result):else_(default)
Tuples:
Sql.tuple .../Sql.Tuple ...- works with composite
INexpressions
Functions and Windows
Generic:
Sql.call "COUNT" exprSql.fn "SUM" exprSql.Functions.*dynamic function helpers
Function aliases/chaining:
count-distinctandcount_distinct:distinct:filter:over:orderbyand:order_by:rows/:range:ignore-nullsand:ignore_nulls
Analytics helpers:
Sql.Analytics.row_number,rank,dense_rank,lag,lead, etc.
INSERT / UPSERT / UPDATE / DELETE
INSERT values:
fennel
(-> (Sql.into users)
(:columns "id" "name")
(:values [1 "sam"] [2 "pat"]))INSERT from SELECT:
fennel
(-> (Sql.into archived)
(:columns "id" "name")
(:from_select source-query))Rules:
valuesandfrom_selectare mutually exclusive.
SQLite UPSERT:
:on-conflict .../:on_conflict ...:do-update/:do_update:do-nothing/:do_nothing:conflict-where ...Sql.excluded "col"forexcluded."col"
Replace/ignore:
:replace,:insert_or_replace,:ignore
DML returning:
:returning ...on insert/update/delete
DDL
Create table:
:if-not-exists,:temporary:column,:columns:primary-key,:unique,:check,:foreign-key:without-rowid,:strict:as-select
Create index:
:if-not-exists,:unique,:on,:where
Create view:
:if-not-exists,:temporary,:as
Drop:
drop-table,drop-view,drop-index, plus:if-exists
Compile Output
fennel
(local out (q:to-sql {:param-style :qmark}))
;; out.sql -> SQL string
;; out.params -> positional params array
(local out2 (q:to-sql {:param-style :named}))
;; out2.sql -> SQL with :pN names
;; out2.params -> table {p1=..., p2=...}Helpers:
:to-sql:to-sql-stringSql.compile query opts
Strict SQLite Unsupported Surface
These intentionally raise explicit errors:
- Query methods:
prewherefor-update/for_updateuse-indexforce-indexwith-totalsrolluphash-join/hash_join
- Table temporal methods:
for_for-portion/for_portion
- Create-table extras:
unloggedwith-system-versioning/with_system_versioningperiod-for/period_for
- Term helpers:
from-to/from_toas-of/as_ofall_any_
- Operators not valid in SQLite mode:
ilike,not-ilike- regex
~(regex) rlike
- Query facade:
drop_databasedrop_user
Test Coverage
Primary test module:
assets/lua/tests/test-sql-builder.fnl
Included in fast suite:
assets/lua/tests/fast.fnl
