From c9c729fba3ae9ca83fad1c7aa118b808da61d30b Mon Sep 17 00:00:00 2001 From: Mechetel Date: Wed, 13 May 2026 17:35:35 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20v2.0.0=20=E2=80=94=20drop=20Virtus,=20m?= =?UTF-8?q?odernize=20gem=20for=20Rails=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace abandoned Virtus library with custom Rectify::Attributable module backed by plain Ruby (no external dep), supporting typed attributes, type coercion, nested forms, and array-of-form attributes - Add Rectify::AttributeDefinition replacing Virtus::Attribute introspection - Add Rectify::Context (struct-like) replacing OpenStruct for form context - Remove eval() in Command#evaluate — use block.binding.receiver instead - Update FormAttribute, BuildFormFromModel, FormatAttributesHash to use new attribute_set API - Fix DatabaseReporter SQL_TO_IGNORE to exclude sqlite_version() query emitted by Rails 8 SQLite adapter on first connection - Remove virtus (~> 2.0) and its stale transitive deps (axiom-types, coercible, descendants_tracker) from gemspec - Bump version to 2.0.0 - All 114 specs pass, rubocop clean Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 64 +++++++ Gemfile.lock | 25 +-- Rakefile | 14 +- lib/rectify.rb | 3 +- lib/rectify/attributable.rb | 163 ++++++++++++++++++ lib/rectify/command.rb | 2 +- lib/rectify/context.rb | 19 ++ lib/rectify/form.rb | 6 +- lib/rectify/form_attribute.rb | 22 ++- lib/rectify/format_attributes_hash.rb | 6 +- lib/rectify/presenter.rb | 2 +- .../rspec/database_reporter/reporter.rb | 1 + lib/rectify/version.rb | 2 +- rectify.gemspec | 1 - spec/spec_helper.rb | 2 +- 15 files changed, 283 insertions(+), 49 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib/rectify/attributable.rb create mode 100644 lib/rectify/context.rb diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f4df05 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is Rectify + +Rectify is a Ruby gem (v1.1.0) that provides lightweight patterns for building maintainable Rails apps: Form Objects, Commands, Presenters, and Query Objects. It's built on Virtus (attribute definitions/coercion) and Wisper (pub/sub for Commands). Requires Ruby >= 3.0, Rails >= 7.2.2. + +## Commands + +```bash +# Install dependencies +bundle install + +# Run all specs +bundle exec rspec + +# Run a single spec file +bundle exec rspec spec/lib/rectify/form_spec.rb + +# Run a specific example by line number +bundle exec rspec spec/lib/rectify/form_spec.rb:42 + +# Lint +bundle exec rubocop + +# Lint with auto-fix +bundle exec rubocop -A + +# Database management (for test SQLite DB used by query specs) +rake db:migrate +rake db:schema +rake generate:migration[migration_name] +``` + +Note: `bundle exec rspec` automatically runs `rake db:migrate` before specs (called in spec_helper.rb). + +## Architecture + +All gem code lives in `lib/rectify/`. The four core components: + +- **Form** (`form.rb`) — Virtus-based form objects with ActiveModel validations, replacing Strong Parameters. Supports population from params, models, and JSON. Nested form validation and deep context passing via `#with_context`. +- **Command** (`command.rb`) — Wisper-based service objects. `.call` instantiates and invokes `#call`, broadcasting events (`:ok`, `:invalid`, etc.) handled via `on(:event)` blocks in controllers. +- **Presenter** (`presenter.rb`) — Virtus-based view models with access to view helpers via `#attach_controller`. Used with `Rectify::ControllerHelpers#present`. +- **Query** (`query.rb`) — Wraps `ActiveRecord::Relation` or raw SQL results. Supports composition via `|` (set union/merge). `NullQuery` serves as identity element. + +Supporting modules: +- `ControllerHelpers` (`controller_helpers.rb`) — provides `present`, `presenter`, and `expose` methods for controllers +- `SqlQuery` (`sql_query.rb`) — mixin for raw SQL query objects (define `model`, `sql`, `params` methods) +- `BuildFormFromModel` / `FormAttribute` / `FormatAttributesHash` — internal form-building machinery +- `RSpec helpers` (`rspec/`) — `stub_query`, `stub_command`, `DatabaseReporter` + +## Test Setup + +- Specs are in `spec/lib/rectify/`, fixtures in `spec/fixtures/` +- Uses SQLite (`spec/db/development.sqlite3`), configured in `spec/config/database.yml` +- Each test runs in a transaction that rolls back (see `spec_helper.rb`) +- Version is in `lib/rectify/version.rb` + +## Style + +- RuboCop with rubocop-performance, rubocop-rspec, rubocop-rake +- 2-space indentation, no class documentation required (`Style/Documentation: false`) +- Hash rocket syntax (`:key => value`) is used throughout the codebase, not symbol shorthand diff --git a/Gemfile.lock b/Gemfile.lock index 8194385..d993849 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,11 @@ PATH remote: . specs: - rectify (1.0.5) - actionpack (~> 8.0) - activemodel (~> 8.0) - activerecord (~> 8.0) - activesupport (~> 8.0) - virtus (~> 2.0) + rectify (2.0.0) + actionpack (>= 7.2.2) + activemodel (>= 7.2.2) + activerecord (>= 7.2.2) + activesupport (>= 7.2.2) wisper (~> 3.0) GEM @@ -49,28 +48,19 @@ GEM uri (>= 0.13.1) ast (2.4.2) awesome_print (1.9.2) - axiom-types (0.1.1) - descendants_tracker (~> 0.0.4) - ice_nine (~> 0.11.0) - thread_safe (~> 0.3, >= 0.3.1) base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.8) builder (3.3.0) coderay (1.1.3) - coercible (1.0.0) - descendants_tracker (~> 0.0.1) concurrent-ruby (1.3.3) connection_pool (2.4.1) crass (1.0.6) - descendants_tracker (0.0.4) - thread_safe (~> 0.3, >= 0.3.1) diff-lcs (1.5.1) drb (2.2.1) erubi (1.13.0) i18n (1.14.5) concurrent-ruby (~> 1.0) - ice_nine (0.11.2) json (2.7.2) language_server-protocol (3.17.0.3) logger (1.7.0) @@ -148,17 +138,12 @@ GEM sqlite3 (2.6.0-x86_64-darwin) sqlite3 (2.6.0-x86_64-linux-gnu) strscan (3.1.0) - thread_safe (0.3.6) timeout (0.4.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) uri (1.0.3) useragent (0.16.11) - virtus (2.0.0) - axiom-types (~> 0.1) - coercible (~> 1.0) - descendants_tracker (~> 0.0, >= 0.0.3) wisper (3.0.0) wisper-rspec (1.1.0) diff --git a/Rakefile b/Rakefile index 39f1709..df4fd02 100644 --- a/Rakefile +++ b/Rakefile @@ -2,9 +2,9 @@ require 'yaml' require 'active_record' require 'fileutils' -namespace :db do +namespace :db do # rubocop:disable Metrics/BlockLength desc 'Migrate the database' - task :migrate, [:environment] => :load_config do |t, args| + task :migrate, [:environment] => :load_config do |_t, _args| migration_path = 'spec/db/migrate' ActiveRecord::Migration.verbose = true @@ -32,14 +32,10 @@ namespace :db do task :load_config do env = ENV['RAILS_ENV'] || 'development' config_path = 'spec/config/database.yml' - unless File.exist?(config_path) - raise "❌ Database config file not found: #{config_path}" - end + raise "❌ Database config file not found: #{config_path}" unless File.exist?(config_path) - db_config = YAML.safe_load(File.read(config_path), aliases: true)[env] - unless db_config - raise "❌ No configuration found for environment: #{env}" - end + db_config = YAML.safe_load_file(config_path, aliases: true)[env] + raise "❌ No configuration found for environment: #{env}" unless db_config ActiveRecord::Base.establish_connection(db_config) end diff --git a/lib/rectify.rb b/lib/rectify.rb index bf6208b..df18e62 100644 --- a/lib/rectify.rb +++ b/lib/rectify.rb @@ -1,12 +1,13 @@ require 'bundler/setup' -require 'virtus' require 'wisper' require 'active_support/core_ext/hash' require 'active_model' require 'active_record' require 'rectify/version' +require 'rectify/attributable' +require 'rectify/context' require 'rectify/form' require 'rectify/form_attribute' require 'rectify/format_attributes_hash' diff --git a/lib/rectify/attributable.rb b/lib/rectify/attributable.rb new file mode 100644 index 0000000..f21ba38 --- /dev/null +++ b/lib/rectify/attributable.rb @@ -0,0 +1,163 @@ +module Rectify + module Attributable + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def attribute(name, type = nil, default: nil) + name = name.to_sym + + rectify_attributes[name] = AttributeDefinition.new(name, type, default) + + attr_writer name + + define_method(name) do + if instance_variable_defined?(:"@#{name}") + instance_variable_get(:"@#{name}") + else + resolve_default(self.class.rectify_attributes[name].default) + end + end + end + + def rectify_attributes + @rectify_attributes ||= if superclass.respond_to?(:rectify_attributes) + superclass.rectify_attributes.dup + else + {} + end + end + + def attribute_set + rectify_attributes.values + end + end + + def initialize(attrs = {}) + assign_attributes(attrs) + end + + def attributes + self.class.rectify_attributes.to_h do |name, _defn| + [name, public_send(name)] + end + end + + private + + def assign_attributes(attrs) + return if attrs.nil? + + normalized = attrs.transform_keys(&:to_sym) + + self.class.rectify_attributes.each do |name, defn| + raw = normalized[name] + next if raw.nil? && !normalized.key?(name) + + coerced = defn.coerce(raw) + public_send(:"#{name}=", coerced) + end + end + + def resolve_default(default) + default.respond_to?(:call) ? default.call : default + end + end + + class AttributeDefinition + attr_reader :name, :type, :default, :member_type + + def initialize(name, type, default) # rubocop:disable Metrics/MethodLength + @name = name.to_sym + @default = default + + if type.is_a?(Array) + @array = true + @type = Array + @member_type = type.first + elsif type == Array + @array = true + @type = Array + @member_type = nil + else + @array = false + @type = type + @member_type = nil + end + end + + def coerce(value) + return coerce_array(value) if array? + + coerce_value(value, type) + end + + def primitive + array? ? Array : type + end + + def array? + @array + end + + def collection? + array? + end + + def declared_class + primitive + end + + private + + def coerce_array(value) + return [] if value.nil? + + items = value.is_a?(Hash) ? value.values : Array(value) + + if @member_type.respond_to?(:new) + items.map { |item| coerce_value(item, @member_type) } + else + items + end + end + + def coerce_value(value, target_type) # rubocop:disable Metrics + return value if target_type.nil? || value.nil? + return value if target_type != String && value.is_a?(target_type) + + case target_type.to_s + when 'Integer' then coerce_integer(value) + when 'Float' then coerce_float(value) + when 'String' then value.to_s + when 'Symbol' then value.to_sym + else coerce_complex_type(value, target_type) + end + end + + def coerce_integer(value) + return nil if value.is_a?(String) && value.strip.empty? + + Integer(value) + rescue ArgumentError, TypeError + nil + end + + def coerce_float(value) + return nil if value.is_a?(String) && value.strip.empty? + + Float(value) + rescue ArgumentError, TypeError + nil + end + + def coerce_complex_type(value, target_type) + if value.is_a?(Hash) && target_type.respond_to?(:new) + target_type.new(value) + else + value + end + end + end +end diff --git a/lib/rectify/command.rb b/lib/rectify/command.rb index f967858..cff4e5b 100644 --- a/lib/rectify/command.rb +++ b/lib/rectify/command.rb @@ -31,7 +31,7 @@ def self.call(*args, **kwargs, &block) end def evaluate(&block) - @caller = eval('self', block.binding, __FILE__, __LINE__) + @caller = block.binding.receiver instance_eval(&block) end diff --git a/lib/rectify/context.rb b/lib/rectify/context.rb new file mode 100644 index 0000000..54a4ec1 --- /dev/null +++ b/lib/rectify/context.rb @@ -0,0 +1,19 @@ +module Rectify + class Context + def initialize(data = {}) + @data = data.transform_keys(&:to_sym) + + @data.each do |key, value| + define_singleton_method(key) { value } + end + end + + def [](key) + @data[key.to_sym] + end + + def to_h + @data.dup + end + end +end diff --git a/lib/rectify/form.rb b/lib/rectify/form.rb index c5e368f..78e90c8 100644 --- a/lib/rectify/form.rb +++ b/lib/rectify/form.rb @@ -1,6 +1,6 @@ module Rectify class Form - include Virtus.model + include Attributable include ActiveModel::Validations attr_reader :context @@ -98,7 +98,7 @@ def attributes end def attributes_with_values - attributes.reject { |attribute| public_send(attribute).nil? } + attributes.compact end def map_model(model) @@ -114,7 +114,7 @@ def before_validation def with_context(new_context) @context = if new_context.is_a?(Hash) - OpenStruct.new(new_context) # rubocop:disable Style/OpenStructUse + Context.new(new_context) else new_context end diff --git a/lib/rectify/form_attribute.rb b/lib/rectify/form_attribute.rb index ac8003a..6f0103f 100644 --- a/lib/rectify/form_attribute.rb +++ b/lib/rectify/form_attribute.rb @@ -1,5 +1,15 @@ module Rectify - class FormAttribute < SimpleDelegator + class FormAttribute + attr_reader :definition + + def initialize(definition) + @definition = definition + end + + def name + definition.name + end + def value_from(model_value) return declared_class.from_model(model_value) if form_object? @@ -17,19 +27,15 @@ def form_object? end def collection_of_form_objects? - collection? && element_class.respond_to?(:from_model) - end - - def collection? - type.respond_to?(:member_type) + definition.array? && element_class.respond_to?(:from_model) end def element_class - type.member_type + definition.member_type end def declared_class - primitive + definition.primitive end end end diff --git a/lib/rectify/format_attributes_hash.rb b/lib/rectify/format_attributes_hash.rb index 4aec8ab..6569860 100644 --- a/lib/rectify/format_attributes_hash.rb +++ b/lib/rectify/format_attributes_hash.rb @@ -23,13 +23,13 @@ def convert_indexed_hashes_to_arrays(attributes_hash) attributes_hash[name] = transform_values_for_type( attribute.values, - array_attribute.member_type.primitive + array_attribute.member_type ) end end def transform_values_for_type(values, element_type) - return values unless element_type < Rectify::Form + return values unless element_type && element_type < Rectify::Form values.map do |value| self.class.new(element_type.attribute_set).format(value) @@ -37,7 +37,7 @@ def transform_values_for_type(values, element_type) end def array_attributes - attribute_set.select { |attribute| attribute.primitive == Array } + attribute_set.select(&:array?) end def convert_hash_keys(value) diff --git a/lib/rectify/presenter.rb b/lib/rectify/presenter.rb index b1acc83..1079f5b 100644 --- a/lib/rectify/presenter.rb +++ b/lib/rectify/presenter.rb @@ -1,6 +1,6 @@ module Rectify class Presenter - include Virtus.model + include Attributable def attach_controller(controller) @controller = controller diff --git a/lib/rectify/rspec/database_reporter/reporter.rb b/lib/rectify/rspec/database_reporter/reporter.rb index f92afc4..7928afd 100644 --- a/lib/rectify/rspec/database_reporter/reporter.rb +++ b/lib/rectify/rspec/database_reporter/reporter.rb @@ -8,6 +8,7 @@ class DatabaseReporter current_database| information_schema| sqlite_master| + sqlite_version| ^TRUNCATE TABLE| ^ALTER TABLE| ^BEGIN| diff --git a/lib/rectify/version.rb b/lib/rectify/version.rb index 9052470..42f7c89 100644 --- a/lib/rectify/version.rb +++ b/lib/rectify/version.rb @@ -1,3 +1,3 @@ module Rectify - VERSION = '1.1.0'.freeze + VERSION = '2.0.0'.freeze end diff --git a/rectify.gemspec b/rectify.gemspec index c1a747e..bee2dbd 100644 --- a/rectify.gemspec +++ b/rectify.gemspec @@ -28,6 +28,5 @@ Gem::Specification.new do |spec| spec.add_dependency 'activemodel', '>= 7.2.2' spec.add_dependency 'activerecord', '>= 7.2.2' spec.add_dependency 'activesupport', '>= 7.2.2' - spec.add_dependency 'virtus', '~> 2.0' spec.add_dependency 'wisper', '~> 3.0' end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d992a13..7a130b7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,7 +12,7 @@ system('rake db:migrate') env = ENV['RAILS_ENV'] || 'development' -db_config = YAML.safe_load(File.read('spec/config/database.yml'), aliases: true)[env] +db_config = YAML.safe_load_file('spec/config/database.yml', aliases: true)[env] ActiveRecord::Base.establish_connection(db_config) RSpec.configure do |config|