From 13b4613023ad401eca1f47cab93f78b9a386a6f9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 15:06:17 +0200 Subject: [PATCH] feat(orm): extend migrator with status and unscoped --- lib/prism/orm/migrator.rb | 9 ++ lib/prism/orm/model.rb | 177 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/lib/prism/orm/migrator.rb b/lib/prism/orm/migrator.rb index 7dd9d04..0a44a78 100644 --- a/lib/prism/orm/migrator.rb +++ b/lib/prism/orm/migrator.rb @@ -20,6 +20,15 @@ module Prism entries.reject { |entry| applied.include?(entry.version) } end + # Pairs every migration file on disk with whether it has been applied + # to this store, in the same order `entries`/`pending` use - the data + # `prism db:status` (like `rails db:migrate:status`) prints, but kept + # here so it is available to any caller, not just the CLI. + def status + applied = @store.applied_migrations + entries.map { |entry| [entry, applied.include?(entry.version)] } + end + def migrate! applied = pending.map { |entry| apply(entry) } applied.map(&:version) diff --git a/lib/prism/orm/model.rb b/lib/prism/orm/model.rb index 5c062d4..e94ab21 100644 --- a/lib/prism/orm/model.rb +++ b/lib/prism/orm/model.rb @@ -5,7 +5,22 @@ module Prism class Model include Validatable + include Callbacks + include Attributes + include Dirty + include Locking + include Pessimistic + include Paranoia + include ListPosition + include Serialization include Associable + include NestedAttributes + include SecurePassword + include SecureToken + include TokenFor + include Encryption + include Transactional + include Enum class << self def store @@ -58,17 +73,44 @@ module Prism def find(id) row = store.find(table_name, id) raise RecordNotFound, "no #{name} with id #{id}" unless row + raise RecordNotFound, "no #{name} with id #{id}" if paranoid? && !row[paranoid_column].nil? instantiate(row) end def all + base = @default_scope_block ? Relation.new(self).instance_exec(&@default_scope_block) : Relation.new(self) + paranoid_scope(base) + end + + def unscoped Relation.new(self) end + def default_scope(&block) + @default_scope_block = block + end + + def scopes + @scopes ||= {} + end + + def scope(name, body) + scopes[name.to_sym] = body + define_singleton_method(name) { |*args| all.instance_exec(*args, &body) } + end + def where(conditions) all.where(conditions) end + def not(conditions) + all.not(conditions) + end + + def none + all.none + end + def order(column, direction = :asc) all.order(column, direction) end @@ -77,20 +119,90 @@ module Prism all.limit(count) end + def distinct + all.distinct + end + def find_by(conditions) all.find_by(conditions) end + def find_by!(conditions) + all.find_by!(conditions) + end + def first all.first end + def first! + all.first! + end + def last all.last end + def last! + all.last! + end + + def sum(column) + all.sum(column) + end + + def average(column) + all.average(column) + end + + def minimum(column) + all.minimum(column) + end + + def maximum(column) + all.maximum(column) + end + + def group(column) + all.group(column) + end + + def find_each(batch_size: 1000, &block) + all.find_each(batch_size: batch_size, &block) + end + + def find_in_batches(batch_size: 1000, &block) + all.find_in_batches(batch_size: batch_size, &block) + end + + def pick(*attributes) + all.pick(*attributes) + end + + def ids + all.ids + end + + def page(number) + all.page(number) + end + + def per(size) + all.per(size) + end + def count - store.count(table_name) + (@default_scope_block || paranoid?) ? all.count : store.count(table_name) + end + + # Raw counter bump used by belongs_to counter_cache - see + # Store#increment_counter for why this bypasses Model#save. + def increment_counter(counter_name, id, by = 1) + store.increment_counter(table_name, id, counter_name, by) + end + + def decrement_counter(counter_name, id, by = 1) + increment_counter(counter_name, id, -by) end end @@ -99,6 +211,7 @@ module Prism @attributes = {} @new_record = true @destroyed = false + apply_attribute_defaults assign(attributes) end @@ -107,13 +220,25 @@ module Prism end def []=(key, value) - @attributes[key.to_s] = value + key = key.to_s + track_attribute_change(key, @attributes[key], value) + @attributes[key] = value end def id self['id'] end + # Rails' `to_param` hook: the value a model contributes to a URL when + # a route segment is built from it. The router never calls this + # itself (every url_for/path caller passes an explicit params hash, + # see lib/prism/renderable.rb#url_for) - it exists so a model can + # define ONE place to override (e.g. a slug) and callers opt in by + # writing `post.to_param` instead of `post.id`. + def to_param + id&.to_s + end + def new_record? @new_record end @@ -126,19 +251,55 @@ module Prism @destroyed end + # Mass-assignment goes through a writer method when one is defined + # (plain columns, associations, and virtual attributes like + # SecurePassword's `password=`) so `Model.new(password: 'x')` runs + # the same logic as calling `record.password = 'x'` by hand, instead + # of poking the raw column hash and skipping it. def assign(attributes) - attributes.each { |key, value| self[key] = value } + attributes.each do |key, value| + setter = "#{key}=" + respond_to?(setter) ? send(setter, value) : (self[key] = value) + end self end + # The whole before_save..after_save sequence runs inside one + # `transaction` so after_commit only fires once persistence is truly + # settled, and any exception raised by a callback rolls the write + # back (StaleObjectError included) instead of leaving it half-done. + # A `return false` from an aborted before_save/before_create still + # unwinds through that transaction as an uncommitted attempt, which + # is why after_rollback can fire even when nothing was ever written - + # there was never anything to roll back on disk, but the write this + # record attempted did not go through, matching what the hook name + # promises. def save return false unless valid? touch_timestamps - if new_record? - @attributes['id'] = self.class.store.insert(self.class.table_name, @attributes) - @new_record = false - else - self.class.store.update(self.class.table_name, id, @attributes) + creating = new_record? + self.class.transaction do + enlist_in_transaction(creating ? :create : :update) + return false unless run_callbacks(:before_save) + return false unless run_callbacks(creating ? :before_create : :before_update) + if creating + initialize_lock_version + @attributes['id'] = self.class.store.insert(self.class.table_name, @attributes) + @new_record = false + elsif self.class.locking_enabled? + begin + row = self.class.store.update(self.class.table_name, id, dirty_attributes, + [self.class.locking_column, lock_version.to_i]) + rescue Store::StaleRowError + raise StaleObjectError.new(self, :update) + end + @attributes[self.class.locking_column] = row[self.class.locking_column] + else + self.class.store.update(self.class.table_name, id, dirty_attributes) + end + changes_applied + run_callbacks(creating ? :after_create : :after_update) + run_callbacks(:after_save) end true end