📜 ⬆️ ⬇️

We write your DSL on Clojure to work with the database


Let's write a Clojure library for working with relational databases. At the same time we will practice writing macros, try using protocols and multi methods. After all, there is no better way to learn a language than to write something on it. Well ... or read, as someone else wrote.

What for?
At one time I needed such a library for personal needs. At that time there were two common solutions - ClojureQL and Korma. For one reason or another, I did not like them (yes, a fatal flaw), it was decided to make my own bike. The bike is quite working, I am satisfied. Of the external differences - higher extensibility, the emphasis was on the ease of adding new operators and functions, it was important to support subqueries and inserts from bare SQL.

The article describes the general structure of this bike, albeit in a somewhat simplified form. There are no some features, the code was usually developed in a different sequence, many intermediate API options were left behind. But, in general, the main architectural ideas are described accurately. I hope someone article will be useful.

So what are we writing?


We create DSL ( not ORM). Unlike ORM:
- no "smart objects" - only functions and embedded data structures (including records);
- no "lazy" connections, magical loading of records and other "nastiness";
- we want to clearly control the time of occurrence of side effects - they should occur only in strictly expected places.

Add a few more requirements:
- similar to SQL;
- transparency, the less “magic” - the better;
- no validation of queries, schemas and the like - leave the database on the conscience;
- automatic quoting of identifiers and arguments;
- ensuring independence from a specific base (but without fanaticism);
- sometimes we need to write DB-dependent code (stored, triggers, etc.).
')
What is meant by "little magic"? Roughly speaking, such a library should not be engaged in query optimization, and any such activity. Theoretically, this, perhaps, will allow some (well, a little bit) to unload the database, in practice, things usually get worse. After all, only the database and the programmer (occasionally) have sufficient knowledge to properly perform the necessary optimization. The bad option: the developer writes the request, then very carefully examines the application logs in order to find out which SQL is actually sent to the database. It is necessary to re-check the logs regularly, because after the new release the library may suddenly become “smarter”!

So, our DSL will be rather silly transparent by possibilities - we work semantically with bare SQL, but use Clojure's syntax. Something like writing SQL through S-expressions, taking into account the specifics of our language. Everything will look like this:

(select (fields [:name :role_name]) (from :Users) (join-inner :Roles (== :Users.role_id :Roles.id)) (where (= :Roles.name "admin")) (order :Users.name) (limit 100)) 


For this code you will need to generate a query:

 SELECT `name`, `role_name` FROM `Users` INNER JOIN `Roles` ON `Users`.`role_id` = `Roles`.`id` WHERE `Roles`.`name` = ? ORDER BY `Users`.`name` LIMIT ? 


We screen all names with backquotes. In an amicable way, you should use double quotes (and even better to take into account the type of database), but for readability purposes, we will use MySQL-style in the examples. Constants are replaced by ? - The jdbc-driver of a specific database will itself deal with the transmission and shielding of parameters.

Directly performing SELECT queries, we will deal with separate functions: fetch-all , fetch-one , with-fetch . All of them accept as input the connection to the database and the request that we want to perform:

 (def db {:classname "com.mysql.jdbc.Driver" :subprotocol "mysql" :user "test" :password "test" :subname "//localhost/test"}) ;   1    Users (fetch-one db (select (from :Users) (where (== :id 123)))) ;        (fetch-all db (select (from :Users))) (with-fetch db [rows (select (from :Users))] ;     **  `rows` (doseq [r rows] (print ">" r))) 


We generate SQL


To begin with we will be defined how we will store requests in our library.

 (def raw-select ['SELECT :name :role_name ['FROM :Users ['JOIN :Roles ['ON :Users.role_id :Roles_id]]] ['WHERE ['LIKE :Users.name "And%"] ['LIMIT 100]]]) 


In this example, we have a tree of vectors, symbols, and keys. Using symbols, we denote SQL keywords, with keys — table and field names, values ​​(strings, numbers, dates, etc.) are left as is. We represent the compiled query as a pair: SQL code (string) and vector of arguments. We get a separate type:

 (defrecord Sql [sql args]) ;;      : (Sql. "SELECT * FROM `Users` WHERE `id` = ?" [123]) 


This is the bottom view in our library. We implement the conversion from one view to another. To do this, you need a universal way to convert any entity into a Sql record. Protocols are perfect:

 (defprotocol SqlLike (as-sql [this])) ;   (defn quote-name [s] (let [x (name s)] (if (= "*" x) x (str \` x \`)))) (extend-protocol SqlLike ;   `x` (= (as-sql (as-sql x)) (as-sql x)) Sql (as-sql [this] this) ;         Object (as-sql [this] (Sql. "?" [this])) ;      clojure.lang.Keyword (as-sql [this] (Sql. (quote-name this) nil)) ;     SQL clojure.lang.Symbol (as-sql [this] (Sql. (name this) nil)) ;  nil    nil (as-sql [this] (Sql. "NULL" nil))) 


Instead of protocols, one could use a set of if , or even pattern matching. But protocols have an indisputable plus: library users can themselves implement a specific conversion for any type. For example, someone might want to automagically extract values ​​from links:

 (extend-protocol SqlLike clojure.lang.ARef (as-sql [this] (as-sql @this))) ;      ;  (ref, agent, var, atom) (def a (atom 123)) (assert (= (as-sql a) (as-sql @a) (Sql. "?" [123]))) 


We implement our protocol for vectors and lists:

 ;  ,  2 sql-   (defn- join-sqls ([] (Sql. "" nil)) ([^Sql s1 ^Sql s2] (Sql. (str (.sql s1) " " (.sql s2)) (concat (.args s1) (.args s2))))) (extend-protocol SqlLike clojure.lang.Sequential (as-sql [this] (reduce join-sqls (map as-sql this)))) 


With the efficiency of the algorithm here is not very good, you can write the code and quickly. But now:

 (as-sql ['SELECT '* ['FROM :Users] ['WHERE :id '= 1 'AND :name 'IS 'NOT nil]]) ; => #user.Sql{:sql "SELECT * FROM `Users` WHERE `id` = ? AND `name` IS NOT NULL" :args (1)} 


Fine! Let's define a couple more functions ...

 (require '[clojure.java.jdbc :as jdbc]) (defn- to-sql-params [relation] (let [{s :sql p :args} (as-sql relation)] (vec (cons sp)))) (defn fetch-all [db relation] (jdbc/query db (to-sql-params relation) :result-set-fn vec)) ;   `fetch-one` 


Working directly with JDBC is tedious, so tricky - all the dirty work for us is done by clojure.java.jdbc . Finally, we already have quite acceptable results, you can even use the library:

 ;     (def db {:classname "com.mysql.jdbc.Driver" :subprotocol "mysql" :user "test" :password "test" :subname "//localhost/test"}) ;     (fetch-all db (as-sql '[SELECT * FROM :users ORDER BY :name])) 


Oh yeah, we forgot about with-fetch . We realize:

 (defmacro with-fetch [db [v rel :as vr] & body] `(let [params# (to-sql-params ~rel) rsf# (fn [~v] ~@body)] (jdbc/query ~db params# :result-set-fn rsf# ;  RS    rsf# :row-fn identity))) ;     


Increasing queries iteratively


The selected view has serious drawbacks - it is difficult to increase queries iteratively . Suppose we have a tree for the SELECT FROM `Users` LIMIT 10 query, and we want to add a WHERE section to it. In general, for this, you will have to parse the SQL syntax (analyze the AST-tree), which, in truth, would like to avoid.

Why do we need to "iteratively increase" requests? Well, firstly, this is a useful option in itself. When writing a program, we often do not know in advance what requests we will carry out. Example: dynamically build arbitrary conditions for the WHERE and ORDER BY sections in the admin panel.

But more importantly, this is a good practice when writing Clojure programs. We break work into many small pieces that do their work iteratively. Each such brick (pure function) accepts data to the input and returns a “corrected” result. Bricks are easy to test and develop. And in the end such pieces easily come together.

Requests are presented in the form of a hash table. Example:

 (def some-query-example { ;  "  -  " :tables {:r :Roles, :u :Users}, ;  [ ,  ,    ON] ;   -- [ , nil, nil] ;  , ..    join' :joins [[:u nil nil] [:r :inner ['= :Users.role_id :Roles.id]]] ; ast-  :where [:= :u.name "Ivan"], ;  "  -  " :fields {:name :name, :role_name :role_name}, ;   :offset 1000, :limit 100, ; order, group, having, etc... }) 


For sections WHERE , ORDER BY , etc. we store the AST expression tree - it's easier. For the list of tables and fields, we store dictionaries, keys - the names of aliases, values ​​- expressions or table names. Within the framework of such a structure, we implement the necessary functions:

 ;  `limit` & `offset`  (defn limit [relation v] (assoc relation :limit v)) ; **     (defn fields [query fd] (assoc query :fields fd)) (defn where [query wh] (assoc query :where wh)) ; helper- (defn join* [{:keys [tables joins] :as q} type alias table on] (let [a (or alias table)] (assoc q :tables (assoc tables a table) :joins (conj (or joins []) [a type on])))) (defn from ([q table] (join* q nil table table nil)) ([q table alias] (join* q nil table alias nil))) (defn join-cross ([q table] (join* q :cross table table nil)) ([q table alias] (join* q :cross table alias nil))) ;;   join- (left, right, full)   -   


So, we have many functions ( where , fields , from , join , limit and others) that can "tweak" queries. The starting point is an empty request.

 (def empty-select {}) 


Now we can write:

 (-> empty-select (fields [:name :role_name]) (from :Users) (limit 100)) 


This code uses the macro -> , which unfolds into something like:

 (limit (from (fields empty-select [:name :role_name]) :Users) 100) 


For beauty, we define our select macro, which behaves like -> :

 (defmacro select [& body] `(-> empty-select ~@body)) 


It remains to teach our library to convert one representation into another.

 ;  SQL,  nil   "NULL" (def NONE (Sql. "" nil)) ;     (defn render-limit [s] (if-let [l (:limit s)] ['LIMIT l] NONE)) (defn render-fields [s] '*) ;      ;      (defn render-where [s] NONE) (defn render-order [s] NONE) (defn render-expression [s] NONE) ;       (defn render-group [s] NONE) (defn render-having [s] NONE) (defn render-offset [s] NONE) ;   (defn render-table [[alias table]] (if (= alias table) ;     ,    'AS' table [table 'AS alias])) (defn render-join-type [jt] (get {nil (symbol ",") :cross '[CROSS JOIN], :left '[LEFT OUTER JOIN], :right '[RIGHT OUTER JOIN], :inner '[INNER JOIN], :full '[FULL JOIN], } jt jt)) ;     (defn render-from [{:keys [tables joins]}] ;  FROM    ! (if (not (empty? joins)) ['FROM ;   (let [[a jn] (first joins) t (tables a)] ;       `(from ..)` (assert (nil? jn)) (render-table [at])) ;    (for [[a jn c] (rest joins) :let [t (tables a)]] [(render-join-type jn) ;  JOIN XX   (render-table [at]) ;     (if c ['ON (render-expression c)] NONE) ;  'ON' ])] NONE)) (defn render-select [select] ['SELECT (mapv #(% select) [render-fields render-from render-where render-group render-having render-order render-limit render-offset])]) 


Library users may not know about the SqlLike protocol and the as-sql function at all. Good practice. For comparison, in Java, interfaces often define a module / library API. In Clojure, protocols are usually created for the lowest-level operations, some kind of basis, on which a set of helper functions are already working. And now these helper-s provide a public library API. We try to generate a simple query:

 (fetch-all db (render-select (select (from :Users) (limit 10))) 


Done! It's true that manually calling a render-select tedious. We fix:

 (declare render-select) ;      ; record   ,      (defrecord Select [fields where order joins tables offet limit] SqlLike (as-sql [this] (as-sql (render-select this)))) (def empty-select (map->Select {})) 


Now, when running (as-sql (select ...)) , the render-select will be automatically called:

 (fetch-all db (select (from :Users) (limit 10))) ;      SQL    (as-sql (select (from :Table) (limit 10))) ;    (select (from :Table) (limit 10) (as-sql)) 


Expression support


Let's start writing the where function. We want to be able to use it like this:

 (select (from :Table) (where (and (> :x 1) (== :y "z")))) 


Obviously, it is impossible to calculate (> :x 1) at the time of the where call - a macro is needed. The constructed expression will be stored in the form of an AST-tree: nodes - operators, leaves - constants and fields. First, we write a helper function where* :

 ;  2     AND (defn- conj-expression [e1 e2] (cond (not (seq e1)) e2 (= 'and (first e1)) (conj (vec e1) e2) :else (vector 'and e1 e2))) (conj-expression '[> 1 2] '[< "a" "b"]) ; => '[and [> 1 2] [< "a" "b"]]) (conj-expression '[and [> 1 [+ 2 3]] [= :x :y]] '[<> "a" "b"]) ; => '[and [> 1 [+ 2 3]] [= :x :y] [<> "a" "b"]] (defn where* [query expr] (assoc query :where (conj-expression (:where query) expr))) 


Now it's time for a render-where :

 ;   (declare render-operator) (declare render-expression) ;   ? (defn- function-symbol? [s] (re-matches #"\w+" (name s))) ;      (defn render-operator [op & args] (let [ra (map render-expression args) lb (symbol "(") rb (symbol ")")] (if (function-symbol? op) ;  (count, max, ...) [op lb (interpose (symbol ",") ra) rb] ;  (+, *, ...) [lb (interpose op (map render-expression args)) rb]))) (defn render-expression [etree] (if (and (sequential? etree) (symbol? (first etree))) (apply render-operator etree) etree)) (defn render-where [{:keys [where]}] (if where ['WHERE (render-expression where)] NONE)) 


Great, now we can write the simplest expressions:

 (select (from :Users) (where* ['= :id 1]) (as-sql)) ; => (Sql. "SELECT * FROM `Users` WHERE ( `id` = ? )" [1]) 


It turned out ugly, fix it with a simple macro:

 (defn prepare-expression [e] (if (seq? e) `(vector (quote ~(first e)) ~@(map prepare-expression (rest e))) e)) (defmacro where [q body] `(where* ~q ~(prepare-expression body))) 


Replace all sequences (lists) with vectors. The remaining values ​​are left as is. We missed an important point - some operators in Clojure and SQL are called differently, for example <> and not= . A philosophical question, which option is better to use. On the one hand, we decided to leave the library as “stupid” as possible, on the other, it is much more pleasant to see the functions “native” for Clojure. Let's allow both options:

 (defn- canonize-operator-symbol [op] (get '{not= <>, == =} op op)) ;   (defn prepare-expression [e] (if (seq? e) `(vector (quote ~(canonize-operator-symbol (first e))) ~@(map prepare-expression (rest e))) e)) 


Well, when using the where macro, you can write both options, but inside our query view there will be only one. What you need. We have a small favor - joins do not work.

 (defmacro join-left ([q table cond] `(let [t# ~table] (join-left ~qt# t# ~cond))) ([q table alias cond] (join* ~q :cross ~table ~alias ~(prepare-expression cond)))) ;    ,   ... 


To write several identical macros is an ignoble thing:

 ;     `do-template` (use 'clojure.template) ;     5   (do-template [join-name join-key] ;    ;   (defmacro join-name ([relation alias table cond] `(join* ~relation ~join-key ~alias ~table ~(prepare-expression cond))) ([relation table cond] `(let [table# ~table] (join* ~relation ~join-key nil table# ~(prepare-expression cond))))) ;    join-inner :inner, join :inner, join-right :right, join-left :left, join-full :full) 


More possibilities


While we are able to carry out only the elementary requests. Let's support expressions in the enumeration of columns.

 ;  `f`   `m` ( ) (defn- map-vals [fm] (into (if (map? m) (empty m) {}) (for [[kv] m] [k (fv)]))) ;      (def surrogate-alias-counter (atom 0)) ;    :__00001234 (defn generate-surrogate-alias [] (let [k (swap! surrogate-alias-counter #(-> % inc (mod 1000000)))] (keyword (format "__%08d" k)))) ;     "" (defn as-alias [n] (cond (keyword? n) n ;  /    (string? n) (keyword n) ;    :else (generate-surrogate-alias))) ;      ;     --  " - "    (defn- prepare-fields [fs] (if (map? fs) (map-vals prepare-expression fs) (into {} (map (juxt as-alias prepare-expression) fs)))) (defn fields* [query fd] (assoc query :fields fd)) (defmacro fields [query fd] `(fields* ~query ~(prepare-fields fd))) (defn render-field [[alias nm]] (if (= alias nm) nm ;    [(render-expression nm) 'AS alias])) (defn render-fields [{:keys [fields]}] (if (or (nil? fields) (= fields :*)) '* (interpose (symbol ",") (map render-field fields)))) 


Not bad. Now you can write like this:

 (select (fields {:n :name, :a :age}) ;    (from :users)) ;   (select (fields {:cnt (count :*), :max-age (max :age)}) (from :users)) ;    (select (fields [(count :*)]) (from :users)) 


We add sorting. Already the usual order of actions: create the function order* and the macro order , implement the render-order :

 (defn order* ([relation column] (order* relation column nil)) ([{order :order :as relation} column dir] (assoc relation :order (cons [column dir] order)))) (defmacro order ([relation column] `(order* ~relation ~(prepare-expression column))) ([relation column dir] `(order* ~relation ~(prepare-expression column) ~dir))) (defn render-order [{order :order}] (let [f (fn [[cd]] [(render-expression c) (get {nil [] :asc 'ASC :desc 'DESC} dd)])] (if order ['[ORDER BY] (interpose (symbol ",") (map f order))] []))) 


Now it is possible to sort the sample in our queries, including by an arbitrary expression:

 (select (from :User) (order (+ :message_cnt :post_cnt))) 


In a similar way, we can add support for groupings, subqueries, and the like ... So, for example, the implementation for UNION ALL might look like

 ;  - (defn render-union-all [{ss :selects}] (interpose ['UNION 'ALL] (map render-select ss))) ;  ,      (defrecord UnionAll [selects] SqlLike (as-sql [this] (as-sql (render-union-all this)))) ;   **   - (defn union-all [& ss] (->UnionAll ss)) ;;  ... (as-sql (union-all (select (from :Users) (fields [:email])) (select (from :Accounts) (fields [:email])))) 


Multiple DB Support - Dialects


Add support for multiple databases. The idea is simple: a number of functions in our library can change their behavior depending on which base we use. Organize a tree hierarchy of dialects:

 ;    (def ^:const default-dialect ::sql92) ;           (def ^:dynamic *dialect* nil) ;     (def dialects-hierarchy (make-hierarchy)) ; ,      (defn register-dialect ([dialect parent] (alter-var-root #'dialects-hierarchy derive dialect parent)) ;      ::sql92 ([dialect] (register-dialect dialect default-dialect))) ;  (register-dialect ::pgsql) (register-dialect ::pgsql92 ::pgsql) ; postgresql     ;   ad-hoc     (register-dialect ::my-custom-db-with-extra-functions ::pgsql92) 


Now we define a small defndialect macro:

 ;     ;    (defn current-dialect [& _] (or *dialect* default-dialect)) ;    ""  (defmacro defndialect [name & args-and-body] `(do ;   (defmulti ~name current-dialect :hierarchy #'dialects-hierarchy) ;    `sql92` (defmethod ~name default-dialect ~@args-and-body))) 


Now you need to remember to add the dialect value to the *dialect* variable:

 (defmacro with-db-dialect [db & body] ;         `(binding [*dialect* (:dialect ~db)] ~@body)) 


Fine. The last step remains: we rewrite all definitions of functions for rendering, replacing defn with defndialect . The body of the functions do not need to be changed. And now we have the opportunity to generate different SQL depending on the database:

 (defndialect quote-name [s] (let [x (name s)] (if (= "*" x) x (str "\"" x "\"")))) ; MySQL    (defmethod quote-name ::mysql [s] (let [x (name s)] (if (= "*" x) x (str "`" x "`")))) 


Finally, we note that there is no need to call the with-db-dialect manually, you can rewrite our functions fetch-* :

 (defn fetch-all [db relation] (jdbc/query db (with-db-dialect db (to-sql-params relation)) :result-set-fn vec)) ;     fetch-* 


RAW requests


Sometimes you need to use too specific queries - they are easier to write as a string, bypassing the DSL. No problem:

 (require '[clojure.string :as s]) (defn format-sql [raw-sql args] (let [;     :x al (map (comp keyword second) (re-seq #":([\w.-]+)" raw-sql)) ;     "?" pq (s/replace raw-sql #":[\w.-]+" "?")] (->Sql pq (map args al)))) ; ... (fetch-all db (format-sql "SELECT * FROM Users WHERE role = :rl AND age < :age" {:rl "admin" :age 18})) 


By the way, queries generated in this way can be used in UNION ALL , which we implemented a little higher. Unfortunately, to incrementally change them does not work - for this would have to parse the string with the SQL-code. The workaround is subqueries:

 (defn users-by-role [r] (format-sql "SELECT * FROM Users WHERE role = :r" {:rr})) ;    (-> (users-by-role "ADMIN") (order :name) (as-sql)) ;    ..? (select (from :x (users-by-role "ADMIN")) (order :name) (as-sql)) ; => #user.Sql{:sql "SELECT * FROM SELECT * FROM Users WHERE role = ? AS `x` ORDER BY `name`", :args ("ADMIN")} 


Oops, the generated SQL is missing round brackets. We eliminate the mistake, here is the revised version of the render-table :

 (defn render-table [[alias table]] (if (= alias table) ;     ,    'AS' table ;   -  sql -   (if (or (instance? Sql table) (instance? Select table)) [(symbol "(") table (symbol ")") 'AS alias] [table 'AS alias]))) ;      (select (from :x (users-by-role "ADMIN")) (order :name) (as-sql)) 


Permanent connection to the database


Of course, opening a new connection every time inside the fetch-* functions is not an option. Again the macro:

 (defn with-connection* [db body-fn] (assert (nil? (jdbc/db-find-connection db))) (with-open [conn (jdbc/get-connection db)] (body-fn (jdbc/add-connection db conn)))) (defmacro with-connection [binding & body] `(with-connection* ~(second binding) (fn [~(first binding)] ~@body))) 


Here we check that there is still no open connection, open a new one and “attach” it to the dictionary with the database parameters. You need to use this:

 (def db {...}) (with-connection [c db] ;  e `c`    `db` +   (fetch-all c (select (from :B))) ; ... (fetch-all c (select (from :A)))) 


In a similar way, you can add transaction support.

A small bonus - more speed with elements of abnormal programming
Obviously, the library introduces additional costs: you need to create the original query in the highest level view, convert it with the help of render-select , skip the result through as-sql . In addition to this, many of our functions are implemented through defndialect , which also adversely affects performance. It is especially annoying to repeat such operations for the simplest queries like “pull record by id”. In truth, the overhead is quite insignificant compared with the time of the database ... But with a strong desire, you can add even more speed. So, our goal:

 ;  ,   SQL    (defselect get-user-by-id [x] (from :Users) (where (= :id x)))) ;  ,    legacy  (defselect get-user-by-id [x] "SELECT * FROM `Users` WHERE `id` = :x") ;  (fetch-one db (get-user-by-id 123)) 


There is a problem - dialects. We cannot calculate the query at the compilation of the program (in the body of the macro), because we do not know which dialect will be active during execution. You can precompute the query for all available dialects, but they can be added dynamically (in runtime) - most likely we will miss the right one, badly.

An alternative solution is to cache computed queries. Those. each such defselect stores in itself a cache - dictionary “dialect - SqlLike object”. Thus, for each dialect we compile (potentially expensive for complex queries) once for each dialect. After extracting the Sql record, we simply substitute the necessary arguments into the field :args , without changing anything :sql .

 ;   -      SQL  (defrecord LazySelect [content-fn] SqlLike (as-sql [this] (content-fn))) ;       (defrecord RenderedSelect [content] SqlLike (as-sql [this] (as-sql content))) ;   (defrecord SurrogatedArg [symbol] SqlLike (as-sql [this] (Sql. symbol "?"))) (defn emit-precompiled-select [name args body] (let [;  args -    sargs (map ->SurrogatedArg args) ;      sargs-args (into {} (map vector sargs args))] `(let [sqls# (atom {}) ;     ; ""  original# (fn ~name ~args (as-sql (select ~@body))) ;   , ;     compile# (fn [] (apply original# (list ~@sargs)))] (defn ~name ~args (->LazySelect (fn [] (let [;  ,    ;   dialect# (current-dialect) cached-sql# (get @sqls# dialect#) ;   -   sql# (if cached-sql# cached-sql# ;   ;     , ;      -  (let [new-sql# (compile#)] (swap! sqls# assoc dialect# new-sql#) new-sql#)) ;      args# (:args sql#)] ;      (assoc sql# :args (replace ~sargs-args args#))))))))) ;      (defn emit-raw-select [name args sql] ;    (let [args-map (into {} (map (juxt keyword identity) args))] ;  ,  RenderedSelect `(defn ~name ~args (->RenderedSelect (format-sql ~sql ~args-map))))) (defmacro defselect [name args & body] (if (and (== 1 (count body)) (string? (first body))) (emit-raw-select name args (first body)) (emit-precompiled-select name args body))) 



In conclusion


The article does not affect the implementation of functions for modifying the database: insert, delete, update records. There are no tools for working with DDL, transactions, and much more. But the new functionality is quite easy to add, often even without modifying the existing code. The proposed method is one of many, not without shortages, but with the right to live. Finally, I leave a link to the code of the full version , based on which this article was written.

Source: https://habr.com/ru/post/204992/


All Articles