Skip to content

Commit 7b390d9

Browse files
authored
feat: v2.0.0 — drop Virtus, modernize gem for Rails 8
feat: v2.0.0 — drop Virtus, modernize gem for Rails 8
2 parents 274c70d + c9c729f commit 7b390d9

15 files changed

Lines changed: 283 additions & 49 deletions

CLAUDE.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What is Rectify
6+
7+
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.
8+
9+
## Commands
10+
11+
```bash
12+
# Install dependencies
13+
bundle install
14+
15+
# Run all specs
16+
bundle exec rspec
17+
18+
# Run a single spec file
19+
bundle exec rspec spec/lib/rectify/form_spec.rb
20+
21+
# Run a specific example by line number
22+
bundle exec rspec spec/lib/rectify/form_spec.rb:42
23+
24+
# Lint
25+
bundle exec rubocop
26+
27+
# Lint with auto-fix
28+
bundle exec rubocop -A
29+
30+
# Database management (for test SQLite DB used by query specs)
31+
rake db:migrate
32+
rake db:schema
33+
rake generate:migration[migration_name]
34+
```
35+
36+
Note: `bundle exec rspec` automatically runs `rake db:migrate` before specs (called in spec_helper.rb).
37+
38+
## Architecture
39+
40+
All gem code lives in `lib/rectify/`. The four core components:
41+
42+
- **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`.
43+
- **Command** (`command.rb`) — Wisper-based service objects. `.call` instantiates and invokes `#call`, broadcasting events (`:ok`, `:invalid`, etc.) handled via `on(:event)` blocks in controllers.
44+
- **Presenter** (`presenter.rb`) — Virtus-based view models with access to view helpers via `#attach_controller`. Used with `Rectify::ControllerHelpers#present`.
45+
- **Query** (`query.rb`) — Wraps `ActiveRecord::Relation` or raw SQL results. Supports composition via `|` (set union/merge). `NullQuery` serves as identity element.
46+
47+
Supporting modules:
48+
- `ControllerHelpers` (`controller_helpers.rb`) — provides `present`, `presenter`, and `expose` methods for controllers
49+
- `SqlQuery` (`sql_query.rb`) — mixin for raw SQL query objects (define `model`, `sql`, `params` methods)
50+
- `BuildFormFromModel` / `FormAttribute` / `FormatAttributesHash` — internal form-building machinery
51+
- `RSpec helpers` (`rspec/`) — `stub_query`, `stub_command`, `DatabaseReporter`
52+
53+
## Test Setup
54+
55+
- Specs are in `spec/lib/rectify/`, fixtures in `spec/fixtures/`
56+
- Uses SQLite (`spec/db/development.sqlite3`), configured in `spec/config/database.yml`
57+
- Each test runs in a transaction that rolls back (see `spec_helper.rb`)
58+
- Version is in `lib/rectify/version.rb`
59+
60+
## Style
61+
62+
- RuboCop with rubocop-performance, rubocop-rspec, rubocop-rake
63+
- 2-space indentation, no class documentation required (`Style/Documentation: false`)
64+
- Hash rocket syntax (`:key => value`) is used throughout the codebase, not symbol shorthand

Gemfile.lock

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
PATH
22
remote: .
33
specs:
4-
rectify (1.0.5)
5-
actionpack (~> 8.0)
6-
activemodel (~> 8.0)
7-
activerecord (~> 8.0)
8-
activesupport (~> 8.0)
9-
virtus (~> 2.0)
4+
rectify (2.0.0)
5+
actionpack (>= 7.2.2)
6+
activemodel (>= 7.2.2)
7+
activerecord (>= 7.2.2)
8+
activesupport (>= 7.2.2)
109
wisper (~> 3.0)
1110

1211
GEM
@@ -49,28 +48,19 @@ GEM
4948
uri (>= 0.13.1)
5049
ast (2.4.2)
5150
awesome_print (1.9.2)
52-
axiom-types (0.1.1)
53-
descendants_tracker (~> 0.0.4)
54-
ice_nine (~> 0.11.0)
55-
thread_safe (~> 0.3, >= 0.3.1)
5651
base64 (0.2.0)
5752
benchmark (0.4.0)
5853
bigdecimal (3.1.8)
5954
builder (3.3.0)
6055
coderay (1.1.3)
61-
coercible (1.0.0)
62-
descendants_tracker (~> 0.0.1)
6356
concurrent-ruby (1.3.3)
6457
connection_pool (2.4.1)
6558
crass (1.0.6)
66-
descendants_tracker (0.0.4)
67-
thread_safe (~> 0.3, >= 0.3.1)
6859
diff-lcs (1.5.1)
6960
drb (2.2.1)
7061
erubi (1.13.0)
7162
i18n (1.14.5)
7263
concurrent-ruby (~> 1.0)
73-
ice_nine (0.11.2)
7464
json (2.7.2)
7565
language_server-protocol (3.17.0.3)
7666
logger (1.7.0)
@@ -148,17 +138,12 @@ GEM
148138
sqlite3 (2.6.0-x86_64-darwin)
149139
sqlite3 (2.6.0-x86_64-linux-gnu)
150140
strscan (3.1.0)
151-
thread_safe (0.3.6)
152141
timeout (0.4.1)
153142
tzinfo (2.0.6)
154143
concurrent-ruby (~> 1.0)
155144
unicode-display_width (2.5.0)
156145
uri (1.0.3)
157146
useragent (0.16.11)
158-
virtus (2.0.0)
159-
axiom-types (~> 0.1)
160-
coercible (~> 1.0)
161-
descendants_tracker (~> 0.0, >= 0.0.3)
162147
wisper (3.0.0)
163148
wisper-rspec (1.1.0)
164149

Rakefile

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ require 'yaml'
22
require 'active_record'
33
require 'fileutils'
44

5-
namespace :db do
5+
namespace :db do # rubocop:disable Metrics/BlockLength
66
desc 'Migrate the database'
7-
task :migrate, [:environment] => :load_config do |t, args|
7+
task :migrate, [:environment] => :load_config do |_t, _args|
88
migration_path = 'spec/db/migrate'
99
ActiveRecord::Migration.verbose = true
1010

@@ -32,14 +32,10 @@ namespace :db do
3232
task :load_config do
3333
env = ENV['RAILS_ENV'] || 'development'
3434
config_path = 'spec/config/database.yml'
35-
unless File.exist?(config_path)
36-
raise "❌ Database config file not found: #{config_path}"
37-
end
35+
raise "❌ Database config file not found: #{config_path}" unless File.exist?(config_path)
3836

39-
db_config = YAML.safe_load(File.read(config_path), aliases: true)[env]
40-
unless db_config
41-
raise "❌ No configuration found for environment: #{env}"
42-
end
37+
db_config = YAML.safe_load_file(config_path, aliases: true)[env]
38+
raise "❌ No configuration found for environment: #{env}" unless db_config
4339

4440
ActiveRecord::Base.establish_connection(db_config)
4541
end

lib/rectify.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
require 'bundler/setup'
22

3-
require 'virtus'
43
require 'wisper'
54
require 'active_support/core_ext/hash'
65
require 'active_model'
76
require 'active_record'
87

98
require 'rectify/version'
9+
require 'rectify/attributable'
10+
require 'rectify/context'
1011
require 'rectify/form'
1112
require 'rectify/form_attribute'
1213
require 'rectify/format_attributes_hash'

lib/rectify/attributable.rb

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
module Rectify
2+
module Attributable
3+
def self.included(base)
4+
base.extend(ClassMethods)
5+
end
6+
7+
module ClassMethods
8+
def attribute(name, type = nil, default: nil)
9+
name = name.to_sym
10+
11+
rectify_attributes[name] = AttributeDefinition.new(name, type, default)
12+
13+
attr_writer name
14+
15+
define_method(name) do
16+
if instance_variable_defined?(:"@#{name}")
17+
instance_variable_get(:"@#{name}")
18+
else
19+
resolve_default(self.class.rectify_attributes[name].default)
20+
end
21+
end
22+
end
23+
24+
def rectify_attributes
25+
@rectify_attributes ||= if superclass.respond_to?(:rectify_attributes)
26+
superclass.rectify_attributes.dup
27+
else
28+
{}
29+
end
30+
end
31+
32+
def attribute_set
33+
rectify_attributes.values
34+
end
35+
end
36+
37+
def initialize(attrs = {})
38+
assign_attributes(attrs)
39+
end
40+
41+
def attributes
42+
self.class.rectify_attributes.to_h do |name, _defn|
43+
[name, public_send(name)]
44+
end
45+
end
46+
47+
private
48+
49+
def assign_attributes(attrs)
50+
return if attrs.nil?
51+
52+
normalized = attrs.transform_keys(&:to_sym)
53+
54+
self.class.rectify_attributes.each do |name, defn|
55+
raw = normalized[name]
56+
next if raw.nil? && !normalized.key?(name)
57+
58+
coerced = defn.coerce(raw)
59+
public_send(:"#{name}=", coerced)
60+
end
61+
end
62+
63+
def resolve_default(default)
64+
default.respond_to?(:call) ? default.call : default
65+
end
66+
end
67+
68+
class AttributeDefinition
69+
attr_reader :name, :type, :default, :member_type
70+
71+
def initialize(name, type, default) # rubocop:disable Metrics/MethodLength
72+
@name = name.to_sym
73+
@default = default
74+
75+
if type.is_a?(Array)
76+
@array = true
77+
@type = Array
78+
@member_type = type.first
79+
elsif type == Array
80+
@array = true
81+
@type = Array
82+
@member_type = nil
83+
else
84+
@array = false
85+
@type = type
86+
@member_type = nil
87+
end
88+
end
89+
90+
def coerce(value)
91+
return coerce_array(value) if array?
92+
93+
coerce_value(value, type)
94+
end
95+
96+
def primitive
97+
array? ? Array : type
98+
end
99+
100+
def array?
101+
@array
102+
end
103+
104+
def collection?
105+
array?
106+
end
107+
108+
def declared_class
109+
primitive
110+
end
111+
112+
private
113+
114+
def coerce_array(value)
115+
return [] if value.nil?
116+
117+
items = value.is_a?(Hash) ? value.values : Array(value)
118+
119+
if @member_type.respond_to?(:new)
120+
items.map { |item| coerce_value(item, @member_type) }
121+
else
122+
items
123+
end
124+
end
125+
126+
def coerce_value(value, target_type) # rubocop:disable Metrics
127+
return value if target_type.nil? || value.nil?
128+
return value if target_type != String && value.is_a?(target_type)
129+
130+
case target_type.to_s
131+
when 'Integer' then coerce_integer(value)
132+
when 'Float' then coerce_float(value)
133+
when 'String' then value.to_s
134+
when 'Symbol' then value.to_sym
135+
else coerce_complex_type(value, target_type)
136+
end
137+
end
138+
139+
def coerce_integer(value)
140+
return nil if value.is_a?(String) && value.strip.empty?
141+
142+
Integer(value)
143+
rescue ArgumentError, TypeError
144+
nil
145+
end
146+
147+
def coerce_float(value)
148+
return nil if value.is_a?(String) && value.strip.empty?
149+
150+
Float(value)
151+
rescue ArgumentError, TypeError
152+
nil
153+
end
154+
155+
def coerce_complex_type(value, target_type)
156+
if value.is_a?(Hash) && target_type.respond_to?(:new)
157+
target_type.new(value)
158+
else
159+
value
160+
end
161+
end
162+
end
163+
end

lib/rectify/command.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def self.call(*args, **kwargs, &block)
3131
end
3232

3333
def evaluate(&block)
34-
@caller = eval('self', block.binding, __FILE__, __LINE__)
34+
@caller = block.binding.receiver
3535
instance_eval(&block)
3636
end
3737

lib/rectify/context.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module Rectify
2+
class Context
3+
def initialize(data = {})
4+
@data = data.transform_keys(&:to_sym)
5+
6+
@data.each do |key, value|
7+
define_singleton_method(key) { value }
8+
end
9+
end
10+
11+
def [](key)
12+
@data[key.to_sym]
13+
end
14+
15+
def to_h
16+
@data.dup
17+
end
18+
end
19+
end

lib/rectify/form.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module Rectify
22
class Form
3-
include Virtus.model
3+
include Attributable
44
include ActiveModel::Validations
55

66
attr_reader :context
@@ -98,7 +98,7 @@ def attributes
9898
end
9999

100100
def attributes_with_values
101-
attributes.reject { |attribute| public_send(attribute).nil? }
101+
attributes.compact
102102
end
103103

104104
def map_model(model)
@@ -114,7 +114,7 @@ def before_validation
114114

115115
def with_context(new_context)
116116
@context = if new_context.is_a?(Hash)
117-
OpenStruct.new(new_context) # rubocop:disable Style/OpenStructUse
117+
Context.new(new_context)
118118
else
119119
new_context
120120
end

0 commit comments

Comments
 (0)