├── bearsql.png ├── .github └── workflows │ └── test.yml ├── deps.edn ├── LICENSE ├── README.md ├── src └── bearsql │ ├── core.clj │ └── sql.clj └── test └── bearsql └── core_test.clj /bearsql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatut/bearsql/HEAD/bearsql.png -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: setup clojure 9 | uses: DeLaGuardo/setup-clojure@master 10 | with: 11 | cli: 1.11.1.1149 12 | - name: run bearsql unit tests 13 | run: clojure -A:dev:test 14 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}} 3 | :aliases {:dev {:extra-paths ["test"] 4 | :extra-deps {org.hsqldb/hsqldb {:mvn/version "2.7.2"}}} 5 | :test {:extra-deps {com.cognitect/test-runner 6 | {:git/url "https://github.com/cognitect-labs/test-runner.git" 7 | :sha "b6b3193fcc42659d7e46ecd1884a228993441182"}} 8 | :main-opts ["-m" "cognitect.test-runner"]}}} 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tatu Tarvainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BearSQL 2 | 3 | ![test workflow](https://github.com/tatut/bearsql/actions/workflows/test.yml/badge.svg) 4 | 5 | Bare words SQL macro for Clojure. 6 | 7 | ![BearSQL logo](bearsql.png) 8 | 9 | What if you could **just write SQL** as is and still retain safety of parameters. 10 | With BearSQL you can! 11 | 12 | ```clojure 13 | ;; Using ambient dynamic connection 14 | (let [id 1] 15 | (q select name from product where id = @id)) 16 | ;; => [{:product/name "Fine leather jacket"}] 17 | ``` 18 | 19 | BearSQL macros provide a minimal symbols to SQL conversion at compile time 20 | while still allowing you to have runtime input parameters. 21 | 22 | You can also pass in the database by using a configuration map as the first 23 | argument. 24 | 25 | ## Syntax rules 26 | 27 | - Any symbol is converted to SQL as is (with dashes converted to underscores) 28 | - Symbol `as` is special and allows aliasing 29 | - Vectors are turned into comma separated lists (because comma is whitespace in Clojure) except special (see below) 30 | - Lists are recursively converted and surrounded with parenthesis 31 | - Deref (`@form`) escapes back to Clojure code and uses that value as SQL query parameter 32 | - Strings are passed in as SQL-quoted strings 33 | - Any other Clojure value is used as input parameter as is 34 | - Vector that begins with `:raw` is special and can be used to include any raw SQL fragment and parameters into the query 35 | 36 | See unit tests for examples of usage. 37 | -------------------------------------------------------------------------------- /src/bearsql/core.clj: -------------------------------------------------------------------------------- 1 | (ns bearsql.core 2 | "Bear SQL. A bare words SQL library." 3 | (:require [next.jdbc :as jdbc] 4 | [bearsql.sql :as sql])) 5 | 6 | (def ^:dynamic *db* 7 | "The ambient next.jdbc connectable that is used if not explicitly specified.") 8 | 9 | (defmacro q 10 | "Run a bare words SQL query. 11 | The first argument can be an options map that specifies the 12 | database to use with :db and other options to pass to next.jdbc. 13 | 14 | The is built from the rest of the arguments as follows: 15 | - any symbol is stringified 16 | - a vector is turned into a comma separated list 17 | - a list is recursively converted into SQL and surrounded with parenthesis 18 | - @form is a Clojure value that is passed in as a query parameter 19 | - any other value (string, number, so on) is passed in as query parameter 20 | 21 | Symbols are converted to SQL keywords by replacing dashes with underscores. 22 | This can be changed by passing in the :symbol->sql function or binding 23 | bearsql.core/*symbol->sql*. 24 | 25 | Inside a comma separated list, the keyword AS (case insensitive) is 26 | special and combines 3 values as one (eg. foo as bar). 27 | 28 | examples: 29 | (q select * from items where id = @some-id) 30 | 31 | (q select [it.name as item, cat.name as category] 32 | from item it join category cat on it.category-id=cat.id) 33 | 34 | " 35 | {:clj-kondo/ignore [:unresolved-symbol]} 36 | [& opts-and-query] 37 | (let [[opts query] (if (map? (first opts-and-query)) 38 | [(first opts-and-query) (rest opts-and-query)] 39 | [nil opts-and-query])] 40 | `(next.jdbc/execute! 41 | ~(if (contains? opts :db) 42 | (:db opts) 43 | `*db*) 44 | ~(sql/build query true)))) 45 | 46 | 47 | (defmacro q1 48 | "Same as [[q]] but returns a single value as is. 49 | 50 | Example: 51 | (= 1 (q1 select id from items where id = 1)) 52 | " 53 | {:clj-kondo/ignore [:unresolved-symbol]} 54 | [& args] 55 | `(-> (q ~@args) first vals first)) 56 | -------------------------------------------------------------------------------- /src/bearsql/sql.clj: -------------------------------------------------------------------------------- 1 | (ns bearsql.sql 2 | "SQL builder." 3 | (:require [clojure.string :as str] 4 | [clojure.tools.logging :as log])) 5 | 6 | (defn symbol->sql [sym] 7 | (-> sym str (str/replace #"-" "_"))) 8 | 9 | (def config {:symbol->sql symbol->sql 10 | :log-sql-level nil}) 11 | 12 | (defn set-config! 13 | "Set compile time configuration. This must be called 14 | before the [[bearsql.core/q]] macroexpansions happen to 15 | take effect." 16 | [key value] 17 | (alter-var-root #'config #(assoc % key value)) 18 | :ok) 19 | 20 | (declare build) 21 | 22 | (defn- combine [sep parts] 23 | (reduce (fn [[combined-sql combined-params] 24 | [item-sql & item-params]] 25 | [(str combined-sql (when (some? combined-sql) sep) 26 | item-sql) 27 | (into combined-params item-params)]) 28 | [nil []] 29 | (map #(build [%]) parts))) 30 | 31 | (defn- combine-as [items] 32 | (loop [out [] 33 | items items] 34 | (if (empty? items) 35 | out 36 | (let [[item & items] items 37 | next (first items)] 38 | (if (= "AS" (some-> next str str/upper-case)) 39 | (let [[alias & items] (rest items)] 40 | (recur (conj out [::as item alias]) 41 | items)) 42 | (recur (conj out item) items)))))) 43 | 44 | (defn build 45 | "Build SQL. Returns [sql-string & parameters]." 46 | ([parts] (build parts false)) 47 | ([parts toplevel?] 48 | (let [{:keys [symbol->sql log-sql-level]} config] 49 | (loop [sql nil 50 | params [] 51 | parts (combine-as parts)] 52 | (if (empty? parts) 53 | (let [sql-and-params 54 | (into [(str/replace sql #" " " ")] params)] 55 | (when (and toplevel? log-sql-level) 56 | (log/log log-sql-level sql-and-params)) 57 | sql-and-params) 58 | (let [[p & parts] parts 59 | more-sql (fn [thing] 60 | (str sql (when (some? sql) " ") thing))] 61 | (cond 62 | (symbol? p) 63 | (recur (more-sql (symbol->sql p)) params parts) 64 | 65 | (and (vector? p) (= :raw (first p))) 66 | (let [[_ sql* & params*] p] 67 | (recur (more-sql sql*) 68 | (into params params*) 69 | parts)) 70 | 71 | (and (vector? p) (= ::as (first p))) 72 | (let [[sql* params*] (combine " AS " (rest p))] 73 | (recur (more-sql sql*) 74 | (into params params*) 75 | parts)) 76 | 77 | (vector? p) 78 | (let [[sql* params*] (combine ", " (combine-as p))] 79 | (recur (more-sql sql*) 80 | (into params params*) 81 | parts)) 82 | 83 | ;; @form, pass the form into query parameters 84 | (and (seq? p) (= 'clojure.core/deref (first p))) 85 | (recur (more-sql "?") 86 | (conj params (second p)) 87 | parts) 88 | 89 | (list? p) 90 | (let [[sql* & params*] (build p)] 91 | (recur (more-sql (str "(" sql* ")")) 92 | (into params params*) 93 | parts)) 94 | 95 | (string? p) 96 | (recur (more-sql (str "'" (str/replace p #"'" "''") "'")) 97 | params parts) 98 | 99 | ;; Any other Clojure value, pass as is into query parameters 100 | :else 101 | (recur (more-sql "?") 102 | (conj params p) 103 | parts)))))))) 104 | -------------------------------------------------------------------------------- /test/bearsql/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns bearsql.core-test 2 | "Basic test suite for bearsql" 3 | (:require [next.jdbc :as jdbc] 4 | [bearsql.core :refer [q q1]] 5 | [clojure.test :refer [use-fixtures testing deftest is]]) 6 | (:import (java.sql DriverManager))) 7 | 8 | (bearsql.sql/set-config! :log-sql-level :info) 9 | 10 | (def ddl 11 | ["drop schema public cascade" 12 | "create table category ( 13 | id integer identity primary key, 14 | name varchar(255))" 15 | 16 | "create table manufacturer ( 17 | id integer identity primary key, 18 | name varchar (128))" 19 | 20 | "create table product ( 21 | id integer identity primary key, 22 | name varchar(255), 23 | description varchar(255), 24 | category_id integer foreign key references category (id), 25 | manufacturer_id integer foreign key references manufacturer (id), 26 | price numeric 27 | )" 28 | 29 | "insert into category (name) values (('widgets'), ('clothing'), ('toys'), ('food'))" 30 | "insert into manufacturer (name) values ( 31 | ('Acme Inc'), 32 | ('Blammo Toy Company'), 33 | ('Threepwood Pirate Clothing Inc'), 34 | ('Transgalactic Tools Ltd'), 35 | ('Omni Consumer Products'))" 36 | 37 | "insert into product (name, description, price, category_id, manufacturer_id) values ( 38 | ('Acme earthquake pills', 'Why wait? Make your own earthquakes! Loads of fun.', 14.99, 3, 0), 39 | ('Fine leather jacket', 'I''m selling these fine leather jackets', 150, 1, 2), 40 | ('Log from Blammo!', 'It''s log, log, it''s big, it''s heavy, it''s wood. It''s log, log, it''s better than bad, it''s good.', 24.95, 2, 1), 41 | ('Illudium Q-36 explosive space modulator', 'Planets obstructing YOUR view of Venus? Destroy them with the new explosive space modulator!', 49.99, 0, 0), 42 | ('Blue pants', 'Need a generic pair of blue pants? We got you covered.', 70, 1, 3), 43 | ('Powerthirst!', 'Feel uncomfortably energetic! Made from real lightning!', 19.95, 3, 4), 44 | ('Tornado kit', 'Create your own tornado with this easy kit.', 17.50, 0, 0), 45 | ('Boots of escaping', 'In a jam? Wear these to get out of anything.', 999.95, 1, 2))" 46 | ]) 47 | 48 | (defn init-db [c] 49 | (doseq [sql ddl] 50 | (next.jdbc/execute! c [sql]))) 51 | 52 | (def db 53 | (do 54 | (Class/forName "org.hsqldb.jdbc.JDBCDriver") 55 | (doto (DriverManager/getConnection "jdbc:hsqldb:mem:bearsqltest" "SA" "") 56 | (init-db)))) 57 | 58 | (use-fixtures :each #(binding [bearsql.core/*db* db] (%))) 59 | 60 | (deftest simple-select 61 | (is (= [#:CATEGORY{:ID 0 :NAME "widgets"} 62 | #:CATEGORY{:ID 1 :NAME "clothing"} 63 | #:CATEGORY{:ID 2 :NAME "toys"} 64 | #:CATEGORY{:ID 3 :NAME "food"}] 65 | (q select * from category))) 66 | 67 | (is (= [#:CATEGORY{:NAME "widgets"}] 68 | (q select name from category where id < 1))) 69 | 70 | (let [match (str "%o%")] 71 | (is (= #{"clothing" "toys" "food"} 72 | (into #{} (map :CATEGORY/NAME) 73 | (q select name from category where name like @match))))) 74 | 75 | (is (= [{:PRODUCT/PRICE 17M, 76 | :PRODUCT/MANUFACTURER_ID 0, 77 | :MANUFACTURER/ID 0, 78 | :MANUFACTURER/NAME "Acme Inc", 79 | :PRODUCT/ID 6, 80 | :PRODUCT/NAME "Tornado kit", 81 | :PRODUCT/CATEGORY_ID 0, 82 | :CATEGORY/ID 0, 83 | :PRODUCT/DESCRIPTION "Create your own tornado with this easy kit.", 84 | :CATEGORY/NAME "widgets"}] 85 | (q select * from product p 86 | join manufacturer m on p.manufacturer-id = m.id 87 | join category c on p.category-id = c.id 88 | where p.id = 6)))) 89 | 90 | (deftest single-value 91 | (is (= "widgets" (q1 select name from category where id = 0)))) 92 | 93 | (deftest as-alias 94 | (is (= [{:CATEGORY/CATNAME "clothing" 95 | :PRODUCT/PRODNAME "Fine leather jacket"}] 96 | (q select [c.name as catname 97 | p.name as prodname] 98 | from product p join category c on p.category-id = c.id 99 | where p.id = 1)))) 100 | 101 | (deftest multiple-from 102 | (is (= [{:CATEGORY/NAME "widgets" :PRODUCT/NAME "Illudium Q-36 explosive space modulator"}] 103 | (q select [c.name p.name] 104 | from [product as p, category as c] 105 | where p.category-id = c.id 106 | and p.id = 3)))) 107 | 108 | (deftest calling-function 109 | (is (= [{:PRODUCTS "Fine leather jacket, Boots of escaping, Blue pants"}] 110 | (q select group-concat (distinct p.name order by p.name desc separator ", ") as products 111 | from product p 112 | where p.category-id = 1)))) 113 | 114 | (deftest raw-part 115 | (is (= ["Acme earthquake pills" 116 | "Log from Blammo!" 117 | "Powerthirst!"] 118 | (mapv :PRODUCT/N 119 | (q select [:raw "name as n"] 120 | from product p 121 | where [:raw "p.category_id in (?,?)" 2 3] 122 | order by n asc))))) 123 | --------------------------------------------------------------------------------