From 4f2807a1f61397dab13121a8e99af422f8847650 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 20:18:27 +0200 Subject: [PATCH] feat(orm): add Prism and Orm --- lib/prism/orm/attributes.rb | 73 +++++++++++++++++++++++++++++++++++++ lib/prism/orm/callbacks.rb | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 lib/prism/orm/attributes.rb create mode 100644 lib/prism/orm/callbacks.rb diff --git a/lib/prism/orm/attributes.rb b/lib/prism/orm/attributes.rb new file mode 100644 index 0000000..ea790b9 --- /dev/null +++ b/lib/prism/orm/attributes.rb @@ -0,0 +1,73 @@ +module Prism + module Orm + # ActiveModel::Attributes-lite: `attribute :featured, :boolean` casts + # every read AND write of that column through TypeCasting, and + # optionally seeds a default before mass-assignment runs. + # + # Deliberately opt-in per attribute rather than inferred from the + # migration's declared column type (`t.boolean :featured`) - Store's + # schema has never tracked column types past table creation + # (TableDefinition#column discards its `type` argument), and making + # every column on every model suddenly cast automatically would be a + # behavior change no existing model/test asked for. Declaring it + # explicitly is the same shape as SecurePassword's `password=` or + # SecureToken's `has_secure_token`: a plain writer method already + # defined before Model.load_schema! runs, so load_schema! leaves it + # alone instead of overwriting it with a raw passthrough (see its + # `next if method_defined?(...)` guard). + module Attributes + NO_DEFAULT = Object.new.freeze + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def own_attribute_types + @own_attribute_types ||= {} + end + + def own_attribute_defaults + @own_attribute_defaults ||= {} + end + + # Same superclass-plus-own fallback as Callbacks#callbacks_for/ + # Controller#before_actions/Serialization#json_hidden_attributes - + # a plain `@ivar ||= {}` per class would silently drop whatever a + # base model class declared once a subclass declared its own. + def attribute_types + inherited = superclass.respond_to?(:attribute_types) ? superclass.attribute_types : {} + inherited.merge(own_attribute_types) + end + + def attribute_defaults + inherited = superclass.respond_to?(:attribute_defaults) ? superclass.attribute_defaults : {} + inherited.merge(own_attribute_defaults) + end + + def attribute(name, type, default: NO_DEFAULT) + name = name.to_s + own_attribute_types[name] = type + own_attribute_defaults[name] = default unless default.equal?(NO_DEFAULT) + + define_method(name) { TypeCasting.cast(type, self[name]) } + define_method("#{name}=") { |value| self[name] = TypeCasting.cast(type, value) } + end + end + + private + + # Runs before mass-assignment in Model#initialize so an explicit + # value passed to .new/.create always wins over the default, and so + # a default is visible to validations/before_save callbacks even + # when the caller never mentioned the attribute at all. + def apply_attribute_defaults + self.class.attribute_defaults.each do |name, default| + next unless self[name].nil? + raw = default.is_a?(Proc) ? instance_exec(&default) : default + self[name] = TypeCasting.cast(self.class.attribute_types[name], raw) + end + end + end + end +end diff --git a/lib/prism/orm/callbacks.rb b/lib/prism/orm/callbacks.rb new file mode 100644 index 0000000..5c637c8 --- /dev/null +++ b/lib/prism/orm/callbacks.rb @@ -0,0 +1,65 @@ +module Prism + module Orm + # ActiveRecord-style lifecycle hooks: before/after_validation, _save, + # _create, _update, _destroy. A hook is either a method name (Symbol) or + # a block, run via `send`/`instance_exec` in registration order. + # + # `throw :abort` from a `before_*` hook halts the chain and makes the + # caller (valid?/save/destroy) bail out, matching Rails 5+ semantics - + # returning `false` from a hook does NOT halt anything, only the throw does. + module Callbacks + KINDS = %i[ + before_validation after_validation + before_save after_save + before_create after_create + before_update after_update + before_destroy after_destroy + before_restore after_restore + after_commit after_rollback + after_touch + ].freeze + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # `on:` restricts an after_commit/after_rollback hook to one + # operation (:create/:update/:destroy), same idea as Rails' + # `after_commit on: :create` - ignored (always matches) for every + # other kind, since those already run inside a single known phase. + KINDS.each do |kind| + define_method(kind) do |method_name = nil, on: nil, &block| + own_callbacks[kind] << { hook: (method_name || block), on: on } + end + end + + def own_callbacks + @own_callbacks ||= Hash.new { |hash, key| hash[key] = [] } + end + + # Superclass hooks run before the subclass's own, same fallback + # pattern as Controller#before_actions - a plain `@ivar ||= []` per + # class would silently drop everything a base model class declared. + def callbacks_for(kind) + inherited = superclass.respond_to?(:callbacks_for) ? superclass.callbacks_for(kind) : [] + inherited + own_callbacks[kind] + end + end + + private + + def run_callbacks(kind, operation = nil) + catch(:abort) { + self.class.callbacks_for(kind).each do |entry| + next if entry[:on] && entry[:on] != operation + hook = entry[:hook] + hook.is_a?(Proc) ? instance_exec(&hook) : send(hook) + end + return true + } + false + end + end + end +end