A type-safe query builder and Active Record ORM for Crystal
SQLite · PostgreSQL · MySQL · CockroachDB
Documentation · Tutorials · How-to guides · Reference · API · Examples · Changelog
Tip
The full guide is at azutopia.gitbook.io/cql: tutorials, task guides, the
reference and the design notes. Its source is in docs/, but it reads best on the site.
The v1 release candidate continues PR #65. See the release audit and validation record for the tested configurations and remaining release gates. The automated release workflow owns the version bump; this branch is not a published v1.0.0.
For the native engine, connect_failure_threshold=3&connect_retry_delay=1 enables a connection circuit breaker:
failed connects open it, one probe tests recovery after the delay, and healthy existing connections remain usable.
It never retries statements. Use sslmode=verify-full to verify the PostgreSQL server, and size the pool and query
timeouts for the application. Pool configuration explains
the defaults and failure behavior.
- Mistakes stop the build. Schemas, models and queries are Crystal code, so a misspelled column or a value of the wrong type is a compiler error instead of a runtime one.
- Values are always bound. Every value reaches the database as a parameter, and names that can't be bound, such as index and alias names, are validated.
- PostgreSQL needs no extra shard. CQL ships its own wire-protocol driver, with a fiber-safe pool, streaming,
pipelining and
COPY.
Take a users table with a text name and an integer age. Neither of these lines compiles:
User.where { nmae == "Ada" } # misspelled column
User.where { age == "forty" } # a String compared with an Int32 columnError: undefined local variable or method 'nmae' for User::Columns (with ... yield) and top-level (current scope)
Error: CQL: cannot compare a Int32 column with String using `==`; use a Int32 value or another Int32 column
| Area | What you get | Guide |
|---|---|---|
| Schema and migrations | Tables, indexes and foreign keys in CQL::Schema.define. Versioned CQL::Migration(N) classes that apply, roll back and redo. CQL::SchemaDump reads an existing database back into a schema. |
Create a migration |
| Models | CQL::ActiveRecord::Model(Int64), or Int32, UUID and ULID keys, with validations, callbacks, scopes, timestamps, soft deletes, optimistic locking and opt-in dirty tracking. |
Define a model |
| Associations | belongs_to, has_one, has_many and many_to_many, with preload for the first three. A foreign key whose type doesn't match the target's key fails to compile. |
Has many |
| Typed queries | where { (age >= 18) & (status == "active") }, checked at compile time. Joins, CTEs, window functions, set operations, upsert, row locking, JSON and geospatial predicates, raw fragments with bound values, explain, and to_sql(for: adapter). |
Typed where DSL |
| Bulk work | insert_all, upsert_all and insert_<association>_all send multi-row statements. find_each, find_in_batches and pluck keep large reads small. |
Optimize queries |
| Transactions | Nested savepoints on every adapter. | Transactions |
| Guardrails | Validated identifiers, and CQL_VALIDATE_SCHEMA_MAPPINGS=1 crystal spec to check model attribute types against the schema. |
Safety guardrails |
| Caching | Memory and Redis stores, a fragment cache with tag invalidation, and a per-request query cache with middleware for Kemal, Azu, Lucky, Amber, SpiderGazelle and HTTP::Server. |
Query cache |
| Observability | CQL::Telemetry.subscribe sees every statement. CQL::Performance adds a profiler, N+1 detection and reports. Both stay off until you enable them or run in development. |
Telemetry |
| PostgreSQL driver | Protocol 3.2 with fallback to 3.0, TLS, an AsyncEngine pool, streaming, pipelining, COPY and LISTEN/NOTIFY. |
Native driver |
Add CQL and your database's driver to shard.yml, then run shards install:
dependencies:
cql:
github: azutoolkit/cql
version: ~> 0.0.861
sqlite3: # for SQLite
github: crystal-lang/crystal-sqlite3
mysql: # for MySQL; used by the optional cql/mysql integration
github: crystal-lang/crystal-mysql
version: ~> 0.16.0Note
PostgreSQL and CockroachDB need nothing more, because CQL ships the driver. CQL needs Crystal 1.19.1 or later; CI tests Crystal 1.19.1 and 1.22.1.
For MySQL 8.4, use require "cql/mysql" to enable caching_sha2_password and verified TLS.
Set ssl-ca=/path/to/ca.pem in the database URL for a private CA; see
MySQL 8.4 setup guide.
Save this as src/app.cr:
require "cql"
require "sqlite3"
AppDB = CQL::Schema.define(:app_db, adapter: CQL::Adapter::SQLite, uri: "sqlite3://./app.db") do
table :users do
primary :id, Int64, auto_increment: true
text :name
end
table :posts do
primary :id, Int64, auto_increment: true
text :title
bigint :user_id
end
end
class User
include CQL::ActiveRecord::Model(Int64)
db_context AppDB, :users
property name : String
has_many :posts, Post, foreign_key: :user_id
validate :name, presence: true
def initialize(@name : String)
end
end
class Post
include CQL::ActiveRecord::Model(Int64)
db_context AppDB, :posts
property title : String
property user_id : Int64?
belongs_to :user, User, foreign_key: :user_id
def initialize(@title : String, @user_id : Int64? = nil)
end
end
AppDB.users.create! # CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)
AppDB.posts.create!
ada = User.create!(name: "Ada") # INSERT INTO users (name) VALUES (?)
ada.posts.create(title: "Notes on the Analytical Engine") # INSERT INTO posts (title, user_id) VALUES (?, ?)
# Two queries: SELECT ... FROM posts, then SELECT ... FROM users WHERE users.id IN (?)
Post.preload(:user).all.each { |post| puts "#{post.title} by #{post.user.try(&.name)}" }
puts User.where { name == "Ada" }.count # SELECT COUNT(*) FROM users WHERE users.name = ?
puts User.new("").valid? # the presence validation fails on a blank nameRun it:
crystal run src/app.crNotes on the Analytical Engine by Ada
1
false
Next, Your First CQL App adds migrations, updates, deletes and transactions.
flowchart LR
models["Schema and models<br/>CQL::Schema.define"] --> build["Build<br/>QueryBuilder(T) → CQL::Query"]
build --> render["Render<br/>AST → Expression::Generator<br/>with the adapter's dialect"]
render --> execute["Execute<br/>SQL and bound values on the pool<br/>or the open transaction"]
execute --> db[("SQLite · PostgreSQL<br/>MySQL · CockroachDB")]
db --> hydrate["Hydrate<br/>DB::Serializable models"]
Each query method records one clause. At execution, Query#build turns the clauses into an AST, and the
Expression::Generator visitor writes SQL in the adapter's dialect, with placeholders (? or $1) where the values
go. How CQL Keeps Queries Fast walks through each stage and what it costs.
| Database | Adapter | URL | Driver | How CQL checks it |
|---|---|---|---|---|
| SQLite | CQL::Adapter::SQLite |
sqlite3:// |
sqlite3 shard |
the spec suite runs live |
| PostgreSQL | CQL::Adapter::Postgres |
postgres://, postgresql:// |
built in | generated-SQL specs; driver live specs on PostgreSQL 11 to 18 |
| MySQL | CQL::Adapter::MySql |
mysql:// |
mysql shard + require "cql/mysql" |
live MySQL 8.0/8.4 transaction and authentication contracts |
| CockroachDB | CQL::Adapter::CockroachDB |
cockroachdb:// |
built in | generated-SQL specs; driver live specs on CockroachDB 24.3 |
Feature differences, such as upsert, RETURNING, DISTINCT ON, locking, JSON and the minimum versions for CTEs and
window functions, are listed in Database Support.
require "cql" registers CQL's driver with crystal-db for postgres:// and postgresql:// URLs, so CQL::Schema
and DB.open use it without the pg shard. The native driver guide covers
running alongside pg and opting in to cockroachdb:// for DB.open.
CQL::Postgres::AsyncEngine has its own fiber-safe pool and is a CQL::Executor, so any statement runs on it with
using:
DATABASE_URL = "postgres://app:secret@db.internal/app?sslmode=verify-full&pool_size=10"
engine = CQL::Postgres::AsyncEngine.connect(DATABASE_URL)
User.query.where(name: "Ada").using(engine).to_a # ... WHERE users.name = $1
AppDB.query.from(:users).select(:name).where("id > ?", 10).using(engine).all(as: String) # ... WHERE id > $1
engine.transaction(isolation: :serializable) do |tx|
AppDB.insert.into(:users).values(name: "Ada").using(tx).commit
tx.savepoint { |inner| AppDB.insert.into(:posts).values(title: "Draft", user_id: 1_i64).using(inner).commit }
end
# 500 rows per fetch through a portal, not the whole result in memory
engine.stream(AppDB.query.from(:users).select(:id), fetch_size: 500) do |rows|
rows.each { puts rows.read(Int64) }
end
# Several statements, one round trip
results = engine.pipeline([AppDB.query.from(:users).select(:name), AppDB.query.from(:posts).select(:title)])
results[0].as(CQL::Postgres::PipelineResult).all(as: String)
# A whole schema (models, migrations, transactions) on the engine's pool
ShopDB = CQL::Schema.new(:shop, DATABASE_URL, CQL::Adapter::Postgres, source: CQL::Postgres::EngineSource.connect(DATABASE_URL))| Guide | Covers |
|---|---|
| Native PostgreSQL Driver | entry points, switching from the pg shard |
| AsyncEngine and pool options | pool_size, health checks, setup:/reset:, errors |
| A Schema on the engine's pool | EngineSource |
| Streaming and pipelining | stream, pipeline, timeouts |
| COPY and binary parameters | copy_in, copy_out, binary_params=true |
| TLS, poolers and timeouts | sslmode, PgBouncer and other poolers, cancel, isolation |
| Use CockroachDB | CockroachDB differences |
The driver passes its live specs on PostgreSQL 11 to 18 and CockroachDB 24.3, and has been run behind PgBouncer,
Odyssey, pgcat and pgpool-II.1 CI also runs spec/postgres/live_spec.cr against a postgres service container
on every push.
PostgreSQL versions, CockroachDB, poolers and TLS
| PostgreSQL | Protocol used | Cancel key | SCRAM, MD5, cleartext | live_spec.cr |
|---|---|---|---|---|
| 11.22, 12.22, 13.23, 14.24, 15.19, 16.15, 17.11 | 3.0 (server answers NegotiateProtocolVersion) |
4 bytes | pass | pass |
| 18.6 | 3.2 | 32 bytes | pass | pass |
- Versions:
script/pg-matrixrunsspec/postgres/matrix_spec.crandlive_spec.cr(23 examples) against each server. Every version also passed re-preparing after a schema change, pipelining,COPYin and out, cancel after a statement timeout, and per-transactionsynchronous_commit: false. PostgreSQL 19rc1, built from source, passed the matrix (7 of 7),live_spec.crandstreaming_spec.cr(35 examples). Untested: a final PostgreSQL 19, non-Linux or non-x86_64 hosts, distribution-patched builds. - CockroachDB 24.3.5, insecure and with TLS plus a password:
live_spec.crandstreaming_spec.crpass except theLISTEN/NOTIFYexample (CockroachDB has noLISTEN). - Poolers: PgBouncer 1.22, 1.24 and 1.26 (
script/pgbouncer-matrix), Odyssey 1.5.2, pgcat 1.2.0 and pgpool-II 4.3.7 (script/pooler_probes.cr); see Poolers for what works on each. - TLS refusal:
script/tls-probeschecks that a TLS requirement that cannot be met raisesCQL::Postgres::TLSRefusedand that crystal-db's pool does not retry it.spec/postgres/blackhole_spec.crdrops a TLS connection on a path that stops answering: 50 timed-out requests, 50 pings and 10 writes finished without hanging. Other TLS modes and Unix sockets were tried by hand against PostgreSQL 16; beyond that they have byte-level fixture and fake-server specs only.
Benchmarks: streaming and binary parameters
Two measurements on PostgreSQL 16 over loopback.2
benchmarks/pg_wire_bench.cr, 200,000 rows of two columns: streaming 1000 rows per fetch held 1000 rows at once (+1.7 MB peak memory) and took 125 ms because of its 200 round trips; reading the whole result held 200,000 rows (+45 MB) and took 82 ms.benchmarks/pg_binary_params_bench.cr, statements per second withbinary_paramsoff / on (medians of 7 rounds of 5000 statements, encoding included):
| Workload | Text | Binary |
|---|---|---|
| six scalars | 15,751 | 18,358 |
one int8 / timestamptz / numeric |
19,958 / 18,431 / 19,176 | 19,509 / 18,717 / 19,385 |
| seven-column insert | 12,902 | 13,654 |
int8[1000] / text[200] / numeric[200] |
1,944 / 7,224 / 5,113 | 3,009 / 8,339 / 5,877 |
timestamptz[500] |
904 | 4,929 |
A single value is within run-to-run noise, so binary_params stays off by default. To run both scripts on your own
server, see benchmarks/README.md.
Model reads and writes on in-memory SQLite, from benchmarks/sqlite_bench.cr:3
| Operation | Time | Allocated |
|---|---|---|
ArUser.find(id) |
1.13 µs | 480 B |
ArUser.last |
3.31 µs | 3.25 kB |
ArUser.query.exists?(age: 30) |
2.78 µs | 3.1 kB |
create! |
3.57 µs | 2.44 kB |
update! |
3.32 µs | 2.05 kB |
query.from(:users).where(id: 42).to_sql |
1.27 µs | 1.89 kB |
join with order and limit, to_sql |
2.28 µs | 3.73 kB |
100 parents, preload(:posts) |
222 µs | 165 kB |
Bytes per operation repeat from run to run; time per operation depends on the machine. How CQL Keeps Queries Fast explains what each query costs and what is off by default.
The guide is published at azutopia.gitbook.io/cql and organized by what you need:
| Section | For | Start here |
|---|---|---|
| Tutorials | learning CQL step by step, from a first app to a blog | docs/tutorials/ |
| How-to guides | one task each: models, queries, migrations, caching, performance, PostgreSQL | docs/how-to/ |
| Reference | every option, DSL method and error, and a one-page cheat sheet | docs/reference/ |
| Explanation | why CQL works the way it does: Active Record, guardrails, performance | docs/explanation/ |
| Resources | FAQ and contributing | docs/resources/ |
Every public type and method is in the API reference, generated from the doc
comments in src/ for each release. There are also installation notes,
runnable examples and a playground.
Important
CQL is pre-1.0 (0.0.x). CI tags and releases every merge to master, and a release can change behavior. Read
the changelog before you upgrade.
Bug reports, questions and pull requests are welcome. Open an issue to report a problem or discuss a change. To work on CQL itself:
shards install --ignore-crystal-version
crystal spec # SQLite runs live; PostgreSQL specs need DATABASE_URL
crystal tool format --check src spec
crystal run lib/ameba/bin/ameba.cr -- -f progress ./src ./spec # lint, as CI runs itNote
Without a server, the PostgreSQL and Redis specs are pending.
A bug fix comes with a spec that fails without it. A behavior change adds a CHANGELOG.md entry under
[Unreleased] and updates the matching page in docs/. Contributing has the full
workflow.
CQL is released under the MIT License.