github.com/OrelSokolov/active_record.cr

No description declared in shard.yml.

0 stars
13 dependents
License: not declared

Nothing has been indexed for 0.1.1 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.

Installation

# Add this to your shard.yml
dependencies:
  active_record_cr:
    github: OrelSokolov/active_record.cr
    version: ~> 0.1.1

Then run:

shards install

shard.yml

No shard.yml has been indexed for 0.1.1. You can read it on the repository.

Dependencies

Unknown: the shard.yml for this version has not been read yet.

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

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

  1. bin/ar_schema_dump dumps the DB schema to db/schema.yml (columns/types/nullability) — input for compile-time codegen.
  2. 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.
  3. 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

FeatureOriginal (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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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

FeatureOriginalPortStatus
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)

FeatureOriginalactiverecord encryptionPortStatus
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).