camsaul / toucan2

Successor library to Toucan with a modern and more-extensible API, more consistent behavior, and support for different backends including non-JDBC databases and non-HoneySQL queries. Currently in active beta.
Eclipse Public License 1.0
81 stars 11 forks source link
clojure sql toucan

License GitHub last commit Codecov GitHub Sponsors cljdoc badge Get help on Slack

Clojars Project

T2: Toucan 2

Toucan 2

Toucan 2 is a library for delightful database interaction.

At the end of the day, almost every non-trivial project needs to interact with a database. Toucan 2 makes its easy to query the data in your database in a consistent way and define behaviors that should happen every time a row is retrieved, created, updated, or deleted.

Toucan 2 is a successor library to Toucan with a modern and more-extensible API, more consistent behavior (less gotchas), support for different backends including non-JDBC databases and non-HoneySQL queries, support for namespaced keywords, and with more useful utilities.

Toucan 2 uses Honey SQL 2, next.jdbc, and Methodical under the hood. Everything is super efficient and reducible under the hood (even inserts, updates, and deletes!) and magical (in a good way).

You Can Toucan: 12 Cool Things You Can Do with Toucan 2

REPL-friendly syntax

Toucan 2 is optimized to for REPL-driven development, It offers a high-level interface that's quick and easy to use from the REPL.

Compare a simple query with next.jdbc vs the equivalent way to do it in Toucan:

(require '[next.jdbc :as jdbc])

(def db-spec
  {:dbtype   "postgresql"
   :dbname   "toucan2"
   :host     "localhost"
   :post     5432
   :user     "cam"
   :password "cam"})

;;; next.jdbc

(let [my-datasource (jdbc/get-datasource db-spec)]
  (with-open [connection (jdbc/get-connection my-datasource)]
    (jdbc/execute! connection ["SELECT * FROM people WHERE name = ?" "Cam"])))
;; =>
[{:people/id 1
  :people/name "Cam"
  :people/created-at #inst "2020-04-21T23:56:00.000000000-00:00"}]

;;; Toucan 2

(require '[toucan2.core :as t2])

(t2/select :conn db-spec "people" :name "Cam")
;; =>
[{:id 1
  :name "Cam"
  :created-at #object[java.time.OffsetDateTime 0x2c8ec7ed "2020-04-21T23:56Z"]}]

;;; next.jdbc + Honey SQL 2

(require '[honey.sql :as sql])

(let [my-datasource (jdbc/get-datasource db-spec)]
  (with-open [connection (jdbc/get-connection my-datasource)]
    (jdbc/execute! connection (sql/format {:select [:*]
                                           :from   [:people]
                                           :where  [:like :name "C%"]}))))
;; =>
[{:people/id 1
  :people/name "Cam"
  :people/created-at #inst "2020-04-21T23:56:00.000000000-00:00"}]

;;; Toucan 2

(t2/select :conn db-spec "people" :name [:like "C%"])
;; =>
[{:id 1
  :name "Cam"
  :created-at #object[java.time.OffsetDateTime 0x2c8ec7ed "2020-04-21T23:56Z"]}]

Define a default connection

As you can see, Toucan 2 is quick and easy to use from the REPL. But it can be even easier! Typing dbspec over and over can get tedious. Toucan 2 lets you define a default connection:

(require '[methodical.core :as m])

(m/defmethod t2/do-with-connection :default
  [_connectable f]
  (t2/do-with-connection db-spec f))

(t2/select "people" :name [:like "C%"])
;; =>
[{:id 1
  :name "Cam"
  :created-at #object[java.time.OffsetDateTime 0x2bf3111d "2020-04-21T23:56Z"]}]

You can also define a default connection for specific tables (models). For more information, see Connections.

Define models

As we'll see below, you can define lots of arbitrary behaviors when selecting, updating, inserting, and deleting rows from various tables in your database. These behaviors are encapsulated in multimethods that are triggered for what Toucan 2 calls models, which are usually just plain Clojure keywords.

By default, Toucan 2 will use the name of a model keyword as the table name to use when querying it. Let's try using a model called :model/people:

(t2/table-name :model/people)
;; => :people

;; Select one row from :people with the primary key 1
(t2/select-one :model/people 1)
;; =>
{:id 1
 :name "Cam"
 :created-at #object[java.time.OffsetDateTime 0x2c8ec7ed "2020-04-21T23:56Z"]}

Going forward, we'll be deriving a lot of models from :models/people. To make new models like :models/people.cool use the right table name, let's tell Toucan 2 to always use people as the table name for anything deriving from :models/people:

(m/defmethod t2/table-name :models/people
  [_model]
  :people)

To learn more about models, see Models.

Define transforms

Toucan 2 is much more than just a glorified collection of helper functions. Suppose we have a column that we would like to automatically be converted to keywords whenever it comes out of the database, and automatically be converted back to strings when it goes back into the database. With Toucan 2, you can easily define column transformations with deftransforms.

Let's define a new model, so we don't affect :models/people itself.

(derive :models/people.keyword-name :models/people)

(t2/deftransforms :models/people.keyword-name
  {:name {:in  name
          :out keyword}})

(t2/select :models/people.keyword-name :name :Cam)
;; =>
[{:id 1
  :name :Cam
  :created-at #object[java.time.OffsetDateTime 0x3af24cf "2020-04-21T23:56Z"]}]

Whenever a non-nil value goes in to the database, Toucan 2 calls name on it; when a non-nil value comes out, Toucan 2 calls keyword on it. For more info, see Transforms.

Define behavior after selecting something

Sometimes we want to do more general things than just transform a single column to another value. You can use tools like define-after-select to define more general transformations for results, such as adding additional columns, or to trigger some other behavior for side effects. Let's say we want to give all our people cool names when they come out of the database.

(derive :models/people.cool :models/people)

(t2/define-after-select :models/people.cool
  [person]
  (assoc person :cool-name (str "Cool " (:name person))))

(t2/select-one :models/people.cool 1)
;; =>
{:name       "Cam"
 :cool-name  "Cool Cam"
 :id         1
 :created-at #object[java.time.OffsetDateTime 0x2691c719 "2020-04-21T23:56Z"]}

This method is not applied when you use :models/people or other models derived from it, unless those models derive from :models/people.cool. You can define before- and after- methods for select, insert, update, and delete. For more information, see Before & After Methods.

Compose behaviors

Because Toucan 2 is implemented with multimethods, you can compose various behaviors by simply deriving a model from one or more others. Built-in Toucan 2 tools like deftransforms and define-after-select automatically compose.

Let's define another after-select method, ::without-created-at, to remove the :created-at key from our results, then create a new model that combines :models/people.cool with ::without-created-at to get both behaviors:

(t2/define-after-select ::without-created-at
  [row]
  (dissoc row :created-at))

(derive :models/people.cool.without-created-at :models/people.cool)
(derive :models/people.cool.without-created-at ::without-created-at)

;;; Tell Methodical to call the :models.people.cool method before the
;;; ::without-created-at method
(m/prefer-method! #'toucan2.tools.after-select/after-select
                  :models/people.cool
                  ::without-created-at)

(t2/select-one :models/people.cool.without-created-at 1)
;; =>
{:name "Cam", :cool-name "Cool Cam", :id 1}

Define named queries

Often you'll want to write a big complicated query:

(t2/select "venues" {:select    [:venues.id
                                 [:venues.name :venue-name]
                                 [:category.name :category-name]
                                 [:category.slug :category-slug]]
                     :from      [:venues]
                     :left-join [:category [:= :venues.category :category.name]]})
;; =>
[{:id 1, :venue-name "Tempest", :category-name "bar", :category-slug "bar_01"}
 {:id 2, :venue-name "Ho's Tavern", :category-name "bar", :category-slug "bar_01"}
 {:id 3, :venue-name "BevMo", :category-name "store", :category-slug "store_02"}
 ...]

This is not REPL-friendly! With Toucan 2, you can use define-named-query to define named queries to use again later:

(t2/define-named-query ::venues-with-categories
  {:select    [:venues.id
               [:venues.name :venue-name]
               [:category.name :category-name]
               [:category.slug :category-slug]]
   :from      [:venues]
   :left-join [:category [:= :venues.category :category.name]]})

(t2/select "venues" ::venues-with-categories)
;; =>
[{:id 1, :venue-name "Tempest", :category-name "bar", :category-slug "bar_01"}
 {:id 2, :venue-name "Ho's Tavern", :category-name "bar", :category-slug "bar_01"}
 {:id 3, :venue-name "BevMo", :category-name "store", :category-slug "store_02"}
 ...]

You can even combine those queries with additional constraints:

(t2/select "venues" :venues.name [:like "Temp%"] ::venues-with-categories)
;; =>
{:id 1, :venue-name "Tempest", :category-name "bar", :category-slug "bar_01"}

You can even define different versions of named queries to use for different models. For example, you may want to have some sort of analytics query for several different tables in your database. Define a different version of :analytics-query for each of them!

To learn more about query resolution and named queries, see Query Resolution.

Define default fields

TODO

Disallow update, delete, or insert

TODO

Get the model associated with an instance

TODO

Record and save! changes made to an instance

TODO

Define custom keyword-arg behavior

TODO

For more information, see Query Compilation & Map Backends

toucan2-toucan1

Clojars Project

Compatibility layer for projects using Toucan 1 to ease transition to Toucan 2. Implements the same namespaces as Toucan 1, but they are implemented on top of Toucan 2. Projects using Toucan 1 can remove their dependency on toucan, and include a dependency on io.github.camsaul/toucan2-toucan1 in its place; with a few changes your project should work without having to switch everything to Toucan 2 all at once. More details on this coming soon.

See toucan2-toucan1 docs for more information.

License

Code, documentation, and artwork copyright © 2017-2023 Cam Saul.

Distributed under the Eclipse Public License, same as Clojure.