active_record.cr
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: 283/283 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_dumpdumps the DB schema todb/schema.yml(columns/types/nullability) — input for compile-time codegen.- A model class
< ActiveRecord::Baseregisters itself at compile time (macro inherited); at the end of the program a{{ run(...) }}runner readsdb/schema.yml(envCRAILS_SCHEMA) and generates attributes, the row mapper,initialize(**attrs),attributes/[]/inspect/reload!. Ruby's runtimedefine_methodbecomes 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 fromnew(**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); 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 (8.1, oracle-verified) |
✅ |
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, noconstantize, 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).
active_record.cr
- 0
- 0
- 0
- 3
- 3
- about 1 hour ago
- October 12, 2026
Wed, 14 Oct 2026 12:39:30 GMT