Initial Getting Started/Extending HoneySQL docs
This commit is contained in:
parent
25acc53b24
commit
80c137949e
4 changed files with 307 additions and 40 deletions
|
|
@ -1,11 +1,5 @@
|
||||||
{:cljdoc.doc/tree [["Readme" {:file "README.md"}]
|
{:cljdoc.doc/tree [["Readme" {:file "README.md"}]
|
||||||
["Changes" {:file "CHANGELOG.md"}]
|
["Changes" {:file "CHANGELOG.md"}]
|
||||||
["Getting Started" {:file "doc/getting-started.md"}
|
["Getting Started" {:file "doc/getting-started.md"}
|
||||||
#_["Friendly SQL Functions" {:file "doc/friendly-sql-functions.md"}]
|
["Extending HoneySQL" {:file "doc/extending-honeysql.md"}]]
|
||||||
#_["Tips & Tricks" {:file "doc/tips-and-tricks.md"}]
|
|
||||||
#_["Result Set Builders" {:file "doc/result-set-builders.md"}]
|
|
||||||
#_["Prepared Statements" {:file "doc/prepared-statements.md"}]
|
|
||||||
#_["Transactions" {:file "doc/transactions.md"}]]
|
|
||||||
#_["All The Options" {:file "doc/all-the-options.md"}]
|
|
||||||
#_["datafy, nav, and :schema" {:file "doc/datafy-nav-and-schema.md"}]
|
|
||||||
["Differences from 1.x" {:file "doc/difference-from-1-x.md"}]]}
|
["Differences from 1.x" {:file "doc/difference-from-1-x.md"}]]}
|
||||||
|
|
|
||||||
99
doc/extending-honeysql.md
Normal file
99
doc/extending-honeysql.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Extending HoneySQL
|
||||||
|
|
||||||
|
Out of the box, HoneySQL supports most standard ANSI SQL clauses
|
||||||
|
and expressions but where it doesn't support something you need
|
||||||
|
you can add new clauses, new operators, and new "functions" (or
|
||||||
|
"special syntax").
|
||||||
|
|
||||||
|
There are three extension points in `honey.sql` that let you
|
||||||
|
register formatters or behavior corresponding to clauses,
|
||||||
|
operators, and functions.
|
||||||
|
|
||||||
|
Built in clauses include: `:select`, `:from`, `:where` and
|
||||||
|
many more. Built in operators include: `:=`, `:+`, `:mod`.
|
||||||
|
Built in functions (special syntax) include: `:array`, `:case`,
|
||||||
|
`:cast`, `:inline`, `:raw` and many more.
|
||||||
|
|
||||||
|
## Registering a New Clause Formatter
|
||||||
|
|
||||||
|
## Registering a New Operator
|
||||||
|
|
||||||
|
`honey.sql/register-op!` accepts a keyword (or a symbol) that
|
||||||
|
should be treated as a new infix operator.
|
||||||
|
|
||||||
|
By default, operators are treated as strictly binary --
|
||||||
|
accepting just two arguments -- and an exception will be
|
||||||
|
thrown if they are provided less than two or more than
|
||||||
|
two arguments. You can optionally specify that an operator
|
||||||
|
can take any number of arguments with `:variadic true`:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/register-op! :<=> :variadic true)
|
||||||
|
;; and then use the new operator:
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:<=> 13 :x 42]})
|
||||||
|
;; will produce:
|
||||||
|
;;=> ["SELECT * FROM table WHERE ? <=> x <=> ?" 13 42]
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are building expressions programmatically, you
|
||||||
|
may want your new operator to ignore "empty" expressions,
|
||||||
|
i.e., where your expression-building code might produce
|
||||||
|
`nil`. The built-in operators `:and` and `:or` ignore
|
||||||
|
such `nil` expressions. You can specify `:ignore-nil true`
|
||||||
|
to achieve that:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/register-op! :<=> :variadic true :ignore-nil true)
|
||||||
|
;; and then use the new operator:
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:<=> nil :x 42]})
|
||||||
|
;; will produce:
|
||||||
|
;;=> ["SELECT * FROM table WHERE x <=> ?" 42]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registering a New Function (Special Syntax)
|
||||||
|
|
||||||
|
`honey.sql/register-fn!` accepts a keyword (or a symbol)
|
||||||
|
that should be treated as new syntax (as a function call),
|
||||||
|
and a "formatter". The formatter can either be a function
|
||||||
|
of two arguments or a previously registered "function" (so
|
||||||
|
that you can easily reuse formatters).
|
||||||
|
|
||||||
|
The formatter will be called with:
|
||||||
|
* The function name (always as a keyword),
|
||||||
|
* The sequence of arguments provided.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/register-fn! :foo (fn [f args] ..))
|
||||||
|
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:foo 1 2 3]})
|
||||||
|
```
|
||||||
|
|
||||||
|
Your formatter function will be called with `:foo` and `(1 2 3)`.
|
||||||
|
It should return a vector containing a SQL string followed by
|
||||||
|
any parameters:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/register-fn! :foo (fn [f args] ["FOO(?)" (first args)]))
|
||||||
|
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:foo 1 2 3]})
|
||||||
|
;; produces:
|
||||||
|
;;=> ["SELECT * FROM table WHERE FOO(?)" 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
In practice, it is likely that your formatter would call
|
||||||
|
`sql/sql-kw` on the function name to produce a SQL representation
|
||||||
|
of it and would call `sql/format-expr` on each argument:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn- foo-formatter [f [x]]
|
||||||
|
(let [[sql & params] (sql/format-expr x)]
|
||||||
|
(into [(str (sql/sql-kw f) "(" sql ")")] params)))
|
||||||
|
|
||||||
|
(sql/register-fn! :foo foo-formatter)
|
||||||
|
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:foo [:+ :a 1]]})
|
||||||
|
;; produces:
|
||||||
|
;;=> ["SELECT * FROM table WHERE FOO(a + ?)" 1]
|
||||||
|
```
|
||||||
|
|
@ -1,3 +1,125 @@
|
||||||
# Getting Started with HoneySQL
|
# Getting Started with HoneySQL
|
||||||
|
|
||||||
tbd
|
HoneySQL lets you build complex SQL statements by constructing
|
||||||
|
and composing Clojure data structures and then formatting that
|
||||||
|
data to a SQL statement (string) and any parameters it needs.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
For the Clojure CLI, add the following dependency to your `deps.edn` file:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
seancorfield/honeysql {:mvn/version "2.0.0-alpha1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
For Leiningen, add the following dependency to your `project.clj` file:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
[seancorfield/honeysql "2.0.0-alpha1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: 2.0.0-alpha1 will be released shortly!
|
||||||
|
|
||||||
|
HoneySQL produces SQL statements but does not execute them.
|
||||||
|
To execute SQL statements, you will also need a JDBC wrapper like
|
||||||
|
[`seancorfield/next.jdbc`](https://github.com/seancorfield/next-jdbc) and a JDBC driver for the database you use.
|
||||||
|
|
||||||
|
## Basic Concepts
|
||||||
|
|
||||||
|
SQL statements are represented as hash maps, with keys that
|
||||||
|
represent clauses in SQL. SQL expressions are generally
|
||||||
|
represented as vectors, where the first element identifies
|
||||||
|
the function or operator and the remaining elements are the
|
||||||
|
arguments or operands.
|
||||||
|
|
||||||
|
`honey.sql/format` takes a hash map representing a SQL
|
||||||
|
statement and produces a vector, suitable for use with
|
||||||
|
`next.jdbc` or `clojure.java.jdbc`, that has the generated
|
||||||
|
SQL string as the first element followed by any parameter
|
||||||
|
values identified in the SQL expressions:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(ns my.example
|
||||||
|
(:require [honey.sql :as sql]))
|
||||||
|
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:= :id 1]})
|
||||||
|
;; produces:
|
||||||
|
;;=> ["SELECT * FROM table WHERE id = ?" 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Any values found in the data structure, that are not keywords
|
||||||
|
or symbols, are treated as positional parameters and replaced
|
||||||
|
by `?` in the SQL string and lifted out into the vector that
|
||||||
|
is returned from `format`.
|
||||||
|
|
||||||
|
Nearly all clauses expect a vector as their value, containing
|
||||||
|
either a list of SQL entities or the representation of a SQL
|
||||||
|
expression.
|
||||||
|
|
||||||
|
A SQL entity can be a simple keyword (or symbol) or a pair
|
||||||
|
that represents a SQL entity and its alias:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/format {:select [:t.id [:name :item]], :from [[:table :t]], :where [:= :id 1]})
|
||||||
|
;; produces:
|
||||||
|
;;=> ["SELECT t.id, name AS item FROM table AS t WHERE id = ?" 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `FROM` clause now has a pair that identifies the SQL entity
|
||||||
|
`table` and its alias `t`. Columns can be identified either by
|
||||||
|
their qualified name (as in `:t.id`) or their unqualified name
|
||||||
|
(as in `:name`). The `SELECT` clause here identifies two SQL
|
||||||
|
entities: `t.id` and `name` with the latter aliased to `item`.
|
||||||
|
|
||||||
|
Symbols can also be used, but you need to quote them to
|
||||||
|
avoid evaluation:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/format '{select [t.id [name item]], from [[table t]], where [= id 1]})
|
||||||
|
;; also produces:
|
||||||
|
;;=> ["SELECT t.id, name AS item FROM table AS t WHERE id = ?" 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
If you wish, you can specify SQL entities as namespace-qualified
|
||||||
|
keywords (or symbols) and the namespace portion will treated as
|
||||||
|
the table name, i.e., `:foo/bar` instead of `:foo.bar`:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sql/format {:select [:t/id [:name :item]], :from [[:table :t]], :where [:= :id 1]})
|
||||||
|
;; and
|
||||||
|
(sql/format '{select [t/id [name item]], from [[table t]], where [= id 1]})
|
||||||
|
;; both produce:
|
||||||
|
;;=> ["SELECT t.id, name AS item FROM table AS t WHERE id = ?" 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition to the hash map (and vectors) approach of building
|
||||||
|
SQL queries with raw Clojure data structures, a namespace full
|
||||||
|
of helper functions is also available. These functions are
|
||||||
|
generally variadic and threadable:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(ns my.example
|
||||||
|
(:require [honey.sql :as sql]
|
||||||
|
[honey.sql.helpers :refer [select from where]]))
|
||||||
|
|
||||||
|
(-> (select :t/id [:name :item])
|
||||||
|
(from [:table :t])
|
||||||
|
(where [:= :id 1])
|
||||||
|
(sql/format))
|
||||||
|
;; produces:
|
||||||
|
;;=> ["SELECT t.id, name AS item FROM table AS t WHERE id = ?" 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition to being variadic -- which often lets you omit one
|
||||||
|
level of `[`..`]` -- the helper functions merge clauses, which
|
||||||
|
can make it easier to build queries programmatically:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(-> (select :t/id)
|
||||||
|
(from [:table :t])
|
||||||
|
(where [:= :id 1])
|
||||||
|
(select [:name :item])
|
||||||
|
(sql/format))
|
||||||
|
;; produces:
|
||||||
|
;;=> ["SELECT t.id, name AS item FROM table AS t WHERE id = ?" 1]
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -107,11 +107,33 @@
|
||||||
"Given a keyword, return a SQL representation of it as a string.
|
"Given a keyword, return a SQL representation of it as a string.
|
||||||
|
|
||||||
A `:kebab-case` keyword becomes a `KEBAB CASE` (uppercase) string
|
A `:kebab-case` keyword becomes a `KEBAB CASE` (uppercase) string
|
||||||
with hyphens replaced by spaces, e.g., `:insert-into` => `INSERT INTO`."
|
with hyphens replaced by spaces, e.g., `:insert-into` => `INSERT INTO`.
|
||||||
|
|
||||||
|
Any namespace qualifier is ignored."
|
||||||
[k]
|
[k]
|
||||||
(-> k (name) (upper-case)
|
(-> k (name) (upper-case)
|
||||||
(as-> s (if (= "-" s) s (str/replace s "-" " ")))))
|
(as-> s (if (= "-" s) s (str/replace s "-" " ")))))
|
||||||
|
|
||||||
|
(defn- sym->kw
|
||||||
|
"Given a symbol, produce a keyword, retaining the namespace
|
||||||
|
qualifier, if any."
|
||||||
|
[s]
|
||||||
|
(if (symbol? s)
|
||||||
|
(if-let [n (namespace s)]
|
||||||
|
(keyword n (name s))
|
||||||
|
(keyword (name s)))
|
||||||
|
s))
|
||||||
|
|
||||||
|
(defn- kw->sym
|
||||||
|
"Given a keyword, produce a symbol, retaining the namespace
|
||||||
|
qualifier, if any."
|
||||||
|
[k]
|
||||||
|
(if (keyword? k)
|
||||||
|
(if-let [n (namespace k)]
|
||||||
|
(symbol n (name k))
|
||||||
|
(symbol (name k)))
|
||||||
|
k))
|
||||||
|
|
||||||
(defn- namespace-_ [x] (some-> (namespace x) (str/replace "-" "_")))
|
(defn- namespace-_ [x] (some-> (namespace x) (str/replace "-" "_")))
|
||||||
(defn- name-_ [x] (str/replace (name x) "-" "_"))
|
(defn- name-_ [x] (str/replace (name x) "-" "_"))
|
||||||
|
|
||||||
|
|
@ -493,13 +515,13 @@
|
||||||
(let [[sqls params leftover]
|
(let [[sqls params leftover]
|
||||||
(reduce (fn [[sql params leftover] k]
|
(reduce (fn [[sql params leftover] k]
|
||||||
(if-let [xs (or (k statement-map)
|
(if-let [xs (or (k statement-map)
|
||||||
(let [s (symbol (name k))]
|
(let [s (kw->sym k)]
|
||||||
(get statement-map s)))]
|
(get statement-map s)))]
|
||||||
(let [formatter (k @clause-format)
|
(let [formatter (k @clause-format)
|
||||||
[sql' & params'] (formatter k xs)]
|
[sql' & params'] (formatter k xs)]
|
||||||
[(conj sql sql')
|
[(conj sql sql')
|
||||||
(if params' (into params params') params)
|
(if params' (into params params') params)
|
||||||
(dissoc leftover k (symbol (name k)))])
|
(dissoc leftover k (kw->sym k))])
|
||||||
[sql params leftover]))
|
[sql params leftover]))
|
||||||
[[] [] statement-map]
|
[[] [] statement-map]
|
||||||
*clause-order*)]
|
*clause-order*)]
|
||||||
|
|
@ -638,7 +660,7 @@
|
||||||
(if (sequential? s)
|
(if (sequential? s)
|
||||||
(let [[sqls params]
|
(let [[sqls params]
|
||||||
(reduce (fn [[sqls params] s]
|
(reduce (fn [[sqls params] s]
|
||||||
(if (coll? s)
|
(if (sequential? s)
|
||||||
(let [[sql & params'] (format-expr s)]
|
(let [[sql & params'] (format-expr s)]
|
||||||
[(conj sqls sql)
|
[(conj sqls sql)
|
||||||
(into params params')])
|
(into params params')])
|
||||||
|
|
@ -663,10 +685,7 @@
|
||||||
(format-dsl expr (assoc opts :nested true))
|
(format-dsl expr (assoc opts :nested true))
|
||||||
|
|
||||||
(sequential? expr)
|
(sequential? expr)
|
||||||
(let [op (first expr)
|
(let [op (sym->kw (first expr))]
|
||||||
;; normalize symbols to keywords here -- makes the subsequent
|
|
||||||
;; logic easier since we use op to lookup things in hash maps:
|
|
||||||
op (if (symbol? op) (keyword (name op)) op)]
|
|
||||||
(if (keyword? op)
|
(if (keyword? op)
|
||||||
(cond (contains? @infix-ops op)
|
(cond (contains? @infix-ops op)
|
||||||
(if (contains? @op-variadic op) ; no aliases here, no special semantics
|
(if (contains? @op-variadic op) ; no aliases here, no special semantics
|
||||||
|
|
@ -791,16 +810,18 @@
|
||||||
only clause so far where that would matter is `:set` which differs in
|
only clause so far where that would matter is `:set` which differs in
|
||||||
MySQL."
|
MySQL."
|
||||||
[clause formatter before]
|
[clause formatter before]
|
||||||
|
(let [clause (sym->kw clause)]
|
||||||
(assert (keyword? clause))
|
(assert (keyword? clause))
|
||||||
(let [f (if (keyword? formatter)
|
(let [k (sym->kw formatter)
|
||||||
(get @clause-format formatter)
|
f (if (keyword? k)
|
||||||
|
(get @clause-format k)
|
||||||
formatter)]
|
formatter)]
|
||||||
(when-not (and f (fn? f))
|
(when-not (and f (fn? f))
|
||||||
(throw (ex-info "The formatter must be a function or existing clause"
|
(throw (ex-info "The formatter must be a function or existing clause"
|
||||||
{:type (type formatter)})))
|
{:type (type formatter)})))
|
||||||
(swap! base-clause-order add-clause-before clause before)
|
(swap! base-clause-order add-clause-before clause before)
|
||||||
(swap! current-clause-order add-clause-before clause before)
|
(swap! current-clause-order add-clause-before clause before)
|
||||||
(swap! clause-format assoc clause f)))
|
(swap! clause-format assoc clause f))))
|
||||||
|
|
||||||
(defn register-fn!
|
(defn register-fn!
|
||||||
"Register a new function (as special syntax). The `formatter` is either
|
"Register a new function (as special syntax). The `formatter` is either
|
||||||
|
|
@ -810,26 +831,29 @@
|
||||||
of the function (as a keyword) and a sequence of the arguments from the
|
of the function (as a keyword) and a sequence of the arguments from the
|
||||||
DSL."
|
DSL."
|
||||||
[function formatter]
|
[function formatter]
|
||||||
|
(let [function (sym->kw function)]
|
||||||
(assert (keyword? function))
|
(assert (keyword? function))
|
||||||
(let [f (if (keyword? formatter)
|
(let [k (sym->kw formatter)
|
||||||
(get @special-syntax formatter)
|
f (if (keyword? k)
|
||||||
|
(get @special-syntax k)
|
||||||
formatter)]
|
formatter)]
|
||||||
(when-not (and f (fn? f))
|
(when-not (and f (fn? f))
|
||||||
(throw (ex-info "The formatter must be a function or existing fn name"
|
(throw (ex-info "The formatter must be a function or existing fn name"
|
||||||
{:type (type formatter)})))
|
{:type (type formatter)})))
|
||||||
(swap! special-syntax assoc function f)))
|
(swap! special-syntax assoc function f))))
|
||||||
|
|
||||||
(defn register-op!
|
(defn register-op!
|
||||||
"Register a new infix operator. Operators can be defined to be variadic (the
|
"Register a new infix operator. Operators can be defined to be variadic (the
|
||||||
default is that they are binary) and may choose to ignore `nil` arguments
|
default is that they are binary) and may choose to ignore `nil` arguments
|
||||||
(this can make it easier to programmatically construct the DSL)."
|
(this can make it easier to programmatically construct the DSL)."
|
||||||
[op & {:keys [variadic ignore-nil]}]
|
[op & {:keys [variadic ignore-nil]}]
|
||||||
|
(let [op (sym->kw op)]
|
||||||
(assert (keyword? op))
|
(assert (keyword? op))
|
||||||
(swap! infix-ops conj op)
|
(swap! infix-ops conj op)
|
||||||
(when variadic
|
(when variadic
|
||||||
(swap! op-variadic conj op))
|
(swap! op-variadic conj op))
|
||||||
(when ignore-nil
|
(when ignore-nil
|
||||||
(swap! op-ignore-nil conj op)))
|
(swap! op-ignore-nil conj op))))
|
||||||
|
|
||||||
(comment
|
(comment
|
||||||
(format {:truncate :foo})
|
(format {:truncate :foo})
|
||||||
|
|
@ -868,7 +892,35 @@
|
||||||
;; while working on the docs
|
;; while working on the docs
|
||||||
(require '[honey.sql :as sql])
|
(require '[honey.sql :as sql])
|
||||||
(sql/format {:select [:*] :from [:table] :where [:= :id 1]})
|
(sql/format {:select [:*] :from [:table] :where [:= :id 1]})
|
||||||
|
(sql/format {:select [:t/id [:name :item]], :from [[:table :t]], :where [:= :id 1]})
|
||||||
|
(sql/format '{select [t/id [name item]], from [[table t]], where [= id 1]})
|
||||||
|
(sql/format '{select * from table where (= id 1)})
|
||||||
|
(require '[honey.sql.helpers :refer [select from where]])
|
||||||
|
(-> (select :t/id [:name :item])
|
||||||
|
(from [:table :t])
|
||||||
|
(where [:= :id 1])
|
||||||
|
(sql/format))
|
||||||
|
(-> (select :t/id)
|
||||||
|
(from [:table :t])
|
||||||
|
(where [:= :id 1])
|
||||||
|
(select [:name :item])
|
||||||
|
(sql/format))
|
||||||
(sql/format {:select [:*] :from [:table] :where [:= :id 1]} {:dialect :mysql})
|
(sql/format {:select [:*] :from [:table] :where [:= :id 1]} {:dialect :mysql})
|
||||||
(sql/format {:select [:foo/bar] :from [:q-u-u-x]} {:quoted true})
|
(sql/format {:select [:foo/bar] :from [:q-u-u-x]} {:quoted true})
|
||||||
(sql/format {:select ["foo/bar"] :from [:q-u-u-x]} {:quoted true})
|
(sql/format {:select ["foo/bar"] :from [:q-u-u-x]} {:quoted true})
|
||||||
|
(sql/format-expr [:primary-key])
|
||||||
|
(sql/register-op! 'y)
|
||||||
|
(sql/format {:where '[y 2 3]})
|
||||||
|
(sql/register-op! :<=> :variadic true :ignore-nil true)
|
||||||
|
;; and then use the new operator:
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:<=> nil :x 42]})
|
||||||
|
(sql/register-fn! :foo (fn [f args] ["FOO(?)" (first args)]))
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:foo 1 2 3]})
|
||||||
|
(defn- foo-formatter [f [x]]
|
||||||
|
(let [[sql & params] (sql/format-expr x)]
|
||||||
|
(into [(str (sql/sql-kw f) "(" sql ")")] params)))
|
||||||
|
|
||||||
|
(sql/register-fn! :foo foo-formatter)
|
||||||
|
|
||||||
|
(sql/format {:select [:*], :from [:table], :where [:foo [:+ :a 1]]})
|
||||||
,)
|
,)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue