https://github.com/hamajyotan/active_record_compose
activermodel (activerecord) form object pattern.
https://github.com/hamajyotan/active_record_compose
activerecord activerecord-models rails ruby
Last synced: 8 months ago
JSON representation
activermodel (activerecord) form object pattern.
- Host: GitHub
- URL: https://github.com/hamajyotan/active_record_compose
- Owner: hamajyotan
- License: mit
- Created: 2024-01-02T14:05:01.000Z (over 2 years ago)
- Default Branch: main
- Last Pushed: 2024-10-09T14:11:33.000Z (over 1 year ago)
- Last Synced: 2024-10-09T15:52:25.523Z (over 1 year ago)
- Topics: activerecord, activerecord-models, rails, ruby
- Language: Ruby
- Homepage:
- Size: 78.1 KB
- Stars: 10
- Watchers: 1
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE.txt
- Code of conduct: CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# ActiveRecordCompose
ActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface.
It makes complex updates - such as user registration forms spanning multiple tables - easier to write, validate, and maintain.
[](https://badge.fury.io/rb/active_record_compose)

[](https://deepwiki.com/hamajyotan/active_record_compose)
## Table of Contents
- [Motivation](#motivation)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Basic Example](#basic-example)
- [Attribute Delegation](#attribute-delegation)
- [Unified Error Handling](#unified-error-handling)
- [I18n Support](#i18n-support)
- [Advanced Usage](#advanced-usage)
- [Destroy Option](#destroy-option)
- [Callback ordering with `#persisted?`](#callback-ordering-with-persisted)
- [Notes on adding models dynamically](#notes-on-adding-models-dynamically)
- [Sample Application](#sample-application)
- [Links](#links)
- [Development](#development)
- [Contributing](#contributing)
- [License](#license)
- [Code of Conduct](#code-of-conduct)
## Motivation
In Rails, `ActiveRecord::Base` is responsible for persisting data to the database.
By defining validations and callbacks, you can model use cases effectively.
However, when a single model must serve multiple different use cases, you often end up with conditional validations (`on: :context`) or workarounds like `save(validate: false)`.
This mixes unrelated concerns into one model, leading to unnecessary complexity.
`ActiveModel::Model` helps here — it provides the familiar API (`attribute`, `errors`, validations, callbacks) without persistence, so you can isolate logic per use case.
**ActiveRecordCompose** builds on `ActiveModel::Model` and acts as a first-class model within Rails:
- Transparently accesses attributes across multiple models
- Saves all associated models atomically in a transaction
- Collects and exposes error information consistently
This leads to cleaner domain models, better separation of concerns, and fewer surprises in validations and callbacks.
## Installation
To install `active_record_compose`, just put this line in your Gemfile:
```ruby
gem 'active_record_compose'
```
Then bundle
```sh
$ bundle
```
## Quick Start
### Basic Example
Suppose you have two models:
```ruby
class Account < ApplicationRecord
has_one :profile
validates :name, :email, presence: true
end
class Profile < ApplicationRecord
belongs_to :account
validates :firstname, :lastname, :age, presence: true
end
```
You can compose them into one form object:
```ruby
class UserRegistration < ActiveRecordCompose::Model
def initialize
@account = Account.new
@profile = @account.build_profile
super()
models << account << profile
end
attribute :terms_of_service, :boolean
validates :terms_of_service, presence: true
validates :email, confirmation: true
after_commit :send_email_message
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profile
private
attr_reader :account, :profile
def send_email_message
SendEmailConfirmationJob.perform_later(account)
end
end
```
Usage:
```ruby
registration = UserRegistration.new
registration.update!(
name: "foo",
email: "bar@example.com",
firstname: "taro",
lastname: "yamada",
age: 18,
email_confirmation: "bar@example.com",
terms_of_service: true,
)
```
Both `Account` and `Profile` will be updated **atomically in one transaction**.
### Attribute Delegation
`delegate_attribute` allows transparent access to attributes of inner models:
```ruby
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profile
```
They are also included in `#attributes`:
```ruby
registration.attributes
# => {
# "terms_of_service" => true,
# "email" => nil,
# "name" => "foo",
# "age" => nil,
# "firstname" => nil,
# "lastname" => nil
# }
```
### Unified Error Handling
Validation errors from inner models are collected into the composed model:
```ruby
user_registration = UserRegistration.new(
email: "foo@example.com",
email_confirmation: "BAZ@example.com",
age: 18,
terms_of_service: true,
)
user_registration.save # => false
user_registration.errors.full_messages
# => [
# "Name can't be blank",
# "Firstname can't be blank",
# "Lastname can't be blank",
# "Email confirmation doesn't match Email"
# ]
```
### I18n Support
When `#save!` raises `ActiveRecord::RecordInvalid`,
make sure you have locale entries such as:
```yaml
en:
activemodel:
errors:
messages:
record_invalid: 'Validation failed: %{errors}'
```
## Advanced Usage
### Destroy Option
```ruby
models.push(profile, destroy: true)
```
This deletes the model on `#save` instead of persisting it.
Conditional deletion is also supported:
```ruby
models.push(profile, destroy: -> { profile_field_is_blank? })
```
### Callback ordering with `#persisted?`
The result of `#persisted?` determines **which callbacks are fired**:
- `persisted? == false` -> create callbacks (`before_create`, `after_create`, ...)
- `persisted? == true` -> update callbacks (`before_update`, `after_update`, ...)
This matches the behavior of normal ActiveRecord models.
```ruby
class ComposedModel < ActiveRecordCompose::Model
before_save { puts "before_save" }
before_create { puts "before_create" }
before_update { puts "before_update" }
after_create { puts "after_create" }
after_update { puts "after_update" }
after_save { puts "after_save" }
def persisted?
account.persisted?
end
end
```
Example:
```ruby
# When persisted? == false
model = ComposedModel.new
model.save
# => before_save
# => before_create
# => after_create
# => after_save
# When persisted? == true
model = ComposedModel.new
def model.persisted?; true; end
model.save
# => before_save
# => before_update
# => after_update
# => after_save
```
### Notes on adding models dynamically
Avoid adding `models` to the models array **after validation has already run**
(for example, inside `after_validation` or `before_save` callbacks).
```ruby
class Example < ActiveRecordCompose::Model
before_save { models << AnotherModel.new }
end
```
In this case, the newly added model will **not** run validations for the current save cycle.
This may look like a bug, but it is the expected behavior: validations are only applied
to models that were registered before validation started.
We intentionally do not restrict this at the framework level, since there may be valid
advanced use cases where models are manipulated dynamically.
Instead, this behavior is documented here so that developers can make an informed decision.
## Sample Application
Try it out in your browser with GitHub Codespaces (or locally):
- https://github.com/hamajyotan/active_record_compose-example
## Links
- [API Documentation (YARD)](https://hamajyotan.github.io/active_record_compose/)
- [Blog article introducing the concept](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)
- [Sample Application](https://github.com/hamajyotan/active_record_compose-example)
## Development
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/hamajyotan/active_record_compose. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/hamajyotan/active_record_compose/blob/main/CODE_OF_CONDUCT.md).
## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
## Code of Conduct
Everyone interacting in the ActiveRecord::Compose project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/hamajyotan/active_record_compose/blob/main/CODE_OF_CONDUCT.md).