Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
25 changes: 5 additions & 20 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 5 additions & 9 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/rectify.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
163 changes: 163 additions & 0 deletions lib/rectify/attributable.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/rectify/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions lib/rectify/context.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions lib/rectify/form.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module Rectify
class Form
include Virtus.model
include Attributable
include ActiveModel::Validations

attr_reader :context
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading