953 lines
33 KiB
HTML
953 lines
33 KiB
HTML
|
<!DOCTYPE html>
|
|||
|
<html lang="en">
|
|||
|
<head>
|
|||
|
<meta name="generator" content=
|
|||
|
"HTML Tidy for HTML5 for Linux version 5.2.0">
|
|||
|
<title>Database Access and Persistence</title>
|
|||
|
<meta charset="utf-8">
|
|||
|
<meta name="description" content="A collection of examples of using Common Lisp">
|
|||
|
<meta name="viewport" content=
|
|||
|
"width=device-width, initial-scale=1">
|
|||
|
<link rel="stylesheet" href=
|
|||
|
"assets/style.css">
|
|||
|
<script type="text/javascript" src=
|
|||
|
"assets/highlight-lisp.js">
|
|||
|
</script>
|
|||
|
<script type="text/javascript" src=
|
|||
|
"assets/jquery-3.2.1.min.js">
|
|||
|
</script>
|
|||
|
<script type="text/javascript" src=
|
|||
|
"assets/jquery.toc/jquery.toc.min.js">
|
|||
|
</script>
|
|||
|
<script type="text/javascript" src=
|
|||
|
"assets/toggle-toc.js">
|
|||
|
</script>
|
|||
|
|
|||
|
<link rel="stylesheet" href=
|
|||
|
"assets/github.css">
|
|||
|
|
|||
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
|
|||
|
</head>
|
|||
|
<body>
|
|||
|
<h1 id="title-xs"><a href="index.html">The Common Lisp Cookbook</a> – Database Access and Persistence</h1>
|
|||
|
<div id="logo-container">
|
|||
|
<a href="index.html">
|
|||
|
<img id="logo" src="assets/cl-logo-blue.png"/>
|
|||
|
</a>
|
|||
|
|
|||
|
<div id="searchform-container">
|
|||
|
<form onsubmit="duckSearch()" action="javascript:void(0)">
|
|||
|
<input id="searchField" type="text" value="" placeholder="Search...">
|
|||
|
</form>
|
|||
|
</div>
|
|||
|
|
|||
|
<div id="toc-container" class="toc-close">
|
|||
|
<div id="toc-title">Table of Contents</div>
|
|||
|
<ul id="toc" class="list-unstyled"></ul>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<div id="content-container">
|
|||
|
<h1 id="title-non-xs"><a href="index.html">The Common Lisp Cookbook</a> – Database Access and Persistence</h1>
|
|||
|
|
|||
|
<!-- Announcement we can keep for 1 month or more. I remove it and re-add it from time to time. -->
|
|||
|
<p class="announce">
|
|||
|
📹 <a href="https://www.udemy.com/course/common-lisp-programming/?couponCode=6926D599AA-LISP4ALL">NEW! Learn Lisp in videos and support our contributors with this 40% discount.</a>
|
|||
|
</p>
|
|||
|
<p class="announce-neutral">
|
|||
|
📕 <a href="index.html#download-in-epub">Get the EPUB and PDF</a>
|
|||
|
</p>
|
|||
|
|
|||
|
|
|||
|
<div id="content"
|
|||
|
<p>The
|
|||
|
<a href="https://github.com/CodyReichert/awesome-cl#database">Database section on the Awesome-cl list</a>
|
|||
|
is a resource listing popular libraries to work with different kind of
|
|||
|
databases. We can group them roughly in four categories:</p>
|
|||
|
|
|||
|
<ul>
|
|||
|
<li>wrappers to one database engine (cl-sqlite, postmodern, cl-redis,…),</li>
|
|||
|
<li>interfaces to several DB engines (clsql, sxql,…),</li>
|
|||
|
<li>persistent object databases (bknr.datastore (see chap. 21 of “Common Lisp Recipes”), ubiquitous,…),</li>
|
|||
|
<li><a href="https://en.wikipedia.org/wiki/Object-relational_mapping">Object Relational Mappers</a> (Mito),</li>
|
|||
|
</ul>
|
|||
|
|
|||
|
<p>and other DB-related tools (pgloader).</p>
|
|||
|
|
|||
|
<p>We’ll begin with an overview of Mito. If you must work with an
|
|||
|
existing DB, you might want to have a look at cl-dbi and clsql. If you
|
|||
|
don’t need a SQL database and want automatic persistence of Lisp
|
|||
|
objects, you also have a choice of libraries.</p>
|
|||
|
|
|||
|
<h2 id="the-mito-orm-and-sxql">The Mito ORM and SxQL</h2>
|
|||
|
|
|||
|
<p>Mito is in Quicklisp:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(ql:quickload "mito")
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="overview">Overview</h3>
|
|||
|
|
|||
|
<p><a href="https://github.com/fukamachi/mito">Mito</a> is “an ORM for Common Lisp
|
|||
|
with migrations, relationships and PostgreSQL support”.</p>
|
|||
|
|
|||
|
<ul>
|
|||
|
<li>it <strong>supports MySQL, PostgreSQL and SQLite3</strong>,</li>
|
|||
|
<li>when defining a model, it adds an <code>id</code> (serial primary key),
|
|||
|
<code>created_at</code> and <code>updated_at</code> fields by default like Ruby’s
|
|||
|
ActiveRecord or Django,</li>
|
|||
|
<li>handles DB <strong>migrations</strong> for the supported backends,</li>
|
|||
|
<li>permits DB <strong>schema versioning</strong>,</li>
|
|||
|
<li>is tested under SBCL and CCL.</li>
|
|||
|
</ul>
|
|||
|
|
|||
|
<p>As an ORM, it allows to write class definitions, to specify relationships, and
|
|||
|
provides functions to query the database. For custom queries, it relies on
|
|||
|
<a href="https://github.com/fukamachi/sxql">SxQL</a>, an SQL generator that provides the
|
|||
|
same interface for several backends.</p>
|
|||
|
|
|||
|
<p>Working with Mito generally involves these steps:</p>
|
|||
|
|
|||
|
<ul>
|
|||
|
<li>connecting to the DB</li>
|
|||
|
<li>writing <a href="clos.html">CLOS</a> classes to define models</li>
|
|||
|
<li>running migrations to create or alter tables</li>
|
|||
|
<li>creating objects, saving same in the DB,</li>
|
|||
|
</ul>
|
|||
|
|
|||
|
<p>and iterating.</p>
|
|||
|
|
|||
|
<h3 id="connecting-to-a-db">Connecting to a DB</h3>
|
|||
|
|
|||
|
<p>Mito provides the function <code>connect-toplevel</code> to establish a
|
|||
|
connection to RDBMs:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:connect-toplevel :mysql :database-name "myapp" :username "fukamachi" :password "c0mon-1isp")
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>The driver type can be of <code>:mysql</code>, <code>:sqlite3</code> and <code>:postgres</code>.</p>
|
|||
|
|
|||
|
<p>With sqlite you don’t need the username and password:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:connect-toplevel :sqlite3 :database-name "myapp")
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>As usual, you need to create the MySQL or PostgreSQL database beforehand.
|
|||
|
Refer to their documentation.</p>
|
|||
|
|
|||
|
<p>Connecting sets <code>mito:*connection*</code> to the new connection and returns it.</p>
|
|||
|
|
|||
|
<p>Disconnect with <code>disconnect-toplevel</code>.</p>
|
|||
|
|
|||
|
<p>You might make good use of a wrapper function:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defun connect ()
|
|||
|
"Connect to the DB."
|
|||
|
(mito:connect-toplevel :sqlite3 :database-name "myapp"))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="models">Models</h3>
|
|||
|
|
|||
|
<h4 id="defining-models">Defining models</h4>
|
|||
|
|
|||
|
<p>In Mito, you can define a class which corresponds to a database table with the <code>deftable</code> macro:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user ()
|
|||
|
((name :col-type (:varchar 64))
|
|||
|
(email :col-type (or (:varchar 128) :null)))
|
|||
|
</code></pre>
|
|||
|
<p>Alternatively, you can specify <code>(:metaclass mito:dao-table-class)</code> in a regular class definition.</p>
|
|||
|
|
|||
|
<p>The <code>deftable</code> macro automatically adds some slots: a primary key named <code>id</code> if there’s no primary key, and <code>created_at</code> and <code>updated_at</code> for recording timestamps. Specifying <code>(:auto-pk nil)</code> and <code>(:record-timestamps nil)</code> in the <code>deftable</code> form will disable these behaviours. A <code>deftable</code> class will also come with initializers, named after the slot, and accessors, of form <code><class-name>-<slot-name></code>, for each named slot. For example, for the <code>name</code> slot in the above table definition, the initarg <code>:name</code> will be added to the constuctor, and the accessor <code>user-name</code> will be created.</p>
|
|||
|
|
|||
|
<p>You can inspect the new class:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito.class:table-column-slots (find-class 'user))
|
|||
|
;=> (#<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS MITO.DAO.MIXIN::ID>
|
|||
|
; #<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS COMMON-LISP-USER::NAME>
|
|||
|
; #<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS COMMON-LISP-USER::EMAIL>
|
|||
|
; #<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS MITO.DAO.MIXIN::CREATED-AT>
|
|||
|
; #<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS MITO.DAO.MIXIN::UPDATED-AT>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>The class inherits <code>mito:dao-class</code> implicitly.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(find-class 'user)
|
|||
|
;=> #<MITO.DAO.TABLE:DAO-TABLE-CLASS COMMON-LISP-USER::USER>
|
|||
|
|
|||
|
(c2mop:class-direct-superclasses *)
|
|||
|
;=> (#<STANDARD-CLASS MITO.DAO.TABLE:DAO-CLASS>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>This may be useful when you define methods which can be applied for
|
|||
|
all table classes.</p>
|
|||
|
|
|||
|
<p>For more information on using the Common Lisp Object System, see the
|
|||
|
<a href="clos.html">clos</a> page.</p>
|
|||
|
|
|||
|
<h4 id="creating-the-tables">Creating the tables</h4>
|
|||
|
|
|||
|
<p>After defining the models, you must create the tables:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:ensure-table-exists 'user)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>So a helper function:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defun ensure-tables ()
|
|||
|
(mapcar #'mito:ensure-table-exists '(user foo bar)))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>See
|
|||
|
<a href="https://github.com/fukamachi/mito#generating-table-definitions">Mito’s documentation</a>
|
|||
|
for a couple more ways.</p>
|
|||
|
|
|||
|
<p>When you alter the model you’ll need to run a DB migration, see the next section.</p>
|
|||
|
|
|||
|
<h4 id="fields">Fields</h4>
|
|||
|
|
|||
|
<h5 id="fields-types">Fields types</h5>
|
|||
|
|
|||
|
<p>Field types are:</p>
|
|||
|
|
|||
|
<p><code>(:varchar <integer>)</code> ,</p>
|
|||
|
|
|||
|
<p><code>:serial</code>, <code>:bigserial</code>, <code>:integer</code>, <code>:bigint</code>, <code>:unsigned</code>,</p>
|
|||
|
|
|||
|
<p><code>:timestamp</code>, <code>:timestamptz</code>,</p>
|
|||
|
|
|||
|
<p><code>:bytea</code>,</p>
|
|||
|
|
|||
|
<h5 id="optional-fields">Optional fields</h5>
|
|||
|
|
|||
|
<p>Use <code>(or <real type> :null)</code>:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp"> (email :col-type (or (:varchar 128) :null))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h5 id="field-constraints">Field constraints</h5>
|
|||
|
|
|||
|
<p><code>:unique-keys</code> can be used like so:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user ()
|
|||
|
((name :col-type (:varchar 64))
|
|||
|
(email :col-type (:varchar 128))
|
|||
|
(:unique-keys email))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>We already saw <code>:primary-key</code>.</p>
|
|||
|
|
|||
|
<p>You can change the table name with <code>:table-name</code>.</p>
|
|||
|
|
|||
|
<h4 id="relationships">Relationships</h4>
|
|||
|
|
|||
|
<p>You can define a relationship by specifying a foreign class with <code>:col-type</code>:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable tweet ()
|
|||
|
((status :col-type :text)
|
|||
|
;; This slot refers to USER class
|
|||
|
(user :col-type user))
|
|||
|
|
|||
|
(table-definition (find-class 'tweet))
|
|||
|
;=> (#<SXQL-STATEMENT: CREATE TABLE tweet (
|
|||
|
; id BIGSERIAL NOT NULL PRIMARY KEY,
|
|||
|
; status TEXT NOT NULL,
|
|||
|
; user_id BIGINT NOT NULL,
|
|||
|
; created_at TIMESTAMP,
|
|||
|
; updated_at TIMESTAMP
|
|||
|
; )>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>Now you can create or retrieve a <code>TWEET</code> by a <code>USER</code> object, not a <code>USER-ID</code>.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defvar *user* (mito:create-dao 'user :name "Eitaro Fukamachi"))
|
|||
|
(mito:create-dao 'tweet :user *user*)
|
|||
|
|
|||
|
(mito:find-dao 'tweet :user *user*)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>Mito doesn’t add foreign key constraints for referring tables.</p>
|
|||
|
|
|||
|
<h5 id="one-to-one">One-to-one</h5>
|
|||
|
|
|||
|
<p>A one-to-one relationship is simply represented with a simple foreign
|
|||
|
key on a slot (as <code>:col-type user</code> in the <code>tweet</code> class). Besides, we
|
|||
|
can add a unicity constraint, as with <code>(:unique-keys email)</code>.</p>
|
|||
|
|
|||
|
<h5 id="one-to-many-many-to-one">One-to-many, many-to-one</h5>
|
|||
|
|
|||
|
<p>The tweet example above shows a one-to-many relationship between a user and
|
|||
|
his tweets: a user can write many tweets, and a tweet belongs to only
|
|||
|
one user.</p>
|
|||
|
|
|||
|
<p>The relationship is defined with a foreign key on the “many” side
|
|||
|
linking back to the “one” side. Here the <code>tweet</code> class defines a
|
|||
|
<code>user</code> foreign key, so a tweet can only have one user. You didn’t need
|
|||
|
to edit the <code>user</code> class.</p>
|
|||
|
|
|||
|
<p>A many-to-one relationship is actually the contrary of a one-to-many.
|
|||
|
You have to put the foreign key on the appropriate side.</p>
|
|||
|
|
|||
|
<h5 id="many-to-many">Many-to-many</h5>
|
|||
|
|
|||
|
<p>A many-to-many relationship needs an intermediate table, which will be
|
|||
|
the “many” side for the two tables it is the intermediary of.</p>
|
|||
|
|
|||
|
<p>And, thanks to the join table, we can store more information about the relationship.</p>
|
|||
|
|
|||
|
<p>Let’s define a <code>book</code> class:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable book ()
|
|||
|
((title :col-type (:varchar 128))
|
|||
|
(ean :col-type (or (:varchar 128) :null))))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>A user can have many books, and a book (as the title, not the physical
|
|||
|
copy) is likely to be in many people’s library. Here’s the
|
|||
|
intermediate class:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user-books ()
|
|||
|
((user :col-type user)
|
|||
|
(book :col-type book)))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>Each time we want to add a book to a user’s collection (say in
|
|||
|
a <code>add-book</code> function), we create a new <code>user-books</code> object.</p>
|
|||
|
|
|||
|
<p>But someone may very well own many copies of one book. This is an
|
|||
|
information we can store in the join table:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user-books ()
|
|||
|
((user :col-type user)
|
|||
|
(book :col-type book)
|
|||
|
;; Set the quantity, 1 by default:
|
|||
|
(quantity :col-type :integer)))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="inheritance-and-mixin">Inheritance and mixin</h4>
|
|||
|
|
|||
|
<p>A subclass of DAO-CLASS is allowed to be inherited. This may be useful
|
|||
|
when you need classes which have similar columns:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user ()
|
|||
|
((name :col-type (:varchar 64))
|
|||
|
(email :col-type (:varchar 128)))
|
|||
|
(:unique-keys email))
|
|||
|
|
|||
|
(mito:deftable temporary-user (user)
|
|||
|
((registered-at :col-type :timestamp)))
|
|||
|
|
|||
|
(mito:table-definition 'temporary-user)
|
|||
|
;=> (#<SXQL-STATEMENT: CREATE TABLE temporary_user (
|
|||
|
; id BIGSERIAL NOT NULL PRIMARY KEY,
|
|||
|
; name VARCHAR(64) NOT NULL,
|
|||
|
; email VARCHAR(128) NOT NULL,
|
|||
|
; registered_at TIMESTAMP NOT NULL,
|
|||
|
; created_at TIMESTAMP,
|
|||
|
; updated_at TIMESTAMP,
|
|||
|
; UNIQUE (email)
|
|||
|
; )>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>If you need a ‘template’ for tables which aren’t related to any
|
|||
|
database tables, you can use <code>DAO-TABLE-MIXIN</code> in a <code>defclass</code> form. The <code>has-email</code>
|
|||
|
class below will not create a table.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defclass has-email ()
|
|||
|
((email :col-type (:varchar 128)
|
|||
|
:initarg :email
|
|||
|
:accessor object-email))
|
|||
|
(:metaclass mito:dao-table-mixin)
|
|||
|
(:unique-keys email))
|
|||
|
;=> #<MITO.DAO.MIXIN:DAO-TABLE-MIXIN COMMON-LISP-USER::HAS-EMAIL>
|
|||
|
|
|||
|
(mito:deftable user (has-email)
|
|||
|
((name :col-type (:varchar 64))))
|
|||
|
;=> #<MITO.DAO.TABLE:DAO-TABLE-CLASS COMMON-LISP-USER::USER>
|
|||
|
|
|||
|
(mito:table-definition 'user)
|
|||
|
;=> (#<SXQL-STATEMENT: CREATE TABLE user (
|
|||
|
; id BIGSERIAL NOT NULL PRIMARY KEY,
|
|||
|
; name VARCHAR(64) NOT NULL,
|
|||
|
; email VARCHAR(128) NOT NULL,
|
|||
|
; created_at TIMESTAMP,
|
|||
|
; updated_at TIMESTAMP,
|
|||
|
; UNIQUE (email)
|
|||
|
; )>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>See more examples of use in <a href="https://github.com/fukamachi/mito-auth/">mito-auth</a>.</p>
|
|||
|
|
|||
|
<h4 id="troubleshooting">Troubleshooting</h4>
|
|||
|
|
|||
|
<h5 id="cannot-change-class-objects-into-class-metaobjects">“Cannot CHANGE-CLASS objects into CLASS metaobjects.”</h5>
|
|||
|
|
|||
|
<p>If you get the following error message:</p>
|
|||
|
|
|||
|
<pre><code>Cannot CHANGE-CLASS objects into CLASS metaobjects.
|
|||
|
[Condition of type SB-PCL::METAOBJECT-INITIALIZATION-VIOLATION]
|
|||
|
See also:
|
|||
|
The Art of the Metaobject Protocol, CLASS [:initialization]
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>it is certainly because you first wrote a class definition and <em>then</em>
|
|||
|
added the Mito metaclass and tried to evaluate the class definition
|
|||
|
again.</p>
|
|||
|
|
|||
|
<p>If this happens, you must remove the class definition from the current package:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(setf (find-class 'foo) nil)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>or, with the Slime inspector, click on the class and find the “remove” button.</p>
|
|||
|
|
|||
|
<p>More info <a href="https://stackoverflow.com/questions/38811931/how-to-change-classs-metaclass">here</a>.</p>
|
|||
|
|
|||
|
<h3 id="migrations">Migrations</h3>
|
|||
|
|
|||
|
<p>We can run database migrations manually, as shown below, or we can
|
|||
|
automatically run migrations after a change to the model
|
|||
|
definitions. To enable automatic migrations, set <code>mito:*auto-migration-mode*</code> to <code>t</code>.</p>
|
|||
|
|
|||
|
<p>The first step is to create the tables, if needed:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(ensure-table-exists 'user)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>then alter the tables:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:migrate-table 'user)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>You can check the SQL generated code with <code>migration-expressions
|
|||
|
'class</code>. For example, we create the <code>user</code> table:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(ensure-table-exists 'user)
|
|||
|
;-> ;; CREATE TABLE IF NOT EXISTS "user" (
|
|||
|
; "id" BIGSERIAL NOT NULL PRIMARY KEY,
|
|||
|
; "name" VARCHAR(64) NOT NULL,
|
|||
|
; "email" VARCHAR(128),
|
|||
|
; "created_at" TIMESTAMP,
|
|||
|
; "updated_at" TIMESTAMP
|
|||
|
; ) () [0 rows] | MITO.DAO:ENSURE-TABLE-EXISTS
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>There are no changes from the previous user definition:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:migration-expressions 'user)
|
|||
|
;=> NIL
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>Now let’s add a unique <code>email</code> field:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user ()
|
|||
|
((name :col-type (:varchar 64))
|
|||
|
(email :col-type (:varchar 128)))
|
|||
|
(:unique-keys email))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>The migration will run the following code:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:migration-expressions 'user)
|
|||
|
;=> (#<SXQL-STATEMENT: ALTER TABLE user ALTER COLUMN email TYPE character varying(128), ALTER COLUMN email SET NOT NULL>
|
|||
|
; #<SXQL-STATEMENT: CREATE UNIQUE INDEX unique_user_email ON user (email)>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>so let’s apply it:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:migrate-table 'user)
|
|||
|
;-> ;; ALTER TABLE "user" ALTER COLUMN "email" TYPE character varying(128), ALTER COLUMN "email" SET NOT NULL () [0 rows] | MITO.MIGRATION.TABLE:MIGRATE-TABLE
|
|||
|
; ;; CREATE UNIQUE INDEX "unique_user_email" ON "user" ("email") () [0 rows] | MITO.MIGRATION.TABLE:MIGRATE-TABLE
|
|||
|
;-> (#<SXQL-STATEMENT: ALTER TABLE user ALTER COLUMN email TYPE character varying(128), ALTER COLUMN email SET NOT NULL>
|
|||
|
; #<SXQL-STATEMENT: CREATE UNIQUE INDEX unique_user_email ON user (email)>)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="queries">Queries</h3>
|
|||
|
|
|||
|
<h4 id="creating-objects">Creating objects</h4>
|
|||
|
|
|||
|
<p>We can create user objects with the regular <code>make-instance</code>:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defvar me
|
|||
|
(make-instance 'user :name "Eitaro Fukamachi" :email "e.arrows@gmail.com"))
|
|||
|
;=> USER
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>To save it in DB, use <code>insert-dao</code>:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:insert-dao me)
|
|||
|
;-> ;; INSERT INTO `user` (`name`, `email`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?) ("Eitaro Fukamachi", "e.arrows@gmail.com", "2016-02-04T19:55:16.365543Z", "2016-02-04T19:55:16.365543Z") [0 rows] | MITO.DAO:INSERT-DAO
|
|||
|
;=> #<USER {10053C4453}>
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>Do the two steps above at once:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:create-dao 'user :name "Eitaro Fukamachi" :email "e.arrows@gmail.com")
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>You should not export the <code>user</code> class and create objects outside of
|
|||
|
its package (it is good practice anyway to keep all database-related
|
|||
|
operations in say a <code>models</code> package and file). You should instead use
|
|||
|
a helper function:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defun make-user (&key name)
|
|||
|
(make-instance 'user :name name))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="updating-fields">Updating fields</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(setf (slot-value me 'name) "nitro_idiot")
|
|||
|
;=> "nitro_idiot"
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>and save it:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:save-dao me)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="deleting">Deleting</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:delete-dao me)
|
|||
|
;-> ;; DELETE FROM `user` WHERE (`id` = ?) (1) [0 rows] | MITO.DAO:DELETE-DAO
|
|||
|
|
|||
|
;; or:
|
|||
|
(mito:delete-by-values 'user :id 1)
|
|||
|
;-> ;; DELETE FROM `user` WHERE (`id` = ?) (1) [0 rows] | MITO.DAO:DELETE-DAO
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="get-the-primary-key-value">Get the primary key value</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:object-id me)
|
|||
|
;=> 1
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="count">Count</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:count-dao 'user)
|
|||
|
;=> 1
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="find-one">Find one</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:find-dao 'user :id 1)
|
|||
|
;-> ;; SELECT * FROM `user` WHERE (`id` = ?) LIMIT 1 (1) [1 row] | MITO.DB:RETRIEVE-BY-SQL
|
|||
|
;=> #<USER {10077C6073}>
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>So here’s a possibility of generic helpers to find an object by a given key:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defgeneric find-user (key-name key-value)
|
|||
|
(:documentation "Retrieves an user from the data base by one of the unique
|
|||
|
keys."))
|
|||
|
|
|||
|
(defmethod find-user ((key-name (eql :id)) (key-value integer))
|
|||
|
(mito:find-dao 'user key-value))
|
|||
|
|
|||
|
(defmethod find-user ((key-name (eql :name)) (key-value string))
|
|||
|
(first (mito:select-dao 'user
|
|||
|
(sxql:where (:= :name key-value)))))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="find-all">Find all</h4>
|
|||
|
|
|||
|
<p>Use the macro <code>select-dao</code>.</p>
|
|||
|
|
|||
|
<p>Get a list of all users:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:select-dao 'user)
|
|||
|
;(#<USER {10077C6073}>)
|
|||
|
;#<SXQL-STATEMENT: SELECT * FROM user>
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="find-by-relationship">Find by relationship</h4>
|
|||
|
|
|||
|
<p>As seen above:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:find-dao 'tweet :user *user*)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="custom-queries">Custom queries</h4>
|
|||
|
|
|||
|
<p>It is with <code>select-dao</code> that you can write more precise queries by
|
|||
|
giving it <a href="https://github.com/fukamachi/sxql">SxQL</a> statements.</p>
|
|||
|
|
|||
|
<p>Example:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(select-dao 'tweet
|
|||
|
(where (:like :status "%Japan%")))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>another:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(select (:id :name :sex)
|
|||
|
(from (:as :person :p))
|
|||
|
(where (:and (:>= :age 18)
|
|||
|
(:< :age 65)))
|
|||
|
(order-by (:desc :age)))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>You can compose your queries with regular Lisp code:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defun find-tweets (&key user)
|
|||
|
(select-dao 'tweet
|
|||
|
(when user
|
|||
|
(where (:= :user user)))
|
|||
|
(order-by :object-created)))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p><code>select-dao</code> is a macro that expands to the right thing©.</p>
|
|||
|
|
|||
|
<div class="info-box info">
|
|||
|
<strong>Note:</strong> if you didn't <code>use</code> SXQL, then write <code>(sxql:where …)</code> and <code>(sxql:order-by …)</code>.
|
|||
|
</div>
|
|||
|
<p><br /></p>
|
|||
|
|
|||
|
<p>You can compose your queries further with the backquote syntax.</p>
|
|||
|
|
|||
|
<p>Imagine you receive a <code>query</code> string, maybe composed of
|
|||
|
space-separated words, and you want to search for books that have
|
|||
|
either one of these words in their title or in their author’s
|
|||
|
name. Searching for “bob adventure” would return a book that has
|
|||
|
“adventure” in its title and “bob” in its author name, or both in the
|
|||
|
title.</p>
|
|||
|
|
|||
|
<p>For the example sake, an author is a string, not a link to another table:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable book ()
|
|||
|
((title :col-type (:varchar 128))
|
|||
|
(author :col-type (:varchar 128))
|
|||
|
(ean :col-type (or (:varchar 128) :null))))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>You want to add a clause that searches on both fields for each word.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defun find-books (&key query (order :desc))
|
|||
|
"Return a list of books. If a query string is given, search on both the title and the author fields."
|
|||
|
(mito:select-dao 'book
|
|||
|
(when (str:non-blank-string-p query)
|
|||
|
(sxql:where
|
|||
|
`(:and
|
|||
|
,@(loop for word in (str:words query)
|
|||
|
:collect `(:or (:like :title ,(str:concat "%" word "%"))
|
|||
|
(:like :authors ,(str:concat "%" word "%")))))))
|
|||
|
(sxql:order-by `(,order :created-at))))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>By the way, we are still using a <code>LIKE</code> statement, but with a non-small dataset you’ll want to use your database’s full text search engine.</p>
|
|||
|
|
|||
|
<h4 id="clauses">Clauses</h4>
|
|||
|
|
|||
|
<p>See the <a href="https://github.com/fukamachi/sxql#sql-clauses">SxQL documentation</a>.</p>
|
|||
|
|
|||
|
<p>Examples:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(select-dao 'foo
|
|||
|
(where (:and (:> :age 20) (:<= :age 65))))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(order-by :age (:desc :id))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(group-by :sex)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(having (:>= (:sum :hoge) 88))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(limit 0 10)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>and <code>join</code>s, etc.</p>
|
|||
|
|
|||
|
<h4 id="operators">Operators</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">:not
|
|||
|
:is-null, :not-null
|
|||
|
:asc, :desc
|
|||
|
:distinct
|
|||
|
:=, :!=
|
|||
|
:<, :>, :<= :>=
|
|||
|
:a<, :a>
|
|||
|
:as
|
|||
|
:in, :not-in
|
|||
|
:like
|
|||
|
:and, :or
|
|||
|
:+, :-, :* :/ :%
|
|||
|
:raw
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="triggers">Triggers</h3>
|
|||
|
|
|||
|
<p>Since <code>insert-dao</code>, <code>update-dao</code> and <code>delete-dao</code> are defined as generic
|
|||
|
functions, you can define <code>:before</code>, <code>:after</code> or <code>:around</code> methods to those, like regular <a href="clos.html#qualifiers-and-method-combination">method combination</a>.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defmethod mito:insert-dao :before ((object user))
|
|||
|
(format t "~&Adding ~S...~%" (user-name object)))
|
|||
|
|
|||
|
(mito:create-dao 'user :name "Eitaro Fukamachi" :email "e.arrows@gmail.com")
|
|||
|
;-> Adding "Eitaro Fukamachi"...
|
|||
|
; ;; INSERT INTO "user" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) ("Eitaro Fukamachi", "e.arrows@gmail.com", "2016-02-16 21:13:47", "2016-02-16 21:13:47") [0 rows] | MITO.DAO:INSERT-DAO
|
|||
|
;=> #<USER {100835FB33}>
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="inflationdeflation">Inflation/Deflation</h3>
|
|||
|
|
|||
|
<p>Inflation/Deflation is a function to convert values between Mito and RDBMS.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito:deftable user-report ()
|
|||
|
((title :col-type (:varchar 100))
|
|||
|
(body :col-type :text
|
|||
|
:initform "")
|
|||
|
(reported-at :col-type :timestamp
|
|||
|
:initform (local-time:now)
|
|||
|
:inflate #'local-time:universal-to-timestamp
|
|||
|
:deflate #'local-time:timestamp-to-universal)))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="eager-loading">Eager loading</h3>
|
|||
|
|
|||
|
<p>One of the pains in the neck to use ORMs is the “N+1 query” problem.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">;; BAD EXAMPLE
|
|||
|
|
|||
|
(use-package '(:mito :sxql))
|
|||
|
|
|||
|
(defvar *tweets-contain-japan*
|
|||
|
(select-dao 'tweet
|
|||
|
(where (:like :status "%Japan%"))))
|
|||
|
|
|||
|
;; Getting names of tweeted users.
|
|||
|
(mapcar (lambda (tweet)
|
|||
|
(user-name (tweet-user tweet)))
|
|||
|
*tweets-contain-japan*)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>This example sends a query to retrieve a user like “SELECT * FROM user
|
|||
|
WHERE id = ?” at each iteration.</p>
|
|||
|
|
|||
|
<p>To prevent this performance issue, add <code>includes</code> to the above query
|
|||
|
which only sends a single WHERE IN query instead of N queries:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">;; GOOD EXAMPLE with eager loading
|
|||
|
|
|||
|
(use-package '(:mito :sxql))
|
|||
|
|
|||
|
(defvar *tweets-contain-japan*
|
|||
|
(select-dao 'tweet
|
|||
|
(includes 'user)
|
|||
|
(where (:like :status "%Japan%"))))
|
|||
|
;-> ;; SELECT * FROM `tweet` WHERE (`status` LIKE ?) ("%Japan%") [3 row] | MITO.DB:RETRIEVE-BY-SQL
|
|||
|
;-> ;; SELECT * FROM `user` WHERE (`id` IN (?, ?, ?)) (1, 3, 12) [3 row] | MITO.DB:RETRIEVE-BY-SQL
|
|||
|
;=> (#<TWEET {1003513EC3}> #<TWEET {1007BABEF3}> #<TWEET {1007BB9D63}>)
|
|||
|
|
|||
|
;; No additional SQLs will be executed.
|
|||
|
(tweet-user (first *))
|
|||
|
;=> #<USER {100361E813}>
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="schema-versioning">Schema versioning</h3>
|
|||
|
|
|||
|
<pre><code>$ ros install mito
|
|||
|
$ mito
|
|||
|
Usage: mito command [option...]
|
|||
|
|
|||
|
Commands:
|
|||
|
generate-migrations
|
|||
|
migrate
|
|||
|
|
|||
|
Options:
|
|||
|
-t, --type DRIVER-TYPE DBI driver type (one of "mysql", "postgres" or "sqlite3")
|
|||
|
-d, --database DATABASE-NAME Database name to use
|
|||
|
-u, --username USERNAME Username for RDBMS
|
|||
|
-p, --password PASSWORD Password for RDBMS
|
|||
|
-s, --system SYSTEM ASDF system to load (several -s's allowed)
|
|||
|
-D, --directory DIRECTORY Directory path to keep migration SQL files (default: "/Users/nitro_idiot/Programs/lib/mito/db/")
|
|||
|
--dry-run List SQL expressions to migrate
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="introspection">Introspection</h3>
|
|||
|
|
|||
|
<p>Mito provides some functions for introspection.</p>
|
|||
|
|
|||
|
<p>We can access the information of <strong>columns</strong> with the functions in
|
|||
|
<code>(mito.class.column:...)</code>:</p>
|
|||
|
|
|||
|
<ul>
|
|||
|
<li><code>table-column-[class, name, info, not-null-p,...]</code></li>
|
|||
|
<li><code>primary-key-p</code></li>
|
|||
|
</ul>
|
|||
|
|
|||
|
<p>and likewise for <strong>tables</strong> with <code>(mito.class.table:...)</code>.</p>
|
|||
|
|
|||
|
<p>Given we get a list of slots of our class:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(ql:quickload "closer-mop")
|
|||
|
|
|||
|
(closer-mop:class-direct-slots (find-class 'user))
|
|||
|
;; (#<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS NAME>
|
|||
|
;; #<MITO.DAO.COLUMN:DAO-TABLE-COLUMN-CLASS EMAIL>)
|
|||
|
|
|||
|
(defparameter user-slots *)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>We can answer the following questions:</p>
|
|||
|
|
|||
|
<h4 id="what-is-the-type-of-this-column-">What is the type of this column ?</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito.class.column:table-column-type (first user-slots))
|
|||
|
;; (:VARCHAR 64)
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h4 id="is-this-column-nullable-">Is this column nullable ?</h4>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(mito.class.column:table-column-not-null-p
|
|||
|
(first user-slots))
|
|||
|
;; T
|
|||
|
(mito.class.column:table-column-not-null-p
|
|||
|
(second user-slots))
|
|||
|
;; NIL
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h3 id="testing">Testing</h3>
|
|||
|
|
|||
|
<p>We don’t want to test DB operations against the production one. We
|
|||
|
need to create a temporary DB before each test.</p>
|
|||
|
|
|||
|
<p>The macro below creates a temporary DB with a random name, creates the
|
|||
|
tables, runs the code and connects back to the original DB connection.</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(defpackage my-test.utils
|
|||
|
(:use :cl)
|
|||
|
(:import-from :my.models
|
|||
|
:*db*
|
|||
|
:*db-name*
|
|||
|
:connect
|
|||
|
:ensure-tables-exist
|
|||
|
:migrate-all)
|
|||
|
(:export :with-empty-db))
|
|||
|
|
|||
|
(in-package my-test.utils)
|
|||
|
|
|||
|
(defun random-string (length)
|
|||
|
;; thanks 40ants/hacrm.
|
|||
|
(let ((chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"))
|
|||
|
(coerce (loop repeat length
|
|||
|
collect (aref chars (random (length chars))))
|
|||
|
'string)))
|
|||
|
|
|||
|
(defmacro with-empty-db (&body body)
|
|||
|
"Run `body` with a new temporary DB."
|
|||
|
`(let* ((*random-state* (make-random-state t))
|
|||
|
(prefix (concatenate 'string
|
|||
|
(random-string 8)
|
|||
|
"/"))
|
|||
|
;; Save our current DB connection.
|
|||
|
(connection mito:*connection*))
|
|||
|
(uiop:with-temporary-file (:pathname name :prefix prefix)
|
|||
|
;; Bind our *db-name* to a new name, so as to create a new DB.
|
|||
|
(let* ((*db-name* name))
|
|||
|
;; Always re-connect to our real DB even in case of error in body.
|
|||
|
(unwind-protect
|
|||
|
(progn
|
|||
|
;; our functions to connect to the DB, create the tables and run the migrations.
|
|||
|
(connect)
|
|||
|
(ensure-tables-exist)
|
|||
|
(migrate-all)
|
|||
|
,@body)
|
|||
|
|
|||
|
(setf mito:*connection* connection))))))
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<p>Use it like this:</p>
|
|||
|
|
|||
|
<pre><code class="language-lisp">(prove:subtest "Creation in a temporary DB."
|
|||
|
(with-empty-db
|
|||
|
(let ((user (make-user :name "Cookbook")))
|
|||
|
(save-user user)
|
|||
|
|
|||
|
(prove:is (name user)
|
|||
|
"Cookbook"
|
|||
|
"Test username in a temp DB."))))
|
|||
|
;; Creation in a temporary DB
|
|||
|
;; CREATE TABLE "user" (
|
|||
|
;; id BIGSERIAL NOT NULL PRIMARY KEY,
|
|||
|
;; name VARCHAR(64) NOT NULL,
|
|||
|
;; email VARCHAR(128) NOT NULL,
|
|||
|
;; created_at TIMESTAMP,
|
|||
|
;; updated_at TIMESTAMP,
|
|||
|
;; UNIQUE (email)
|
|||
|
;; ) () [0 rows] | MITO.DB:EXECUTE-SQL
|
|||
|
;; ✓ Test username in a temp DB.
|
|||
|
</code></pre>
|
|||
|
|
|||
|
<h2 id="see-also">See also</h2>
|
|||
|
|
|||
|
<ul>
|
|||
|
<li>
|
|||
|
<p><a href="https://sites.google.com/site/sabraonthehill/postmodern-examples/exploring-a-database">exploring an existing (PostgreSQL) database with postmodern</a></p>
|
|||
|
</li>
|
|||
|
<li><a href="https://github.com/fukamachi/mito-attachment">mito-attachment</a></li>
|
|||
|
<li><a href="https://github.com/fukamachi/mito-auth">mito-auth</a></li>
|
|||
|
<li><a href="https://github.com/fukamachi/can/">can</a> a role-based access right control library</li>
|
|||
|
<li>an advanced <a href="drafts/defmodel.lisp.html">“defmodel” macro</a>.</li>
|
|||
|
</ul>
|
|||
|
|
|||
|
<!-- # todo: Generating models for an existing DB -->
|
|||
|
|
|||
|
|
|||
|
<p class="page-source">
|
|||
|
Page source: <a href="https://github.com/LispCookbook/cl-cookbook/blob/master/databases.md">databases.md</a>
|
|||
|
</p>
|
|||
|
</div>
|
|||
|
|
|||
|
<script type="text/javascript">
|
|||
|
|
|||
|
// Don't write the TOC on the index.
|
|||
|
if (window.location.pathname != "/cl-cookbook/") {
|
|||
|
$("#toc").toc({
|
|||
|
content: "#content", // will ignore the first h1 with the site+page title.
|
|||
|
headings: "h1,h2,h3,h4"});
|
|||
|
}
|
|||
|
|
|||
|
$("#two-cols + ul").css({
|
|||
|
"column-count": "2",
|
|||
|
});
|
|||
|
$("#contributors + ul").css({
|
|||
|
"column-count": "4",
|
|||
|
});
|
|||
|
</script>
|
|||
|
|
|||
|
|
|||
|
|
|||
|
<div>
|
|||
|
<footer class="footer">
|
|||
|
<hr/>
|
|||
|
© 2002–2021 the Common Lisp Cookbook Project
|
|||
|
</footer>
|
|||
|
|
|||
|
</div>
|
|||
|
<div id="toc-btn">T<br>O<br>C</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<script text="javascript">
|
|||
|
HighlightLisp.highlight_auto({className: null});
|
|||
|
</script>
|
|||
|
|
|||
|
<script type="text/javascript">
|
|||
|
function duckSearch() {
|
|||
|
var searchField = document.getElementById("searchField");
|
|||
|
if (searchField && searchField.value) {
|
|||
|
var query = escape("site:lispcookbook.github.io/cl-cookbook/ " + searchField.value);
|
|||
|
window.location.href = "https://duckduckgo.com/?kj=b2&kf=-1&ko=1&q=" + query;
|
|||
|
// https://duckduckgo.com/params
|
|||
|
// kj=b2: blue header in results page
|
|||
|
// kf=-1: no favicons
|
|||
|
}
|
|||
|
}
|
|||
|
</script>
|
|||
|
|
|||
|
<script async defer data-domain="lispcookbook.github.io/cl-cookbook" src="https://plausible.io/js/plausible.js"></script>
|
|||
|
|
|||
|
</body>
|
|||
|
</html>
|