active_record.cr
A Crystal port of Rails ActiveRecord 8.1.4 for PostgreSQL — models,
Relation querying, validations, callbacks, associations, transactions,
migrations, schema dumping, STI, enums/stores and ActiveRecord::Encryption.
Namespace ActiveRecord mirrors the original 1:1. Status: 292/292
oracle specs green (crud/dirty/callbacks/validations/associations/
transactions/persistence/proxy, through + polymorphic + HABTM +
counter_cache/touch/inverse_of, includes/preload/eager_load, optimistic
locking, STI, enum/store/alias_attribute/attr_readonly/cache_key/
has_secure_token, nested attributes, serialize, autosave), plus
migrations, db/schema.cr dump (byte-identical to the schema.rb
oracle) and encryption.
⚠️ EXPERIMENTAL FRAMEWORK, DO NOT USE IN PRODUCTION YET.
How it works
bin/ar_schema_dump dumps the DB schema to db/schema.yml
(columns/types/nullability) — input for compile-time codegen.
- A model class
< ActiveRecord::Base registers itself at compile time
(macro inherited); at the end of the program a {{ run(...) }}
runner reads db/schema.yml (env CRAILS_SCHEMA) and generates
attributes, the row mapper, initialize(**attrs),
attributes/[]/inspect/reload!. Ruby's runtime define_method
becomes compile-time code generation — this is the key mechanism of
the whole port.
- PostgreSQL → Crystal type mapping:
int* → Int64, float8 →
Float64, numeric → PG::Numeric, timestamp* → Time,
json/jsonb → JSON::Any, bytea → Bytes, arrays → Array(...).
Caveat: columns named after Crystal keywords (until) get getters/
setters but are excluded from new(**kwargs).
Quick start
shards install
# 1) dump the DB schema to db/schema.yml (needed for codegen)
DATABASE_URL='postgres://root@/data_lake_development?host=/var/run/postgresql' \
bin/ar_schema_dump
# 2) demo read
crystal run examples/datalake_read.cr
Feature comparison vs activerecord 8.1.4
Connections & schema
| Feature | Original (activerecord) | Port (active_record.cr) | Status |
|---|
establish_connection (URL / config / database.yml subset), DATABASE_URL, unix sockets, schema_search_path, connection pool (pool: Rails semantics, default 5) | ✅ | Same | ✅ |
Attribute codegen name / name= / name? / name_was / name_changed? | ✅ | {{ run }} from schema.yml | ✅ |
table_name=, primary_key= | ✅ | Custom PK lives in @id (w.uid → w.id) | 🔶 |
attribute :x, default: | ✅ | Applies to new records only (NULL from DB stays nil, oracle-verified); type argument accepted but ignored (types come from the schema) | 🔶 |
| Runtime attribute methods without recompilation | ✅ | Not possible — equivalent: crails watch (rebuild) | ❌ |
Relation / querying
| Feature | Original | Port | Status |
|---|
where/not/order/limit/offset/reverse_order (String+args, hash, Range→BETWEEN, Array→IN, nil→IS NULL) | ✅ | Same | ✅ |
each, empty?, count(+group), size, pluck, pick, ids | ✅ | count with GROUP BY → Hash | ✅ |
take, first, first!, last, sole, to_sql | ✅ | Same | ✅ |
exists? (no args / id / conditions) | ✅ | Same | ✅ |
find_each, find_in_batches, in_batches(of:) | ✅ | Same | ✅ |
find, find?, find_by, find_by!, first_or_create, find_or_create_by | ✅ | Nilable finders cannot be chained (types) | 🔶 |
sum / average / minimum / maximum | ✅ | average → PG::Numeric (2 digits) | ✅ |
or, merge, group, having, distinct | ✅ | or checks structural compatibility | ✅ |
joins, left_outer_joins | ✅ | has_many/has_one FK convention | ✅ |
includes, preload, eager_load, references | ✅ | IN-queries + memory preload; eager_load as one LEFT OUTER JOIN with AS c_<col> aliases and owner dedup; references switches strategy (Rails semantics); polymorphic/through load lazily | ✅ |
select, reselect | ✅ | Simple columns; unloaded attribute → nil (Rails: MissingAttributeError); raw SQL fragments cannot be materialized into records | 🔶 |
reorder, in_order_of | ✅ | ARRAY_POSITION | ✅ |
update_all, delete_all, destroy_all, touch_all | ✅ | Relation and class level | ✅ |
none, except/only, annotate, explain | ✅ | explain → String (Rails: ExplainProxy) | 🔶 |
lock ("FOR UPDATE"), readonly, strict_loading | ✅ | readonly save/destroy → ReadOnlyRecord; strict_loading → StrictLoadingViolationError | ✅ |
scope :name do ... end (with parameters) | ✅ | Block-body splice | ✅ |
default_scope, unscoped | ✅ | default_scope: single where(...) only (macro recursion limit) | 🔶 |
Dynamic finders find_by_email | ✅ | No method_missing — use find_by(email:) | ❌ |
Validations
| Feature | Original | Port | Status |
|---|
presence / length / numericality / format / uniqueness (+scope:) | ✅ | Same | ✅ |
| inclusion, exclusion, acceptance, confirmation, comparison | ✅ | Rails 8 acceptance skips nil | ✅ |
validate (method/block), valid?(context) / invalid? | ✅ | Default context :create/:update | ✅ |
on:, allow_blank:, allow_nil:, strict: | ✅ | strict → StrictValidationFailed | ✅ |
if: / unless: | ✅ | Ruby keywords — named args condition: / unless_condition: | 🔶 |
errors: add/full_messages/where/details/group_by_attribute | ✅ | details keys are Strings (no runtime Symbols) | 🔶 |
around_validation | — | Does not exist in Rails 8 | — |
| numericality of a String into an Int column | ✅ | Will not compile (documented) | ❌ |
Callbacks
| Feature | Original | Port | Status |
|---|
before/after _validation/_save/_create/_update/_destroy | ✅ | Order oracle-verified | ✅ |
around_save/create/update/destroy | ✅ | Block or symbol form, order 1:1 | ✅ |
after_initialize, after_find, after_touch | ✅ | after_find fires before after_initialize on load (1:1) | ✅ |
after_commit, after_rollback (+ on: filter) | ✅ | Real COMMIT/ROLLBACK incl. savepoints | ✅ |
Associations
| Feature | Original | Port | Status |
|---|
belongs_to (+ build_/create_), has_many, has_one (+dependent), options foreign_key, class_name, optional | ✅ | Same | ✅ |
CollectionProxy each/count/size/empty?/ids/pluck/build/create/create!/destroy_all/delete_all/order?/where? | ✅ | Same | ✅ |
CollectionProxy <<, concat, include?, destroy, delete, replace | ✅ | delete/replace (nullify) implemented but not oracle-pinned (FK NOT NULL in scratch schema) | 🔶 |
<assoc>_ids | ✅ | Codegen | ✅ |
through / source | ✅ | Subquery mid_fk IN (SELECT …) | ✅ |
Polymorphic (as:) | ✅ | Param as_name: (as is a keyword); compile-time POLY_OWNERS/POLY_USERS registry | 🔶 |
has_and_belongs_to_many | ✅ | Join naming from both table_names | ✅ |
counter_cache, inverse_of, autosave, touch | ✅ | autosave stores children in @loaded_assocs (semantics 1:1 on oracle cases) | ✅ |
primary_key: / validate: association options | ✅ | Not ported | ⬜ |
Runtime class_name: "Foo" for arbitrary strings | ✅ | Compile-time known classes only | ❌ |
Persistence & dirty
| Feature | Original | Port | Status |
|---|
new, create(_!), save(_!), update(_!), assign_attributes, attributes | ✅ | Persistence methods take a Hash, not keywords | 🔶 |
destroy, persisted?, new_record?, frozen? | ✅ | Same | ✅ |
dirty: changed?/changed/changes/saved_changes/will_save_change_to_x? | ✅ | Same | ✅ |
update_columns, touch(*cols), insert_all(_!), upsert_all | ✅ | Same | ✅ |
reload, update_attribute, increment/decrement(+!), toggle(+!), restore_attributes, previous_changes, instance delete, becomes | ✅ | Return-value semantics 1:1 | ✅ |
| Virtual attributes | ✅ | Direct setter only (no runtime method magic) | 🔶 |
Transactions & locking
| Feature | Original | Port | Status |
|---|
transaction (requires_new:, isolation:) | ✅ | Connection pinned to the fiber; joined-nested Rollback does not roll back (matches 8.1.4, verified on live Rails) | ✅ |
with_lock, lock! | ✅ | SELECT FOR UPDATE on the pinned connection | ✅ |
Optimistic locking (lock_version) | ✅ | UPDATE ... AND lock_version = $N; 0 rows → StaleObjectError | ✅ |
Migrations & schema
| Feature | Original | Port | Status |
|---|
create_table (all types, timestamps, references, index), id: false, column ops, add_index (unique:, name:, where:), add_reference, FKs, execute, reversible change / up-down, change_table, create_join_table, check constraints, reversible blocks | ✅ | Same; partial indexes oracle-verified via dumps | ✅ |
crails db:migrate / rollback / schema:dump / schema:load | ✅ | Dumps db/schema.cr (primary) + db/schema.yml (codegen input; columns + indexes with name/columns/unique/where, implicit PK skipped); schema:load recompiles a runner (~1 s, deliberate deviation from instant ruby db/schema.rb) | 🔶 |
db/schema.cr ↔ schema.rb | ✅ | Byte-identical syntax to 8.1.4 (ActiveRecord::Schema[8.1].define, force: :cascade, t.index, t.check_constraint, enable_extension, add_foreign_key); differences: require "active_record_cr" header, typed empty literals ([] of Int64); expression defaults (now()) are not dumped | 🔶 |
Enums, stores, STI, misc
| Feature | Original | Port | Status |
|---|
enum status: [...] → predicates/bang-methods/scopes | ✅ | Macro ar_enum (enum is a keyword); getter returns String, unmapped DB values read as nil (8.1, oracle-verified); int and string values (index_by(&:itself)) | ✅ |
store / store_accessor | ✅ | jsonb out of the box, StoreProxy container | ✅ |
serialize | ✅ | JSON coder only (Rails' YAML default not ported); text column ↔ JSON roundtrip; mutating a read container is not tracked — assign a new one | 🔶 |
alias_attribute | ✅ | Getter/setter + where resolution | ✅ |
accepts_nested_attributes_for | ✅ | has_many with autosave semantics, allow_destroy:, reject_if: :all_blank; belongs_to form ⬜; setter is explicit (model.n_posts_attributes = [...]) | 🔶 |
cache_key, has_secure_token, attr_readonly, to_param, signed_id | ✅ | cache_key ✅, has_secure_token ✅ (24 hex), attr_readonly ✅ (silently ignored, oracle-verified); to_param ⬜; signed_id ❌ (MessageVerifier not available outside a Rails app) | 🔶 |
STI (type column) | ✅ | One inheritance level: __read_row dispatch + becomes | ✅ |
Runtime constantize | ✅ | Compile-time registry only | ❌ |
Encryption (phase G)
| Feature | Original | activerecord encryption | Port | Status |
|---|
| AES-256-GCM cipher, deterministic IV (HMAC-SHA256), Message + serializer (byte-exact JSON envelope), compression >140 B | ✅ | Same (EVP aes-256-gcm) | ✅ | |
| Key providers: DerivedSecretKeyProvider / DeterministicKeyProvider (PBKDF2-HMAC-SHA1 2**16, AS::KeyGenerator) | ✅ | Same | ✅ | |
encrypts macro, Model.encrypted_attributes, #ciphertext_for, Model.where_encrypted | ✅ | Deterministic lookup (analog of ExtendedDeterministicQueries) | ✅ | |
Config: configure || ENV ACTIVE_RECORD_ENCRYPTION_* || defaults | ✅ | Same (ENV support is an extension) | ✅ | |
Envelope encryption / key references (k/i headers), previous: schemes, ignore_case original_* columns, NullEncryptor contexts | ✅ | Not ported | ⬜ | |
Deviations & Crystal-specific notes
- No
method_missing, no runtime Symbols, no constantize, static
typing — these power most ❌ rows above; equivalents are compile-time
registries and codegen.
- Nilable finders cannot be unwrapped into a chain; persistence methods
accept
Hash, not keywords; virtual attributes via direct setters
(details in the linked SEMANTICS doc of the umbrella project).
- Class variables are not shared with subclasses — state is accessed via
explicit
Base. receivers; transactions pin a connection to the fiber.
Testing
# fixture DB crails_ar_test + datalake read-only
psql -h /var/run/postgresql -U root -d postgres -c 'create database crails_ar_test owner root'
psql -h /var/run/postgresql -U root -d crails_ar_test -f spec/fixtures/seed.sql
crystal spec
Oracle-first development: oracle/cases.rb + oracle/migrations.rb /
oracle/schema.rb are recorded against real activerecord 8.1.4;
mirror specs in spec/oracle_spec.cr / spec/migrations_spec.cr /
spec/schema_spec.cr reproduce the expected files without editing the
cases. Encryption parity: 26 Ruby cases + a reverse check where the real
gem decrypts port ciphertexts (oracle/verify_encryption.rb).