├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── default.md └── workflows │ ├── pages.yml │ └── main.yml ├── .rubocop.yml ├── test ├── fake_app │ ├── config │ │ └── database.yml │ └── application.rb ├── active_record_compose_test.rb ├── test_helper.rb ├── active_record_compose │ ├── railtie_test.rb │ ├── model_lint_test.rb │ ├── model_twice_validation_test.rb │ ├── model_with_if_option_test.rb │ ├── model_callback_abort_test.rb │ ├── model_nested_test.rb │ ├── composed_collection_test.rb │ ├── wrapped_model_test.rb │ ├── delegate_attribute_test.rb │ ├── model_attribute_query_test.rb │ ├── model_context_test.rb │ ├── model_test.rb │ ├── model_uniquify_save_test.rb │ ├── model_callback_order_test.rb │ ├── model_with_destroy_context_test.rb │ └── model_inspect_test.rb ├── support │ ├── model.rb │ └── schema.rb └── use_cases │ ├── batch_update_and_delete_test.rb │ └── multiple_model_creation_test.rb ├── lib ├── active_record_compose │ ├── version.rb │ ├── railtie.rb │ ├── attributes │ │ ├── attribute_predicate.rb │ │ ├── delegation.rb │ │ └── querying.rb │ ├── callbacks.rb │ ├── validations.rb │ ├── composed_collection.rb │ ├── persistence.rb │ ├── wrapped_model.rb │ ├── model.rb │ ├── transaction_support.rb │ ├── inspectable.rb │ └── attributes.rb └── active_record_compose.rb ├── bin ├── rubocop ├── setup └── console ├── .yardopts ├── .gitignore ├── Steepfile ├── Rakefile ├── rbs_collection.yaml ├── LICENSE.txt ├── Gemfile ├── active_record_compose.gemspec ├── sig ├── active_record_compose.rbs └── _internal │ └── package_private.rbs ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: 3 | rubocop-rails-omakase: rubocop.yml 4 | -------------------------------------------------------------------------------- /test/fake_app/config/database.yml: -------------------------------------------------------------------------------- 1 | --- 2 | development: 3 | adapter: sqlite3 4 | database: ":memory:" 5 | -------------------------------------------------------------------------------- /lib/active_record_compose/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecordCompose 4 | VERSION = "1.1.1" 5 | end 6 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | load Gem.bin_path('rubocop', 'rubocop') 6 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --private 2 | --no-private 3 | --markup markdown 4 | --markup-provider redcarpet 5 | --plugin activesupport-concern 6 | --default-return void 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /tmp/ 9 | /vendor/bundle/ 10 | 11 | /.gem_rbs_collection/ 12 | /rbs_collection.lock.yaml 13 | -------------------------------------------------------------------------------- /Steepfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | D = Steep::Diagnostic 4 | 5 | target :lib do 6 | signature 'sig' 7 | 8 | check 'lib' 9 | 10 | configure_code_diagnostics(D::Ruby.strict) 11 | end 12 | -------------------------------------------------------------------------------- /test/active_record_compose_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveRecordComposeTest < ActiveSupport::TestCase 6 | test "that it has a version number" do 7 | assert_not_nil ActiveRecordCompose::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'minitest/test_task' 5 | 6 | Minitest::TestTask.create 7 | 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task :typecheck do 13 | sh 'bundle exec steep check' 14 | end 15 | 16 | task default: %i[test rubocop typecheck] 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'active_record_compose' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'irb' 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /test/fake_app/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record/railtie" 4 | require "active_record_compose/railtie" 5 | 6 | class FakeApp < Rails::Application 7 | config.secret_key_base = "test_secret_key_base" 8 | config.eager_load = false 9 | config.root = __dir__ 10 | config.logger = Logger.new($stdout) 11 | 12 | config.filter_parameters += %i[password sensitive] 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_record_compose.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | 5 | require_relative "active_record_compose/version" 6 | require_relative "active_record_compose/model" 7 | 8 | # namespaces in gem `active_record_compose`. 9 | # 10 | # Most of the functionality resides in {ActiveRecordCompose::Model}. 11 | # 12 | module ActiveRecordCompose 13 | end 14 | 15 | require "active_record_compose/railtie" if defined?(::Rails::Railtie) 16 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest/reporters" 4 | Minitest::Reporters.use! 5 | 6 | require "minitest/power_assert" 7 | 8 | # Since Rails 7.1, test_case implicitly depends on deprecator. 9 | require "active_support/deprecator" 10 | 11 | require "active_support/test_case" 12 | 13 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 14 | require "active_record_compose" 15 | 16 | require_relative "support/schema" 17 | require_relative "support/model" 18 | 19 | require "minitest/autorun" 20 | -------------------------------------------------------------------------------- /lib/active_record_compose/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails" 4 | require "active_record_compose" 5 | require "active_record/railtie" 6 | 7 | module ActiveRecordCompose 8 | class Railtie < Rails::Railtie 9 | initializer "active_record_compose.set_filter_attributes", after: "active_record.set_filter_attributes" do 10 | ActiveSupport.on_load(:active_record) do 11 | ActiveRecordCompose::Model.filter_attributes += ActiveRecord::Base.filter_attributes 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Default 3 | about: default issue template 4 | --- 5 | 6 | 15 | 16 | -------------------------------------------------------------------------------- /rbs_collection.yaml: -------------------------------------------------------------------------------- 1 | # Download sources 2 | sources: 3 | - type: git 4 | name: ruby/gem_rbs_collection 5 | remote: https://github.com/ruby/gem_rbs_collection.git 6 | revision: main 7 | repo_dir: gems 8 | 9 | # You can specify local directories as sources also. 10 | # - type: local 11 | # path: path/to/your/local/repository 12 | 13 | # A directory to install the downloaded RBSs 14 | path: .gem_rbs_collection 15 | 16 | # gems: 17 | # # If you want to avoid installing rbs files for gems, you can specify them here. 18 | # - name: GEM_NAME 19 | # ignore: true 20 | -------------------------------------------------------------------------------- /lib/active_record_compose/attributes/attribute_predicate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecordCompose 4 | module Attributes 5 | # @private 6 | class AttributePredicate 7 | def initialize(value) 8 | @value = value 9 | end 10 | 11 | def call 12 | case value 13 | when true then true 14 | when false, nil then false 15 | else 16 | if value.respond_to?(:zero?) 17 | !value.zero? 18 | else 19 | value.present? 20 | end 21 | end 22 | end 23 | 24 | private 25 | 26 | attr_reader :value 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/active_record_compose/railtie_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose" 5 | 6 | class ActiveRecordCompose::RailtieTest < ActiveSupport::TestCase 7 | include ActiveSupport::Testing::Isolation 8 | 9 | test "The filter_parameters settings in the rails application are reflected in the filter_attributes" do 10 | require "fake_app/application" 11 | 12 | assert_changes -> { ActiveRecordCompose::Model.filter_attributes } do 13 | FakeApp.initialize! 14 | 15 | assert { FakeApp.config.filter_parameters.size > 0 } 16 | assert { (FakeApp.config.filter_parameters - ActiveRecordCompose::Model.filter_attributes).empty? } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/active_record_compose/model_lint_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelLintTest < ActiveSupport::TestCase 7 | include ActiveModel::Lint::Tests 8 | 9 | class ComposedModel < ActiveRecordCompose::Model 10 | def initialize(account = Account.new) 11 | @account = account 12 | @profile = account.then { _1.profile || _1.build_profile } 13 | super() 14 | models << account << profile 15 | end 16 | 17 | delegate_attribute :name, :email, to: :account 18 | delegate_attribute :firstname, :lastname, :age, to: :profile 19 | 20 | private 21 | 22 | attr_reader :account, :profile 23 | end 24 | 25 | setup do 26 | @model = ComposedModel.new 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | primary_abstract_class 5 | end 6 | 7 | class Account < ApplicationRecord 8 | has_one :profile 9 | has_one :credential 10 | validates :name, presence: true 11 | validates :email, presence: true 12 | validates :email, format: { with: /\.edu\z/ }, on: :education 13 | end 14 | 15 | class Profile < ApplicationRecord 16 | belongs_to :account 17 | validates :firstname, presence: true, length: { maximum: 32 } 18 | validates :lastname, presence: true, length: { maximum: 32 } 19 | validates :age, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } 20 | validates :age, numericality: { less_than_or_equal_to: 18 }, on: :education 21 | end 22 | 23 | class Credential < ApplicationRecord 24 | has_secure_password 25 | belongs_to :account 26 | end 27 | 28 | class OperationLog < ApplicationRecord 29 | validates :action, presence: true 30 | end 31 | -------------------------------------------------------------------------------- /test/support/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.establish_connection( 4 | adapter: "sqlite3", 5 | database: ":memory:", 6 | ) 7 | 8 | ActiveRecord::Migration.verbose = false 9 | 10 | ActiveRecord::Schema.define do 11 | create_table :accounts, force: true do |t| 12 | t.string :name, null: false 13 | t.string :email, null: false 14 | t.datetime :resigned_at 15 | t.timestamps 16 | end 17 | 18 | create_table :credentials, force: true do |t| 19 | t.references :account, null: false, index: { unique: true }, foreign_key: true 20 | t.string :password_digest, null: false 21 | end 22 | 23 | create_table :profiles, force: true do |t| 24 | t.references :account, null: false, index: { unique: true }, foreign_key: true 25 | t.string :firstname, null: false 26 | t.string :lastname, null: false 27 | t.integer :age, null: false 28 | t.timestamps 29 | end 30 | 31 | create_table :operation_logs, force: true do |t| 32 | t.string :action, null: false 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy static content to Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | deploy: 20 | environment: 21 | name: github-pages 22 | url: ${{ steps.deployment.outputs.page_url }} 23 | 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v5 28 | 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ruby 32 | bundler-cache: true 33 | 34 | - run: bundle exec yard 35 | 36 | - name: Setup Pages 37 | uses: actions/configure-pages@v5 38 | 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v3 41 | with: 42 | path: "./doc" 43 | 44 | - name: Deploy to GitHub Pages 45 | id: deployment 46 | uses: actions/deploy-pages@v4 47 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 SAKAGUCHI Takashi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/active_record_compose/model_twice_validation_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelTwiceValidationTest < ActiveSupport::TestCase 7 | class FailureOnTwiceValidation < ActiveRecordCompose::Model 8 | def initialize(model = nil) 9 | super() 10 | models.push(model) 11 | @validation_count = 0 12 | end 13 | 14 | before_validation :increment_validation_count 15 | 16 | validates :validation_count, numericality: { less_than_or_equal_to: 1 } 17 | 18 | private 19 | 20 | attr_reader :validation_count 21 | 22 | def increment_validation_count 23 | @validation_count += 1 24 | end 25 | end 26 | 27 | test "FailureOnTwiceValidation model cannot be validated more than once." do 28 | model = FailureOnTwiceValidation.new 29 | assert model.valid? 30 | assert_not model.valid? 31 | assert_not model.valid? 32 | end 33 | 34 | test "Validation must be performed only once for the encompassing model." do 35 | inner_model = FailureOnTwiceValidation.new 36 | model = FailureOnTwiceValidation.new(inner_model) 37 | 38 | assert model.save 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/active_record_compose/attributes/delegation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "attribute_predicate" 4 | 5 | module ActiveRecordCompose 6 | module Attributes 7 | # @private 8 | class Delegation 9 | # @return [Symbol] The attribute name as symbol 10 | attr_reader :attribute 11 | 12 | def initialize(attribute:, to:, allow_nil: false) 13 | @attribute = attribute.to_sym 14 | @to = to.to_sym 15 | @allow_nil = !!allow_nil 16 | 17 | freeze 18 | end 19 | 20 | def define_delegated_attribute(klass) 21 | klass.delegate(reader, writer, to:, allow_nil:) 22 | klass.module_eval <<~RUBY, __FILE__, __LINE__ + 1 23 | def #{reader}? 24 | ActiveRecordCompose::Attributes::AttributePredicate.new(#{reader}).call 25 | end 26 | RUBY 27 | end 28 | 29 | # @return [String] The attribute name as string 30 | def attribute_name = attribute.to_s 31 | 32 | # @return [Hash] 33 | def attribute_hash(model) 34 | { attribute_name => model.public_send(attribute) } 35 | end 36 | 37 | private 38 | 39 | attr_reader :to, :allow_nil 40 | 41 | def reader = attribute.to_s 42 | 43 | def writer = "#{attribute}=" 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in active_record_compose.gemspec 6 | gemspec 7 | 8 | ar_version = ENV.fetch("AR", "latest") 9 | 10 | case ar_version 11 | when "latest" 12 | gem "activerecord" 13 | gem "railties" 14 | when "head" 15 | gem "activemodel", github: "rails/rails" 16 | gem "activerecord", github: "rails/rails" 17 | gem "activesupport", github: "rails/rails" 18 | gem "railties", github: "rails/rails" 19 | when /-stable\z/ 20 | gem "activemodel", github: "rails/rails", branch: ar_version 21 | gem "activerecord", github: "rails/rails", branch: ar_version 22 | gem "activesupport", github: "rails/rails", branch: ar_version 23 | gem "railties", github: "rails/rails", branch: ar_version 24 | else 25 | gem "activerecord", ar_version 26 | gem "railties", ar_version 27 | end 28 | 29 | gem "sqlite3", "~> 2.1" 30 | 31 | gem "rake" 32 | 33 | # test and debug. 34 | gem "debug" 35 | gem "minitest" 36 | gem "minitest-power_assert" 37 | gem "minitest-reporters" 38 | 39 | # model defined in the test is dependent. 40 | gem "bcrypt" 41 | 42 | # lint. 43 | gem "rubocop-rails-omakase" 44 | gem "steep", require: false 45 | 46 | # document. 47 | gem "yard" 48 | gem "yard-activesupport-concern" 49 | gem "webrick" 50 | gem "redcarpet" 51 | gem "github-markup" 52 | -------------------------------------------------------------------------------- /test/active_record_compose/model_with_if_option_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelWithIfOptionTest < ActiveSupport::TestCase 7 | class ComposedModelWithOperationLog < ActiveRecordCompose::Model 8 | def initialize(attributes = {}) 9 | @account = Account.new(name: "foobar", email: "foobar@example.com") 10 | @operation_log = OperationLog.new(action: "account_registration") 11 | super(attributes) 12 | models.push(account) 13 | models.push(operation_log, if: :output_log) 14 | end 15 | 16 | attribute :output_log, :boolean, default: true 17 | 18 | private 19 | 20 | attr_reader :account, :operation_log 21 | end 22 | 23 | test ":if option process is truthy, it is included in the update target." do 24 | model = ComposedModelWithOperationLog.new(output_log: true) 25 | 26 | assert_difference -> { Account.count } => 1, -> { OperationLog.count } => 1 do 27 | model.save! 28 | end 29 | end 30 | 31 | test ":if option process is falsy, it is not included in the update target." do 32 | model = ComposedModelWithOperationLog.new(output_log: false) 33 | 34 | assert_difference -> { Account.count } => 1, -> { OperationLog.count } => 0 do 35 | model.save! 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 0' 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | name: "Ruby ${{ matrix.ruby }} x ActiveRecord ${{ matrix.ar }}" 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: ['3.1', '3.2', '3.3', '3.4'] 19 | ar: 20 | - '~> 7.1.0' 21 | - '~> 7.2.0' 22 | - '~> 8.0.0' 23 | - '8-1-stable' 24 | - 'head' 25 | exclude: 26 | - ruby: '3.1' 27 | ar: 'head' 28 | - ruby: '3.1' 29 | ar: '~> 8.0.0' 30 | - ruby: '3.1' 31 | ar: '8-1-stable' 32 | env: 33 | AR: ${{ matrix.ar }} 34 | steps: 35 | - uses: actions/checkout@v5 36 | - name: Set up Ruby 37 | uses: ruby/setup-ruby@v1 38 | with: 39 | ruby-version: ${{ matrix.ruby }} 40 | bundler-cache: true 41 | - name: Run test 42 | run: bundle exec rake test 43 | - name: Run rubocop 44 | run: bundle exec rake rubocop 45 | 46 | type-check: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v5 50 | - name: Set up Ruby 51 | uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: 3.3 54 | bundler-cache: true 55 | - name: Run type check 56 | run: rbs collection install && bundle exec steep check 57 | -------------------------------------------------------------------------------- /test/active_record_compose/model_callback_abort_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelCallbackAbortTest < ActiveSupport::TestCase 7 | class CallbackWithAbort < ActiveRecordCompose::Model 8 | def initialize(account = Account.new) 9 | @account = account 10 | super() 11 | models << account 12 | end 13 | 14 | attribute :throw_flag, :boolean, default: false 15 | attribute :after_save_called, :boolean, default: false 16 | 17 | delegate_attribute :name, :email, to: :account 18 | 19 | before_save do 20 | throw(:abort) if throw_flag 21 | end 22 | 23 | after_save { self.after_save_called = true } 24 | 25 | private 26 | 27 | attr_reader :account 28 | end 29 | 30 | test "when :abort is not thrown in the before hook, it should be saved normally" do 31 | model = CallbackWithAbort.new 32 | model.assign_attributes(name: "foo", email: "foo@example.com", throw_flag: false) 33 | 34 | assert_difference -> { Account.count } => 1 do 35 | assert model.save 36 | end 37 | assert model.after_save_called 38 | end 39 | 40 | test "when :abort is thrown in the before hook, the save must fail" do 41 | model = CallbackWithAbort.new 42 | model.assign_attributes(name: "foo", email: "foo@example.com", throw_flag: true) 43 | 44 | assert_no_changes -> { Account.count } do 45 | assert_not model.save 46 | end 47 | assert_not model.after_save_called 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/active_record_compose/model_nested_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelNestedTest < ActiveSupport::TestCase 7 | class InnerComposedModel < ActiveRecordCompose::Model 8 | def initialize 9 | @account = Account.new 10 | super() 11 | models << account 12 | end 13 | 14 | validates :name, length: { maximum: 10 } 15 | 16 | delegate_attribute :name, :email, to: :account 17 | 18 | private 19 | 20 | attr_reader :account 21 | end 22 | 23 | class OuterComposedModel < ActiveRecordCompose::Model 24 | def initialize 25 | @inner_model = InnerComposedModel.new 26 | super() 27 | models << inner_model 28 | end 29 | 30 | delegate_attribute :name, :email, to: :inner_model 31 | 32 | private 33 | 34 | attr_reader :inner_model 35 | end 36 | 37 | test "Ensure a ComposedModel can be saved correctly even when it contains another ComposedModel" do 38 | model = OuterComposedModel.new 39 | model.assign_attributes(name: "foo", email: "foo@example.com") 40 | 41 | assert_difference -> { Account.count } => 1 do 42 | model.save! 43 | end 44 | end 45 | 46 | test "Ensure errors propagate correctly even when a ComposedModel contains another ComposedModel and is invalid." do 47 | model = OuterComposedModel.new 48 | model.assign_attributes(name: "veryverylongname") 49 | 50 | assert model.invalid? 51 | assert { model.errors.to_a == [ "Email can't be blank", "Name is too long (maximum is 10 characters)" ] } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/active_record_compose/composed_collection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/composed_collection" 5 | 6 | class ActiveRecordCompose::ComposedCollectionTest < ActiveSupport::TestCase 7 | test "#empty should return true if the element is absent and false if the element is present" do 8 | collection = ActiveRecordCompose::ComposedCollection.new(nil) 9 | 10 | assert collection.empty? 11 | 12 | collection << Account.new 13 | 14 | assert_not collection.empty? 15 | end 16 | 17 | test "can be made empty by #clear" do 18 | collection = ActiveRecordCompose::ComposedCollection.new(nil) 19 | collection << Account.new 20 | collection.clear 21 | 22 | assert collection.empty? 23 | end 24 | 25 | test "#delete to exclude specific elements" do 26 | collection = ActiveRecordCompose::ComposedCollection.new(nil) 27 | account = Account.new 28 | profile = Profile.new 29 | collection << account << profile 30 | 31 | assert { collection.first == account } 32 | 33 | collection.delete(account) 34 | 35 | assert { collection.first == profile } 36 | end 37 | 38 | test "#delete will delete the specified model regardless of the options used when adding it" do 39 | collection = ActiveRecordCompose::ComposedCollection.new(nil) 40 | account = Account.new 41 | profile = Profile.new 42 | collection.push(account, destroy: false) 43 | collection.push(profile) 44 | collection.push(account, destroy: true) 45 | 46 | assert { collection.first == account } 47 | assert { collection.count == 3 } 48 | 49 | collection.delete(account) 50 | 51 | assert { collection.first == profile } 52 | assert { collection.count == 1 } 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/active_record_compose/attributes/querying.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "attribute_predicate" 4 | 5 | module ActiveRecordCompose 6 | module Attributes 7 | # @private 8 | # This provides predicate methods based on the attributes. 9 | # 10 | # @example 11 | # class AccountRegistration < ActiveRecordCompose::Model 12 | # def initialize 13 | # @account = Account.new 14 | # super() 15 | # models << account 16 | # end 17 | # 18 | # attribute :original_attr 19 | # delegate_attribute :name, :email, to: :account 20 | # 21 | # private 22 | # 23 | # attr_reader :account 24 | # end 25 | # 26 | # model = AccountRegistration.new 27 | # 28 | # model.name #=> nil 29 | # model.name? #=> false 30 | # model.name = "Alice" 31 | # model.name? #=> true 32 | # 33 | # model.original_attr = "Bob" 34 | # model.original_attr? #=> true 35 | # model.original_attr = "" 36 | # model.original_attr? #=> false 37 | # 38 | # # If the value is numeric, it returns the result of checking whether it is zero or not. 39 | # # This behavior is consistent with `ActiveRecord::AttributeMethods::Query`. 40 | # model.original_attr = 123 41 | # model.original_attr? #=> true 42 | # model.original_attr = 0 43 | # model.original_attr? #=> false 44 | # 45 | module Querying 46 | extend ActiveSupport::Concern 47 | include ActiveModel::AttributeMethods 48 | 49 | included do 50 | attribute_method_suffix "?", parameters: false 51 | end 52 | 53 | private 54 | 55 | def attribute?(attr_name) = query?(public_send(attr_name)) 56 | 57 | def query?(value) 58 | ActiveRecordCompose::Attributes::AttributePredicate.new(value).call 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /active_record_compose.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/active_record_compose/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "active_record_compose" 7 | spec.version = ActiveRecordCompose::VERSION 8 | spec.authors = [ "hamajyotan" ] 9 | spec.email = [ "hamajyotan@gmail.com" ] 10 | 11 | spec.description = "activemodel form object pattern. " \ 12 | "it embraces multiple AR models and provides " \ 13 | "a transparent interface as if they were a single model." 14 | spec.summary = "activemodel form object pattern" 15 | spec.homepage = "https://github.com/hamajyotan/active_record_compose" 16 | spec.license = "MIT" 17 | spec.required_ruby_version = ">= 3.1.0" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(__dir__) do 22 | `git ls-files -z`.split("\x0").reject do |f| 23 | (File.expand_path(f) == __FILE__) || 24 | f.start_with?(*%w[ 25 | bin/ 26 | test/ 27 | spec/ 28 | features/ 29 | .git 30 | .circleci 31 | appveyor 32 | Gemfile 33 | Rakefile 34 | rbs_collection.yaml 35 | Steepfile 36 | ]) 37 | end 38 | end 39 | spec.require_paths = [ "lib" ] 40 | 41 | spec.add_dependency "activerecord", ">= 7.1", "< 8.2" 42 | 43 | spec.metadata["homepage_uri"] = spec.homepage 44 | spec.metadata["source_code_uri"] = spec.homepage 45 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 46 | spec.metadata["documentation_uri"] = "https://hamajyotan.github.io/active_record_compose/" 47 | spec.metadata["rubygems_mfa_required"] = "true" 48 | end 49 | -------------------------------------------------------------------------------- /test/active_record_compose/wrapped_model_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/wrapped_model" 5 | 6 | class ActiveRecordCompose::WrappedModelTest < ActiveSupport::TestCase 7 | test "returns true if and only if model is equivalent" do 8 | account = Account.new 9 | profile = Profile.new 10 | wrapped_model = ActiveRecordCompose::WrappedModel.new(account) 11 | 12 | assert { wrapped_model != described_class.new(profile) } 13 | assert { wrapped_model == described_class.new(account) } 14 | end 15 | 16 | test "the :destroy option is also taken into account to determine equivalence" do 17 | account = Account.new 18 | 19 | assert { described_class.new(account) == described_class.new(account, destroy: false) } 20 | assert { described_class.new(account) != described_class.new(account, destroy: true) } 21 | 22 | destroy_proc = -> { true } 23 | other_destroy_proc = -> { true } 24 | assert { described_class.new(account, destroy: destroy_proc) == described_class.new(account, destroy: destroy_proc) } 25 | assert { described_class.new(account, destroy: destroy_proc) != described_class.new(account, destroy: other_destroy_proc) } 26 | end 27 | 28 | test "the :if option is also taken into account to determine equivalence" do 29 | account = Account.new 30 | 31 | if_proc = -> { true } 32 | other_if_proc = -> { true } 33 | assert { described_class.new(account, if: if_proc) == described_class.new(account, if: if_proc) } 34 | assert { described_class.new(account, if: if_proc) != described_class.new(account, if: other_if_proc) } 35 | end 36 | 37 | test "when `destroy` option is false, save model by `#save`" do 38 | already_persisted_account = Account.create(name: "foo", email: "foo@example.com") 39 | wrapped_model = described_class.new(already_persisted_account, destroy: false) 40 | 41 | assert wrapped_model.save 42 | assert already_persisted_account.persisted? 43 | end 44 | 45 | test "when `destroy` option is true, delete model by `#save`" do 46 | already_persisted_account = Account.create(name: "foo", email: "foo@example.com") 47 | wrapped_model = described_class.new(already_persisted_account, destroy: true) 48 | 49 | assert wrapped_model.save 50 | assert already_persisted_account.destroyed? 51 | end 52 | 53 | private 54 | 55 | def described_class = ActiveRecordCompose::WrappedModel 56 | end 57 | -------------------------------------------------------------------------------- /test/active_record_compose/delegate_attribute_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/attributes" 5 | 6 | class ActiveRecordCompose::DelegateAttributeTest < ActiveSupport::TestCase 7 | class Inner 8 | include ActiveModel::Model 9 | include ActiveModel::Attributes 10 | 11 | attribute :x 12 | attribute :y 13 | attribute :z 14 | end 15 | 16 | class Dummy 17 | include ActiveRecordCompose::Attributes 18 | 19 | def initialize(data) 20 | @data = data 21 | super() 22 | end 23 | 24 | delegate_attribute :x, :y, to: :data 25 | 26 | private 27 | 28 | attr_reader :data 29 | end 30 | 31 | test "methods of reader and writer are defined" do 32 | data = Struct.new(:x, :y, :z, keyword_init: true).new 33 | data.x = "foo" 34 | object = Dummy.new(data) 35 | 36 | assert { data.x == "foo" } 37 | assert { object.x == "foo" } 38 | 39 | object.y = "bar" 40 | 41 | assert { data.y == "bar" } 42 | assert { object.y == "bar" } 43 | end 44 | 45 | test "definition declared in delegate must be included in attributes" do 46 | data = Struct.new(:x, :y, :z, keyword_init: true).new 47 | object = Dummy.new(data) 48 | object.x = "foo" 49 | object.y = "bar" 50 | 51 | assert { object.attributes == { "x" => "foo", "y" => "bar" } } 52 | assert { object.attribute_names == %w[x y] } 53 | assert { object.class.attribute_names == %w[x y] } 54 | end 55 | 56 | test "attributes to be transferred must be independent, even if there is an inheritance relationship" do 57 | data = Struct.new(:x, :y, :z, keyword_init: true).new 58 | data.x = "foo" 59 | data.y = "bar" 60 | data.z = "baz" 61 | 62 | o1 = Dummy.new(data) 63 | assert { o1.attributes == { "x" => "foo", "y" => "bar" } } 64 | assert { o1.attribute_names == %w[x y] } 65 | assert { o1.class.attribute_names == %w[x y] } 66 | 67 | subclass = Class.new(Dummy) do 68 | delegate_attribute :z, to: :data 69 | end 70 | o2 = subclass.new(data) 71 | assert { o2.attributes == { "x" => "foo", "y" => "bar", "z" => "baz" } } 72 | assert { o2.attribute_names == %w[x y z] } 73 | assert { o2.class.attribute_names == %w[x y z] } 74 | end 75 | 76 | test "Raises ArgumentError if instance variable is directly specified in :to option of delegate_attribute" do 77 | assert_raises(ArgumentError, "Instance variables cannot be specified in delegate to. (@model)") do 78 | Class.new(ActiveRecordCompose::Model) do 79 | def initialize(attributes) 80 | @model = Inner.new 81 | super(attributes) 82 | end 83 | 84 | delegate_attribute :x, :y, to: :@model 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/active_record_compose/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecordCompose 4 | # Provides hooks into the life cycle of an ActiveRecordCompose model, 5 | # allowing you to insert custom logic before or after changes to the object's state. 6 | # 7 | # The callback flow generally follows the same structure as Active Record: 8 | # 9 | # * `before_validation` 10 | # * `after_validation` 11 | # * `before_save` 12 | # * `before_create` (or `before_update` for update operations) 13 | # * `after_create` (or `after_update` for update operations) 14 | # * `after_save` 15 | # * `after_commit` (or `after_rollback` when the transaction is rolled back) 16 | # 17 | module Callbacks 18 | extend ActiveSupport::Concern 19 | include ActiveModel::Validations::Callbacks 20 | 21 | included do 22 | # @!method self.before_save(*args, &block) 23 | # Registers a callback to be called before a model is saved. 24 | 25 | # @!method self.around_save(*args, &block) 26 | # Registers a callback to be called around the save of a model. 27 | 28 | # @!method self.after_save(*args, &block) 29 | # Registers a callback to be called after a model is saved. 30 | 31 | define_model_callbacks :save 32 | 33 | # @!method self.before_create(*args, &block) 34 | # Registers a callback to be called before a model is created. 35 | 36 | # @!method self.around_create(*args, &block) 37 | # Registers a callback to be called around the creation of a model. 38 | 39 | # @!method self.after_create(*args, &block) 40 | # Registers a callback to be called after a model is created. 41 | 42 | define_model_callbacks :create 43 | 44 | # @!method self.before_update(*args, &block) 45 | # Registers a callback to be called before a model is updated. 46 | 47 | # @!method self.around_update(*args, &block) 48 | # Registers a callback to be called around the update of a model. 49 | 50 | # @!method self.after_update(*args, &block) 51 | # Registers a callback to be called after a update is updated. 52 | define_model_callbacks :update 53 | end 54 | 55 | private 56 | 57 | # @private 58 | # Evaluate while firing callbacks such as `before_save` `after_save` 59 | # before and after block evaluation. 60 | # 61 | def with_callbacks(&block) = run_callbacks(:save) { run_callbacks(callback_context, &block) } 62 | 63 | # @private 64 | # Returns the symbol representing the callback context, which is `:create` if the record 65 | # is new, or `:update` if it has been persisted. 66 | # 67 | # @return [:create, :update] either `:create` if not persisted, or `:update` if persisted 68 | def callback_context = persisted? ? :update : :create 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/active_record_compose/model_attribute_query_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelAttributeQueryTest < ActiveSupport::TestCase 7 | class ComposedModel < ActiveRecordCompose::Model 8 | def initialize(attributes = {}) 9 | @account = Account.new 10 | @profile = account.build_profile 11 | super(attributes) 12 | models << account << profile 13 | self.attributes_method_calls = 0 14 | end 15 | 16 | attribute :foo 17 | attribute :bar 18 | attribute :baz 19 | attribute :qux 20 | delegate_attribute :name, :email, to: :account 21 | delegate_attribute :firstname, :lastname, :age, to: :profile 22 | 23 | attr_accessor :without_attribute, :attributes_method_calls 24 | 25 | def attributes 26 | self.attributes_method_calls += 1 27 | super 28 | end 29 | 30 | private 31 | 32 | attr_reader :account, :profile 33 | end 34 | 35 | test "Methods with the suffix `?` are defined for each method declared as an attribute." do 36 | model = ComposedModel.new 37 | 38 | assert model.respond_to?(:foo?) 39 | assert model.respond_to?(:bar?) 40 | assert model.respond_to?(:baz?) 41 | assert model.respond_to?(:qux?) 42 | assert model.respond_to?(:name?) 43 | assert model.respond_to?(:email?) 44 | assert model.respond_to?(:firstname?) 45 | assert model.respond_to?(:lastname?) 46 | assert model.respond_to?(:age?) 47 | end 48 | 49 | test "Accessor methods that are not attributes do not have corresponding methods with a `?` suffix defined." do 50 | model = ComposedModel.new 51 | 52 | assert_not model.respond_to?(:without_attribute?) 53 | end 54 | 55 | test "If the value of the attribute is true, the query method returns true." do 56 | assert ComposedModel.new(foo: true).foo? 57 | end 58 | 59 | test "If the value of the attribute is false, the query method returns false." do 60 | assert_not ComposedModel.new(foo: false).foo? 61 | end 62 | 63 | test "If the value of the attribute is nil, the query method returns false." do 64 | assert_not ComposedModel.new(foo: nil).foo? 65 | end 66 | 67 | test "Returns true if a value has been provided for the attribute." do 68 | model = ComposedModel.new(foo: "Alice", bar: "", baz: [ 1 ], qux: []) 69 | 70 | assert model.foo? 71 | assert_not model.bar? 72 | assert model.baz? 73 | assert_not model.qux? 74 | end 75 | 76 | test "If the value of the attribute is a number, it returns true when the value is non-zero." do 77 | model = ComposedModel.new(foo: 123, bar: 0, baz: 456.7, qux: 0.0) 78 | 79 | assert model.foo? 80 | assert_not model.bar? 81 | assert model.baz? 82 | assert_not model.qux? 83 | end 84 | 85 | test "attribute method is defined so that `#attributes` are not evaluated each time the query method is executed" do 86 | model = ComposedModel.new(foo: 123, bar: 0, baz: 456.7, qux: 0.0) 87 | 88 | assert_no_changes -> { model.attributes_method_calls } do 89 | model.foo? 90 | model.bar? 91 | model.qux? 92 | model.name? 93 | model.email? 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/active_record_compose/model_context_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelContextTest < ActiveSupport::TestCase 7 | class ComposedModel < ActiveRecordCompose::Model 8 | def initialize(attributes) 9 | @account = Account.new 10 | @profile = account.then { _1.profile || _1.build_profile } 11 | super 12 | models << account << profile 13 | end 14 | 15 | attribute :accept, :boolean, default: true 16 | validates :accept, presence: true, on: :education 17 | 18 | delegate_attribute :name, :email, to: :account 19 | delegate_attribute :firstname, :lastname, :age, to: :profile 20 | 21 | private 22 | 23 | attr_reader :account, :profile 24 | end 25 | 26 | test "#valid without `:context` Validations with `:on` do not work." do 27 | assert new_model.valid? 28 | assert new_model(accept: false).valid? 29 | end 30 | 31 | test "if `context` is specified, then the `:on` validation on the model will work on #valid? operation." do 32 | assert new_model.valid?(:education) 33 | 34 | model = new_model(accept: false) 35 | assert model.invalid?(:education) 36 | assert model.errors.of_kind?(:accept, :blank) 37 | assert { model.errors.to_a == [ "Accept can't be blank" ] } 38 | end 39 | 40 | test "if `context` is specified, then the `:on` validation on inner models will work on #valid? operation." do 41 | assert new_model.valid?(:education) 42 | 43 | model = new_model(email: "foo@example.com", age: 99) 44 | assert model.invalid?(:education) 45 | assert model.errors.of_kind?(:email, :invalid) 46 | assert model.errors.of_kind?(:age, :less_than_or_equal_to) 47 | expected_error_messasges = 48 | [ 49 | "Age must be less than or equal to 18", 50 | "Email is invalid" 51 | ] 52 | assert { model.errors.to_a.sort == expected_error_messasges.sort } 53 | end 54 | 55 | test "#save without the `:context` option does not affect the `:on` specified validation." do 56 | assert_difference -> { Account.count } => 1, -> { Profile.count } => 1 do 57 | assert new_model.save 58 | end 59 | assert_difference -> { Account.count } => 1, -> { Profile.count } => 1 do 60 | new_model(accept: false).save 61 | end 62 | end 63 | 64 | test "#save with `:context` option means that the validation with `:on` on the model works." do 65 | assert_difference -> { Account.count } => 1, -> { Profile.count } => 1 do 66 | assert new_model.save(context: :education) 67 | end 68 | 69 | assert_no_changes -> { Account.count }, -> { Profile.count } do 70 | model = new_model(accept: false) 71 | 72 | assert_not model.save(context: :education) 73 | assert model.errors.of_kind?(:accept, :blank) 74 | assert { model.errors.to_a == [ "Accept can't be blank" ] } 75 | end 76 | end 77 | 78 | test "#save with `:context` option means that the validation with `:on` on the inner models works." do 79 | assert_difference -> { Account.count } => 1, -> { Profile.count } => 1 do 80 | assert new_model.save(context: :education) 81 | end 82 | 83 | assert_no_changes -> { Account.count }, -> { Profile.count } do 84 | model = new_model(email: "foo@example.com", age: 99) 85 | 86 | assert_not model.save(context: :education) 87 | assert model.errors.of_kind?(:email, :invalid) 88 | assert model.errors.of_kind?(:age, :less_than_or_equal_to) 89 | assert { model.errors.to_a.sort == [ "Age must be less than or equal to 18", "Email is invalid" ].sort } 90 | end 91 | end 92 | 93 | private 94 | 95 | def new_model(attributes = {}) = ComposedModel.new(valid_attributes.merge(attributes)) 96 | 97 | def valid_attributes 98 | { 99 | accept: true, 100 | name: "foo", 101 | email: "foo@example.edu", 102 | firstname: "bar", 103 | lastname: "baz", 104 | age: 12 105 | } 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/active_record_compose/validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "composed_collection" 4 | 5 | module ActiveRecordCompose 6 | using ComposedCollection::PackagePrivate 7 | 8 | module Validations 9 | extend ActiveSupport::Concern 10 | include ActiveModel::Validations::Callbacks 11 | 12 | included do 13 | validate :validate_models 14 | end 15 | 16 | def save(**options) 17 | perform_validations(options) ? super : false 18 | end 19 | 20 | def save!(**options) 21 | perform_validations(options) ? super : raise_validation_error 22 | end 23 | 24 | # Runs all the validations and returns the result as true or false. 25 | # 26 | # @param context Validation context. 27 | # @return [Boolean] true on success, false on failure. 28 | def valid?(context = nil) = context_for_override_validation.with_override(context) { super } 29 | 30 | # @!method validate(context = nil) 31 | # Alias for {#valid?} 32 | # @see #valid? Validation context. 33 | # @param context 34 | # @return [Boolean] true on success, false on failure. 35 | 36 | # @!method validate!(context = nil) 37 | # @see #valid? 38 | # Runs all the validations within the specified context. 39 | # no errors are found, raises `ActiveRecord::RecordInvalid` otherwise. 40 | # @param context Validation context. 41 | # @raise ActiveRecord::RecordInvalid 42 | 43 | # @!method errors 44 | # Returns the `ActiveModel::Errors` object that holds all information about attribute error messages. 45 | # 46 | # The `ActiveModel::Base` implementation itself, 47 | # but also aggregates error information for objects stored in {#models} when validation is performed. 48 | # 49 | # class Account < ActiveRecord::Base 50 | # validates :name, :email, presence: true 51 | # end 52 | # 53 | # class AccountRegistration < ActiveRecordCompose::Model 54 | # def initialize(attributes = {}) 55 | # @account = Account.new 56 | # super(attributes) 57 | # models << account 58 | # end 59 | # 60 | # attribute :confirmation, :boolean, default: false 61 | # validates :confirmation, presence: true 62 | # 63 | # private 64 | # 65 | # attr_reader :account 66 | # end 67 | # 68 | # registration = AccountRegistration 69 | # registration.valid? 70 | # #=> false 71 | # 72 | # # In addition to the model's own validation error information (`confirmation`), also aggregates 73 | # # error information for objects stored in `account` (`name`, `email`) when validation is performed. 74 | # 75 | # registration.errors.map { _1.attribute } #=> [:name, :email, :confirmation] 76 | # 77 | # @return [ActiveModel::Errors] 78 | 79 | private 80 | 81 | # @private 82 | def validate_models 83 | context = override_validation_context 84 | models.__wrapped_models.lazy.select { _1.invalid?(context) }.each { errors.merge!(_1) } 85 | end 86 | 87 | # @private 88 | def perform_validations(options) 89 | options[:validate] == false || valid?(options[:context]) 90 | end 91 | 92 | # @private 93 | def raise_validation_error = raise ActiveRecord::RecordInvalid, self 94 | 95 | # @private 96 | def context_for_override_validation 97 | @context_for_override_validation ||= OverrideValidationContext.new 98 | end 99 | 100 | # @private 101 | def override_validation_context = context_for_override_validation.context 102 | 103 | # @private 104 | class OverrideValidationContext 105 | attr_reader :context 106 | 107 | def with_override(context) 108 | @context, original = context, @context 109 | yield 110 | ensure 111 | @context = original # steep:ignore 112 | end 113 | end 114 | private_constant :OverrideValidationContext 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/active_record_compose/model_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelTest < ActiveSupport::TestCase 7 | class ComposedModel < ActiveRecordCompose::Model 8 | def initialize(account = Account.new) 9 | @account = account 10 | @profile = account.then { _1.profile || _1.build_profile } 11 | super() 12 | models << account << profile 13 | end 14 | 15 | delegate_attribute :name, :email, to: :account 16 | delegate_attribute :firstname, :lastname, :age, to: :profile 17 | 18 | private 19 | 20 | attr_reader :account, :profile 21 | end 22 | 23 | test "when invalid, an error object is set" do 24 | model = ComposedModel.new 25 | model.assign_attributes(invalid_attributes) 26 | 27 | assert model.invalid? 28 | assert model.errors.of_kind?(:name, :blank) 29 | assert model.errors.of_kind?(:firstname, :too_long) 30 | assert model.errors.of_kind?(:lastname, :too_long) 31 | assert model.errors.of_kind?(:age, :greater_than_or_equal_to) 32 | expected_error_messasges = 33 | [ 34 | "Name can't be blank", 35 | "Email can't be blank", 36 | "Firstname is too long (maximum is 32 characters)", 37 | "Lastname is too long (maximum is 32 characters)", 38 | "Age must be greater than or equal to 0" 39 | ] 40 | assert { model.errors.to_a.sort == expected_error_messasges.sort } 41 | end 42 | 43 | test "when invalid, models are not saved." do 44 | model = ComposedModel.new 45 | model.assign_attributes(invalid_attributes) 46 | 47 | assert { model.save == false } 48 | e = assert_raises(ActiveRecord::RecordInvalid) { model.save! } 49 | assert { model == e.record } 50 | end 51 | 52 | test "when valid assign, #save is performed for each model entered in models by save." do 53 | model = ComposedModel.new 54 | model.assign_attributes(valid_attributes) 55 | 56 | assert { model.valid? == true } 57 | assert_difference -> { Account.count } => 1, -> { Profile.count } => 1 do 58 | model.save! 59 | end 60 | end 61 | 62 | test "pushed nil object must be ignored." do 63 | model_class = Class.new(ComposedModel) do 64 | def push_falsy_object_to_models = models << nil 65 | end 66 | model = model_class.new 67 | model.assign_attributes(valid_attributes) 68 | model.push_falsy_object_to_models 69 | 70 | assert { model.valid? == true } 71 | assert { model.save == true } 72 | end 73 | 74 | test "errors made during internal model storage are propagated externally." do 75 | account_with_bang = Class.new(Account) do 76 | after_save { raise "bang!" } 77 | end 78 | 79 | model = ComposedModel.new(account_with_bang.new) 80 | model.assign_attributes(valid_attributes) 81 | 82 | assert_raises(RuntimeError, "bang!!") { model.save } 83 | assert_raises(RuntimeError, "bang!!") { model.save! } 84 | end 85 | 86 | test "RecordInvalid errors that occur during internal saving of the model are propagated externally only if #save!" do 87 | model_class = Class.new(ComposedModel) do 88 | after_save { Account.create!(name: nil, email: nil) } 89 | end 90 | model = model_class.new 91 | model.assign_attributes(valid_attributes) 92 | 93 | assert { model.save == false } 94 | assert_raises(ActiveRecord::RecordInvalid) do 95 | model.save! 96 | end 97 | end 98 | 99 | test "attributes defined by .delegate_attributes should be included" do 100 | model_class = Class.new(ComposedModel) do 101 | attribute :foo 102 | end 103 | model = model_class.new 104 | model.assign_attributes(valid_attributes) 105 | model.foo = "foobar" 106 | 107 | assert { model.attributes == { "foo" => "foobar", **valid_attributes.stringify_keys } } 108 | end 109 | 110 | private 111 | 112 | def valid_attributes 113 | { 114 | name: "foo", 115 | email: "foo@example.com", 116 | firstname: "bar", 117 | lastname: "baz", 118 | age: 45 119 | } 120 | end 121 | 122 | def invalid_attributes 123 | { 124 | name: nil, 125 | email: nil, 126 | firstname: "*" * 33, 127 | lastname: "*" * 33, 128 | age: -1 129 | } 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/active_record_compose/composed_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "wrapped_model" 4 | 5 | module ActiveRecordCompose 6 | using WrappedModel::PackagePrivate 7 | 8 | # Object obtained by {ActiveRecordCompose::Model#models}. 9 | # 10 | # It functions as a collection that contains the object to be saved. 11 | class ComposedCollection 12 | include Enumerable 13 | 14 | def initialize(owner) 15 | @owner = owner 16 | @models = Set.new 17 | end 18 | 19 | # Enumerates model objects. 20 | # 21 | # @yieldparam [Object] model model instance 22 | # @return [Enumerator] when not block given. 23 | # @return [self] when block given, returns itself. 24 | def each 25 | return enum_for(:each) unless block_given? 26 | 27 | models.each { yield _1.__raw_model } 28 | self 29 | end 30 | 31 | # Appends model to collection. 32 | # 33 | # @param model [Object] model instance 34 | # @return [self] returns itself. 35 | def <<(model) 36 | models << wrap(model, destroy: false) 37 | self 38 | end 39 | 40 | # Appends model to collection. 41 | # 42 | # @param model [Object] model instance 43 | # @param destroy [Boolean, Proc, Symbol] Controls whether the model should be destroyed. 44 | # - Boolean: if `true`, the model will be destroyed. 45 | # - Proc: the model will be destroyed if the proc returns `true`. 46 | # - Symbol: sends the symbol as a method to `owner`; if the result is truthy, the model will be destroyed. 47 | # @param if [Proc, Symbol] Controls conditional inclusion in renewal. 48 | # - Proc: the proc is called, and if it returns `false`, the model is excluded. 49 | # - Symbol: sends the symbol as a method to `owner`; if the result is falsy, the model is excluded. 50 | # @return [self] returns itself. 51 | def push(model, destroy: false, if: nil) 52 | models << wrap(model, destroy:, if:) 53 | self 54 | end 55 | 56 | # Returns true if the element exists. 57 | # 58 | # @return [Boolean] Returns true if the element exists 59 | def empty? = models.empty? 60 | 61 | # Set to empty. 62 | # 63 | # @return [self] returns itself. 64 | def clear 65 | models.clear 66 | self 67 | end 68 | 69 | # Removes the specified model from the collection. 70 | # Returns nil if the deletion fails, self if it succeeds. 71 | # 72 | # The specified model instance will be deleted regardless of the options used when it was added. 73 | # 74 | # @example 75 | # model_a = Model.new 76 | # model_b = Model.new 77 | # 78 | # collection.push(model_a, destroy: true) 79 | # collection.push(model_b) 80 | # collection.push(model_a, destroy: false) 81 | # collection.count #=> 3 82 | # 83 | # collection.delete(model_a) 84 | # collection.count #=> 1 85 | # 86 | # @param model [Object] model instance 87 | # @return [self] Successful deletion 88 | # @return [nil] If deletion fails 89 | def delete(model) 90 | matched = models.select { _1.__raw_model == model } 91 | return nil if matched.blank? 92 | 93 | matched.each { models.delete(_1) } 94 | self 95 | end 96 | 97 | private 98 | 99 | # @private 100 | attr_reader :owner, :models 101 | 102 | # @private 103 | def wrap(model, destroy: false, if: nil) 104 | if destroy.is_a?(Symbol) 105 | destroy = symbol_proc_map[destroy] 106 | end 107 | 108 | if_option = binding.local_variable_get(:if) 109 | if if_option.is_a?(Symbol) 110 | if_option = symbol_proc_map[if_option] 111 | end 112 | 113 | ActiveRecordCompose::WrappedModel.new(model, destroy:, if: if_option) 114 | end 115 | 116 | # @private 117 | def symbol_proc_map 118 | @symbol_proc_map ||= 119 | Hash.new do |h, k| 120 | h[k] = -> { owner.__send__(k) } 121 | end 122 | end 123 | 124 | # @private 125 | module PackagePrivate 126 | refine ComposedCollection do 127 | # Returns array of wrapped model instance. 128 | # 129 | # @private 130 | # @return [Array[WrappedModel]] array of wrapped model instance. 131 | def __wrapped_models 132 | models.reject { _1.ignore? }.uniq { [ _1.__raw_model, !!_1.destroy_context? ] }.select { _1.__raw_model } 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/active_record_compose/model_uniquify_save_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelUniquifySaveTest < ActiveSupport::TestCase 7 | class Inner 8 | include ActiveModel::Model 9 | include ActiveModel::Validations::Callbacks 10 | include ActiveModel::Attributes 11 | 12 | define_model_callbacks :save 13 | define_model_callbacks :destroy 14 | 15 | attribute :save_count, :integer, default: 0 16 | attribute :destroy_count, :integer, default: 0 17 | 18 | after_save { _1.save_count += 1 } 19 | after_destroy { _1.destroy_count += 1 } 20 | 21 | def save(**) = run_callbacks(:save) { true } 22 | def save!(**) = run_callbacks(:save) { true } 23 | def destroy(**) = run_callbacks(:destroy) { true } 24 | def destroy!(**) = run_callbacks(:destroy) { true } 25 | end 26 | 27 | test "Even if the same object is added multiple times, it will only be saved once." do 28 | inner = Inner.new 29 | klass = 30 | Class.new(ActiveRecordCompose::Model) do 31 | def initialize(*__models) 32 | super() 33 | __models.each { models << _1 } 34 | end 35 | end 36 | 37 | model = klass.new(inner, inner) 38 | model.save! 39 | 40 | assert { inner.save_count == 1 } 41 | end 42 | 43 | test "A save is performed once for each object added" do 44 | inner = Inner.new 45 | other_inner = Inner.new 46 | klass = 47 | Class.new(ActiveRecordCompose::Model) do 48 | def initialize(*__models) 49 | super() 50 | __models.each { models << _1 } 51 | end 52 | end 53 | 54 | model = klass.new(inner, inner, other_inner, other_inner) 55 | model.save! 56 | 57 | assert { inner.save_count == 1 } 58 | assert { other_inner.save_count == 1 } 59 | end 60 | 61 | test "Even if the same object is added in different ways, it is only saved once." do 62 | inner = Inner.new 63 | klass = 64 | Class.new(ActiveRecordCompose::Model) do 65 | def initialize(model) 66 | super() 67 | models.push(model) 68 | models << model 69 | end 70 | end 71 | 72 | model = klass.new(inner) 73 | model.save! 74 | 75 | assert { inner.save_count == 1 } 76 | end 77 | 78 | test "Even if the :if options are different, if the evaluation results are the same, it will be considered the same operation." do 79 | inner = Inner.new 80 | klass = 81 | Class.new(ActiveRecordCompose::Model) do 82 | def initialize(model) 83 | super() 84 | models.push(model) 85 | models.push(model, if: :always_true) 86 | models.push(model, if: :always_true_2) 87 | models.push(model, if: -> { always_true }) 88 | end 89 | 90 | def always_true = true 91 | def always_true_2 = true 92 | end 93 | 94 | model = klass.new(inner) 95 | model.save! 96 | 97 | assert { inner.save_count == 1 } 98 | end 99 | 100 | test "If the evaluation result of the if option is falsy, it will be ignored and will not be saved anyway." do 101 | inner = Inner.new 102 | klass = 103 | Class.new(ActiveRecordCompose::Model) do 104 | def initialize(model) 105 | super() 106 | models.push(model) 107 | models.push(model, if: -> { true }) 108 | models.push(model, if: :always_false) 109 | models.push(model, if: :always_false_2) 110 | models.push(model, if: -> { [ true, false ].sample }) 111 | end 112 | 113 | def always_false = false 114 | def always_false_2 = false 115 | end 116 | 117 | model = klass.new(inner) 118 | model.save! 119 | 120 | assert { inner.save_count == 1 } 121 | end 122 | 123 | test "Treated as separate operations depending on the evaluation result of the :destroy option." do 124 | inner = Inner.new 125 | klass = 126 | Class.new(ActiveRecordCompose::Model) do 127 | def initialize(model) 128 | super() 129 | models.push(model) 130 | models.push(model, destroy: true) 131 | models.push(model, destroy: false) 132 | models.push(model, destroy: -> { always_true }) 133 | end 134 | 135 | def always_true = true 136 | end 137 | 138 | model = klass.new(inner) 139 | model.save! 140 | 141 | assert { inner.save_count == 1 } 142 | assert { inner.destroy_count == 1 } 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /sig/active_record_compose.rbs: -------------------------------------------------------------------------------- 1 | # TypeProf 0.21.9 2 | 3 | # Classes 4 | module ActiveRecordCompose 5 | VERSION: String 6 | 7 | interface _ARLike 8 | def save: (**untyped options) -> bool 9 | def save!: (**untyped options) -> untyped 10 | def invalid?: (?validation_context context) -> bool 11 | def valid?: (?validation_context context) -> bool 12 | def errors: -> untyped 13 | def is_a?: (untyped) -> bool 14 | def ==: (untyped) -> bool 15 | end 16 | interface _ARLikeWithDestroy 17 | def save: (**untyped options) -> bool 18 | def save!: (**untyped options) -> untyped 19 | def destroy: -> bool 20 | def destroy!: -> untyped 21 | def invalid?: (?validation_context context) -> bool 22 | def valid?: (?validation_context context) -> bool 23 | def errors: -> untyped 24 | def is_a?: (untyped) -> bool 25 | def ==: (untyped) -> bool 26 | end 27 | type ar_like = (_ARLike | _ARLikeWithDestroy) 28 | 29 | type validation_context = nil | Symbol | Array[Symbol] 30 | 31 | type condition[T] = Symbol | ^(T) [self: T] -> boolish 32 | type callback[T] = Symbol | ^(T) [self: T] -> void 33 | type around_callback[T] = Symbol | ^(T, Proc) [self: T] -> void 34 | 35 | type attribute_name = (String | Symbol) 36 | type destroy_context_type = ((^() -> boolish) | (^(ar_like) -> boolish)) 37 | type condition_type = ((^() -> boolish) | (^(ar_like) -> boolish)) 38 | 39 | class ComposedCollection 40 | include ::Enumerable[ar_like] 41 | 42 | def each: () { (ar_like) -> void } -> ComposedCollection | () -> Enumerator[ar_like, self] 43 | def <<: (ar_like) -> self 44 | def push: (ar_like, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> self 45 | def empty?: -> bool 46 | def clear: -> self 47 | def delete: (ar_like) -> ComposedCollection? 48 | end 49 | 50 | class Model 51 | include ActiveModel::Model 52 | include ActiveModel::Validations::Callbacks 53 | include ActiveModel::Attributes 54 | extend ActiveModel::Callbacks 55 | extend ActiveModel::Validations::ClassMethods 56 | extend ActiveModel::Validations::Callbacks::ClassMethods 57 | extend ActiveModel::Attributes::ClassMethods 58 | 59 | def self.before_save: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 60 | def self.around_save: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 61 | def self.after_save: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 62 | 63 | def self.before_create: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 64 | def self.around_create: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 65 | def self.after_create: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 66 | 67 | def self.before_update: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 68 | def self.around_update: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 69 | def self.after_update: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 70 | 71 | def self.after_commit: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 72 | def self.after_rollback: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void 73 | 74 | def self.delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: bool) -> untyped 75 | 76 | def self.filter_attributes: () -> Array[untyped] 77 | def self.filter_attributes=: (Array[untyped]) -> untyped 78 | 79 | def initialize: (?Hash[attribute_name, untyped]) -> void 80 | def save: (**untyped options) -> bool 81 | def save!: (**untyped options) -> untyped 82 | def update: (Hash[attribute_name, untyped]) -> bool 83 | def update!: (Hash[attribute_name, untyped]) -> untyped 84 | 85 | def id: -> untyped 86 | 87 | def inspect: () -> String 88 | def pretty_print: (untyped q) -> void 89 | 90 | private 91 | def models: -> ComposedCollection 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/use_cases/batch_update_and_delete_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class BatchUpdateAndDeleteTest < ActiveSupport::TestCase 6 | # Here, we are testing cases where operations such as updating a certain model and 7 | # deleting another model are performed at once. 8 | # 9 | # As a specific example, we are considering something like user withdrawal processing. 10 | # 11 | # Assuming that existing activated users have one piece of data each in the 12 | # `Account`, `Profile`, and `Credential` models, 13 | # the following data updates will be implemented through the withdrawal process. 14 | # 15 | # 1. Enter a timestamp in the resigned_at attribute of the Account (mark for resignation) 16 | # 2. Delete the Profile (profiles) associated with the Account 17 | # 3. Delete the Credential (credentials) associated with the Account 18 | # 19 | # From this data manipulation event, we identify a resource called Resignation 20 | # and **create** it so that it can be resolved with normal Rails application operations. 21 | # 22 | # We are assuming cases where the following controllers are applied. 23 | # 24 | # # app/controllers/resignations_controller.rb 25 | # # 26 | # class ResignationsController < ApplicationController 27 | # before_action :require_login 28 | # 29 | # def new 30 | # @resignation = Resignation.new(current_user) 31 | # end 32 | # 33 | # def create 34 | # @resignation = Resignation.new(current_user) 35 | # if @resignation.update(resignation_params) 36 | # redirect_to root_path, notice: "resigned." 37 | # else 38 | # render :new, status: :unprocessable_entity 39 | # end 40 | # end 41 | # 42 | # private 43 | # 44 | # def resignation_params 45 | # params.expect(resignation: %i[resign_confirmation]) 46 | # end 47 | # end 48 | # 49 | class Resignation < ActiveRecordCompose::Model 50 | def initialize(account) 51 | @account = account 52 | @profile = account.profile 53 | @credential = account.credential 54 | 55 | super() 56 | 57 | models << account 58 | models.push(profile, destroy: true) 59 | models.push(credential, destroy: true) 60 | end 61 | 62 | attribute :resign_confirmation, :boolean, default: false 63 | 64 | validates :resign_confirmation, acceptance: true 65 | 66 | before_validation :set_resigned_at 67 | after_commit :send_resigned_mail 68 | 69 | attr_accessor :send_resigned_mail_called 70 | 71 | private 72 | 73 | attr_reader :account, :profile, :credential 74 | 75 | def set_resigned_at 76 | account.resigned_at = Time.now 77 | end 78 | 79 | def send_resigned_mail 80 | # This is similar to sending an email notifying the user that their account has been deleted. 81 | # 82 | # ex. AccountMailer.with(account:).resigned.deliver_later 83 | self.send_resigned_mail_called = true 84 | end 85 | end 86 | 87 | setup do 88 | account = Account.create!(name: "alice-in-wonderland", email: "alice@example.com") 89 | account.create_profile!(firstname: "Alice", lastname: "Smish", age: 18) 90 | account.create_credential!(password: "P@ssW0rd", password_confirmation: "P@ssW0rd") 91 | @account = account 92 | 93 | @resignation = Resignation.new(@account) 94 | end 95 | 96 | test "When invalid, no updates will be made to the data, and error information can be obtained." do 97 | resignation_params = { resign_confirmation: false } 98 | 99 | assert_no_changes -> { @account.reload.resigned_at } do 100 | assert_no_difference -> { Profile.count }, -> { Credential.count } do 101 | assert_no_changes -> { @resignation.send_resigned_mail_called } do 102 | assert_not @resignation.update(resignation_params) 103 | end 104 | end 105 | end 106 | 107 | assert { @resignation.errors.count == 1 } 108 | assert { @resignation.errors.of_kind?(:resign_confirmation, :accepted) } 109 | assert { @resignation.errors.to_a == [ "Resign confirmation must be accepted" ] } 110 | end 111 | 112 | test "When valid, the data will be updated." do 113 | resignation_params = { resign_confirmation: true } 114 | 115 | assert_changes -> { @account.reload.resigned_at } do 116 | assert_difference -> { Profile.count } => -1, -> { Credential.count } => -1 do 117 | assert_changes -> { @resignation.send_resigned_mail_called } do 118 | assert @resignation.update(resignation_params) 119 | end 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/active_record_compose/persistence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "callbacks" 4 | require_relative "composed_collection" 5 | 6 | module ActiveRecordCompose 7 | using ComposedCollection::PackagePrivate 8 | 9 | module Persistence 10 | extend ActiveSupport::Concern 11 | include ActiveRecordCompose::Callbacks 12 | 13 | # Save the models that exist in models. 14 | # Returns false if any of the targets fail, true if all succeed. 15 | # 16 | # The save is performed within a single transaction. 17 | # 18 | # Only the `:validate` option takes effect as it is required internally. 19 | # However, we do not recommend explicitly specifying `validate: false` to skip validation. 20 | # Additionally, the `:context` option is not accepted. 21 | # The need for such a value indicates that operations from multiple contexts are being processed. 22 | # If the contexts differ, we recommend separating them into different model definitions. 23 | # 24 | # @param options [Hash] parameters. 25 | # @option options [Boolean] :validate Whether to run validations. 26 | # This option is intended for internal use only. 27 | # Users should avoid explicitly passing validate: false, 28 | # as skipping validations can lead to unexpected behavior. 29 | # @return [Boolean] returns true on success, false on failure. 30 | def save(**options) 31 | with_callbacks { save_models(**options, bang: false) } 32 | rescue ActiveRecord::RecordInvalid 33 | false 34 | end 35 | 36 | # Behavior is same to {#save}, but raises an exception prematurely on failure. 37 | # 38 | # @see #save 39 | # @raise ActiveRecord::RecordInvalid 40 | # @raise ActiveRecord::RecordNotSaved 41 | def save!(**options) 42 | with_callbacks { save_models(**options, bang: true) } || raise_on_save_error 43 | end 44 | 45 | # Assign attributes and {#save}. 46 | # 47 | # @param [Hash] attributes 48 | # new attributes. 49 | # @see #save 50 | # @return [Boolean] returns true on success, false on failure. 51 | def update(attributes) 52 | assign_attributes(attributes) 53 | save 54 | end 55 | 56 | # Behavior is same to {#update}, but raises an exception prematurely on failure. 57 | # 58 | # @param [Hash] attributes 59 | # new attributes. 60 | # @see #save 61 | # @see #update 62 | # @raise ActiveRecord::RecordInvalid 63 | # @raise ActiveRecord::RecordNotSaved 64 | def update!(attributes) 65 | assign_attributes(attributes) 66 | save! 67 | end 68 | 69 | # @!method persisted? 70 | # Returns true if model is persisted. 71 | # 72 | # By overriding this definition, you can control the callbacks that are triggered when a save is made. 73 | # For example, returning false will trigger before_create, around_create and after_create, 74 | # and returning true will trigger {.before_update}, {.around_update} and {.after_update}. 75 | # 76 | # @return [Boolean] returns true if model is persisted. 77 | # @example 78 | # # A model where persistence is always false 79 | # class Foo < ActiveRecordCompose::Model 80 | # before_save { puts "before_save called" } 81 | # before_create { puts "before_create called" } 82 | # before_update { puts "before_update called" } 83 | # after_update { puts "after_update called" } 84 | # after_create { puts "after_create called" } 85 | # after_save { puts "after_save called" } 86 | # 87 | # def persisted? = false 88 | # end 89 | # 90 | # # A model where persistence is always true 91 | # class Bar < Foo 92 | # def persisted? = true 93 | # end 94 | # 95 | # Foo.new.save! 96 | # # before_save called 97 | # # before_create called 98 | # # after_create called 99 | # # after_save called 100 | # 101 | # Bar.new.save! 102 | # # before_save called 103 | # # before_update called 104 | # # after_update called 105 | # # after_save called 106 | 107 | private 108 | 109 | # @private 110 | def save_models(bang:, **options) 111 | models.__wrapped_models.all? do |model| 112 | if bang 113 | model.save!(**options, validate: false) 114 | else 115 | model.save(**options, validate: false) 116 | end 117 | end 118 | end 119 | 120 | # @private 121 | def raise_on_save_error = raise ActiveRecord::RecordNotSaved.new(raise_on_save_error_message, self) 122 | 123 | # @private 124 | def raise_on_save_error_message = "Failed to save the model." 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/active_record_compose/wrapped_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/object" 4 | 5 | module ActiveRecordCompose 6 | # @private 7 | class WrappedModel 8 | # @param model [Object] the model instance. 9 | # @param destroy [Boolean, Proc, Symbol] Controls whether the model should be destroyed. 10 | # - Boolean: if `true`, the model will be destroyed. 11 | # - Proc: the model will be destroyed if the proc returns `true`. 12 | # @param if [Proc] evaluation result is false, it will not be included in the renewal. 13 | def initialize(model, destroy: false, if: nil) 14 | @model = model 15 | @destroy_context_type = destroy 16 | @if_option = binding.local_variable_get(:if) 17 | end 18 | 19 | delegate :errors, to: :model 20 | 21 | # Determines whether to save or delete the target object. 22 | # Depends on the `destroy` value of the WrappedModel object initialization option. 23 | # 24 | # On the other hand, there are values `mark_for_destruction` and `marked_for_destruction?` in ActiveRecord. 25 | # However, these values are not substituted here. 26 | # These values only work if the `autosave` option is enabled for the parent model, 27 | # and are not appropriate for other cases. 28 | # 29 | # @return [Boolean] returns true on destroy, false on save. 30 | def destroy_context? 31 | d = destroy_context_type 32 | if d.is_a?(Proc) 33 | if d.arity == 0 34 | # @type var d: ^() -> bool 35 | !!d.call 36 | else 37 | # @type var d: ^(_ARLike) -> bool 38 | !!d.call(model) 39 | end 40 | else 41 | !!d 42 | end 43 | end 44 | 45 | # Returns a boolean indicating whether or not to exclude the user from the update. 46 | # 47 | # @return [Boolean] if true, exclude from update. 48 | def ignore? 49 | i = if_option 50 | if i.nil? 51 | false 52 | elsif i.arity == 0 53 | # @type var i: ^() -> bool 54 | !i.call 55 | else 56 | # @type var i: ^(_ARLike) -> bool 57 | !i.call(model) 58 | end 59 | end 60 | 61 | # Execute save or destroy. Returns true on success, false on failure. 62 | # Whether save or destroy is executed depends on the value of `#destroy_context?`. 63 | # 64 | # @return [Boolean] returns true on success, false on failure. 65 | def save(**options) 66 | # While errors caused by the type check are avoided, 67 | # it is important to note that an error can still occur 68 | # if `#destroy_context?` returns true but ar_like does not implement `#destroy`. 69 | m = model 70 | if destroy_context? 71 | # @type var m: ActiveRecordCompose::_ARLikeWithDestroy 72 | m.destroy 73 | else 74 | # @type var m: ActiveRecordCompose::_ARLike 75 | m.save(**options) 76 | end 77 | end 78 | 79 | # Execute save or destroy. Unlike #save, an exception is raises on failure. 80 | # Whether save or destroy is executed depends on the value of `#destroy_context?`. 81 | # 82 | def save!(**options) 83 | # While errors caused by the type check are avoided, 84 | # it is important to note that an error can still occur 85 | # if `#destroy_context?` returns true but ar_like does not implement `#destroy`. 86 | m = model 87 | if destroy_context? 88 | # @type var m: ActiveRecordCompose::_ARLikeWithDestroy 89 | m.destroy! 90 | else 91 | # @type var model: ActiveRecordCompose::_ARLike 92 | m.save!(**options) 93 | end 94 | end 95 | 96 | # @return [Boolean] 97 | def invalid?(context = nil) = !valid?(context) 98 | 99 | # @return [Boolean] 100 | def valid?(context = nil) = destroy_context? || model.valid?(context) 101 | 102 | # Returns true if equivalent. 103 | # 104 | # @param [Object] other 105 | # @return [Boolean] 106 | def ==(other) 107 | return true if equal?(other) 108 | return false unless self.class == other.class 109 | 110 | equality_key == other.equality_key 111 | end 112 | 113 | def eql?(other) 114 | return true if equal?(other) 115 | return false unless self.class == other.class 116 | 117 | equality_key.eql?(other.equality_key) 118 | end 119 | 120 | def hash = equality_key.hash 121 | 122 | protected 123 | 124 | def equality_key = [ model, destroy_context_type, if_option ] 125 | 126 | private 127 | 128 | attr_reader :model, :destroy_context_type, :if_option 129 | 130 | # @private 131 | module PackagePrivate 132 | refine WrappedModel do 133 | # @private 134 | # Returns a model instance of raw, but it should 135 | # be noted that application developers are not expected to use this interface. 136 | # 137 | # @return [Object] raw model instance 138 | def __raw_model = model 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/active_record_compose/model_callback_order_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelCallbackOrderTest < ActiveSupport::TestCase 7 | class CallbackOrder < ActiveRecordCompose::Model 8 | def initialize(tracer, persisted: false) 9 | @tracer = tracer 10 | @persisted = persisted 11 | super() 12 | end 13 | 14 | before_save { tracer << "before_save called" } 15 | before_create { tracer << "before_create called" } 16 | before_update { tracer << "before_update called" } 17 | before_commit { tracer << "before_commit called" } 18 | after_save { tracer << "after_save called" } 19 | after_create { tracer << "after_create called" } 20 | after_update { tracer << "after_update called" } 21 | after_rollback { tracer << "after_rollback called" } 22 | after_commit { tracer << "after_commit called" } 23 | 24 | def persisted? = !!@persisted 25 | 26 | private 27 | 28 | attr_reader :tracer 29 | end 30 | 31 | test "when persisted, #save causes (before|after)_(save|update) and after_commit callback to work" do 32 | tracer = [] 33 | model = CallbackOrder.new(tracer, persisted: true) 34 | 35 | model.save 36 | expected = 37 | [ 38 | "before_save called", 39 | "before_update called", 40 | "after_update called", 41 | "after_save called", 42 | "before_commit called", 43 | "after_commit called" 44 | ] 45 | assert { tracer == expected } 46 | end 47 | 48 | test "when not persisted, #save causes (before|after)_(save|create) and after_commit callback to work" do 49 | tracer = [] 50 | model = CallbackOrder.new(tracer, persisted: false) 51 | 52 | model.save 53 | expected = 54 | [ 55 | "before_save called", 56 | "before_create called", 57 | "after_create called", 58 | "after_save called", 59 | "before_commit called", 60 | "after_commit called" 61 | ] 62 | assert { tracer == expected } 63 | end 64 | 65 | test "when persisted, #update causes (before|after)_(save|update) and after_commit callback to work" do 66 | tracer = [] 67 | model = CallbackOrder.new(tracer, persisted: true) 68 | 69 | model.update({}) 70 | expected = 71 | [ 72 | "before_save called", 73 | "before_update called", 74 | "after_update called", 75 | "after_save called", 76 | "before_commit called", 77 | "after_commit called" 78 | ] 79 | assert { tracer == expected } 80 | end 81 | 82 | test "when not persisted, #update causes (before|after)_(save|create) and after_commit callback to work" do 83 | tracer = [] 84 | model = CallbackOrder.new(tracer, persisted: false) 85 | 86 | model.update({}) 87 | expected = 88 | [ 89 | "before_save called", 90 | "before_create called", 91 | "after_create called", 92 | "after_save called", 93 | "before_commit called", 94 | "after_commit called" 95 | ] 96 | assert { tracer == expected } 97 | end 98 | 99 | test "execution of (before|after)_commit hook is delayed until after the database commit." do 100 | tracer = [] 101 | model = CallbackOrder.new(tracer) 102 | 103 | ActiveRecord::Base.transaction do 104 | tracer << "outer transsaction starts" 105 | ActiveRecord::Base.transaction do 106 | tracer << "inner transsaction starts" 107 | model.save 108 | tracer << "inner transsaction ends" 109 | end 110 | tracer << "outer transsaction ends" 111 | end 112 | 113 | expected = 114 | [ 115 | "outer transsaction starts", 116 | "inner transsaction starts", 117 | "before_save called", 118 | "before_create called", 119 | "after_create called", 120 | "after_save called", 121 | "inner transsaction ends", 122 | "outer transsaction ends", 123 | "before_commit called", 124 | "after_commit called" 125 | ] 126 | assert { tracer == expected } 127 | end 128 | 129 | test "execution of after_rollback hook is delayed until after the database rollback." do 130 | tracer = [] 131 | model = CallbackOrder.new(tracer) 132 | 133 | ActiveRecord::Base.transaction do 134 | tracer << "outer transsaction starts" 135 | ActiveRecord::Base.transaction do 136 | tracer << "inner transsaction starts" 137 | model.save 138 | tracer << "inner transsaction ends" 139 | end 140 | tracer << "outer transsaction ends" 141 | raise ActiveRecord::Rollback 142 | end 143 | 144 | expected = 145 | [ 146 | "outer transsaction starts", 147 | "inner transsaction starts", 148 | "before_save called", 149 | "before_create called", 150 | "after_create called", 151 | "after_save called", 152 | "inner transsaction ends", 153 | "outer transsaction ends", 154 | "after_rollback called" 155 | ] 156 | assert { tracer == expected } 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/active_record_compose/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "attributes" 4 | require_relative "composed_collection" 5 | require_relative "inspectable" 6 | require_relative "persistence" 7 | require_relative "transaction_support" 8 | require_relative "validations" 9 | 10 | module ActiveRecordCompose 11 | # This is the core class of {ActiveRecordCompose}. 12 | # 13 | # By defining subclasses of this model, you can use ActiveRecordCompose functionality in your application. 14 | # It has the basic functionality of `ActiveModel::Model` and `ActiveModel::Attributes`, 15 | # and also provides aggregation of multiple models and atomic updates through transaction control. 16 | # @example Example of model registration. 17 | # class AccountRegistration < ActiveRecordCompose::Model 18 | # def initialize(account = Account.new, attributes = {}) 19 | # @account = account 20 | # @profile = @account.build_profile 21 | # models << account << profile 22 | # super(attributes) 23 | # end 24 | # 25 | # attribute :register_confirmation, :boolean, default: false 26 | # delegate_attribute :name, :email, to: :account 27 | # delegate_attribute :firstname, :lastname, :age, to: :profile 28 | # 29 | # validates :register_confirmation, presence: true 30 | # 31 | # private 32 | # 33 | # attr_reader :account, :profile 34 | # end 35 | # @example Multiple model update once. 36 | # registration = AccountRegistration.new 37 | # registration.assign_attributes( 38 | # name: "alice-in-wonderland", 39 | # email: "alice@example.com", 40 | # firstname: "Alice", 41 | # lastname: "Smith", 42 | # age: 24, 43 | # register_confirmation: true 44 | # ) 45 | # 46 | # registration.save! # Register Account and Profile models at the same time. 47 | # Account.count # => (0 ->) 1 48 | # Profile.count # => (0 ->) 1 49 | # @example Attribute delegation. 50 | # account = Account.new 51 | # account.name = "foo" 52 | # 53 | # registration = AccountRegistration.new(account) 54 | # registration.name # => "foo" (delegated) 55 | # registration.name? # => true (delegated attribute method + `?`) 56 | # 57 | # registration.name = "bar" # => updates account.name 58 | # account.name # => "bar" 59 | # account.name? # => true 60 | # 61 | # registration.attributes # => { "original_attribute" => "qux", "name" => "bar" } 62 | # @example Aggregate errors on invalid. 63 | # registration = AccountRegistration.new 64 | # 65 | # registration.name = "alice-in-wonderland" 66 | # registration.firstname = "Alice" 67 | # registration.age = 18 68 | # 69 | # registration.valid? 70 | # #=> false 71 | # 72 | # # The error contents of the objects stored in models are aggregated. 73 | # # For example, direct access to errors in Account#email. 74 | # registration.errors[:email].to_a # Account#email 75 | # #=> ["can't be blank"] 76 | # 77 | # # Of course, the validation defined for itself is also working. 78 | # registration.errors[:register_confirmation].to_a 79 | # #=> ["can't be blank"] 80 | # 81 | # registration.errors.to_a 82 | # #=> ["Email can't be blank", "Lastname can't be blank", "Register confirmation can't be blank"] 83 | class Model 84 | include ActiveModel::Model 85 | 86 | include ActiveRecordCompose::Attributes 87 | include ActiveRecordCompose::Persistence 88 | include ActiveRecordCompose::Validations 89 | include ActiveRecordCompose::TransactionSupport 90 | include ActiveRecordCompose::Inspectable 91 | 92 | def initialize(attributes = {}) 93 | super 94 | end 95 | 96 | # Returns the ID value. This value is used when passing it to the `:model` option of `form_with`, etc. 97 | # Normally it returns nil, but it can be overridden to delegate to the containing model. 98 | # 99 | # @example Redefine the id method by delegating to the containing model 100 | # class Foo < ActiveRecordCompose::Model 101 | # def initialize(primary_model) 102 | # @primary_model = primary_model 103 | # # ... 104 | # end 105 | # 106 | # def id 107 | # primary_model.id 108 | # end 109 | # 110 | # private 111 | # 112 | # attr_reader :primary_model 113 | # end 114 | # 115 | # @return [Object] ID value 116 | # 117 | def id = nil 118 | 119 | private 120 | 121 | # Returns a collection of model elements to encapsulate. 122 | # @example Adding models 123 | # models << inner_model_a << inner_model_b 124 | # models.push(inner_model_c) 125 | # @example `#push` can have `:destroy` `:if` options 126 | # models.push(profile, destroy: :blank_profile?) 127 | # models.push(profile, destroy: -> { blank_profile? }) 128 | # @return [ActiveRecordCompose::ComposedCollection] 129 | # 130 | def models = @__models ||= ActiveRecordCompose::ComposedCollection.new(self) 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/active_record_compose/transaction_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module" 4 | 5 | module ActiveRecordCompose 6 | module TransactionSupport 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | define_callbacks :commit, :rollback, :before_commit, scope: [ :kind, :name ] 11 | end 12 | 13 | # steep:ignore:start 14 | 15 | class_methods do 16 | # @private 17 | # @deprecated 18 | def with_connection(...) 19 | ActiveRecord.deprecator.warn("`with_connection` is deprecated. Use `ActiveRecord::Base.with_connection` instead.") 20 | ActiveRecord::Base.with_connection(...) 21 | end 22 | 23 | # @private 24 | # @deprecated 25 | def lease_connection(...) 26 | ActiveRecord.deprecator.warn("`lease_connection` is deprecated. Use `ActiveRecord::Base.lease_connection` instead.") 27 | ActiveRecord::Base.lease_connection(...) 28 | end 29 | 30 | # @private 31 | # @deprecated 32 | def connection(...) 33 | ActiveRecord.deprecator.warn("`connection` is deprecated. Use `ActiveRecord::Base.connection` instead.") 34 | ActiveRecord::Base.connection(...) 35 | end 36 | end 37 | 38 | # steep:ignore:end 39 | 40 | # steep:ignore:start 41 | 42 | class_methods do 43 | # @private 44 | def before_commit(*args, &block) 45 | set_options_for_callbacks!(args) 46 | set_callback(:before_commit, :before, *args, &block) 47 | end 48 | 49 | # Registers a block to be called after the transaction is fully committed. 50 | # 51 | def after_commit(*args, &block) 52 | set_options_for_callbacks!(args, prepend_option) 53 | set_callback(:commit, :after, *args, &block) 54 | end 55 | 56 | # Registers a block to be called after the transaction is rolled back. 57 | # 58 | def after_rollback(*args, &block) 59 | set_options_for_callbacks!(args, prepend_option) 60 | set_callback(:rollback, :after, *args, &block) 61 | end 62 | 63 | private 64 | 65 | # @private 66 | def prepend_option 67 | if ActiveRecord.run_after_transaction_callbacks_in_order_defined 68 | { prepend: true } 69 | else 70 | {} 71 | end 72 | end 73 | 74 | # @private 75 | def set_options_for_callbacks!(args, enforced_options = {}) 76 | options = args.extract_options!.merge!(enforced_options) 77 | args << options 78 | end 79 | end 80 | 81 | # steep:ignore:end 82 | 83 | concerning :SupportForActiveRecordConnectionAdaptersTransaction do 84 | # @private 85 | def trigger_transactional_callbacks? = true 86 | 87 | # @private 88 | def before_committed! 89 | _run_before_commit_callbacks 90 | end 91 | 92 | # @private 93 | def committed!(should_run_callbacks: true) 94 | _run_commit_callbacks if should_run_callbacks 95 | end 96 | 97 | # @private 98 | def rolledback!(force_restore_state: false, should_run_callbacks: true) 99 | _run_rollback_callbacks if should_run_callbacks 100 | end 101 | end 102 | 103 | def save(**options) = with_transaction_returning_status { super } 104 | 105 | def save!(**options) = with_transaction_returning_status { super } 106 | 107 | private 108 | 109 | # @private 110 | def with_transaction_returning_status 111 | connection_pool.with_connection do |connection| 112 | with_pool_transaction_isolation_level(connection) do 113 | ensure_finalize = !connection.transaction_open? 114 | 115 | connection.transaction do 116 | connection.add_transaction_record(self, ensure_finalize || has_transactional_callbacks?) # steep:ignore 117 | 118 | yield.tap { raise ActiveRecord::Rollback unless _1 } 119 | end || false 120 | end 121 | end 122 | end 123 | 124 | # @private 125 | def default_ar_class = ActiveRecord::Base 126 | 127 | # @private 128 | def connection_pool(ar_class: default_ar_class) 129 | connection_specification_name = ar_class.connection_specification_name 130 | role = ar_class.current_role 131 | shard = ar_class.current_shard # steep:ignore 132 | connection_handler = ar_class.connection_handler # steep:ignore 133 | retrieve_options = { role:, shard: } 134 | retrieve_options[:strict] = true if ActiveRecord.gem_version.release >= Gem::Version.new("7.2.0") 135 | 136 | connection_handler.retrieve_connection_pool(connection_specification_name, **retrieve_options) 137 | end 138 | 139 | # @private 140 | def with_pool_transaction_isolation_level(connection, &block) 141 | if ActiveRecord.gem_version.release >= Gem::Version.new("8.1.0") 142 | isolation_level = ActiveRecord.default_transaction_isolation_level # steep:ignore 143 | connection.pool.with_pool_transaction_isolation_level(isolation_level, connection.transaction_open?, &block) 144 | else 145 | block.call 146 | end 147 | end 148 | 149 | # @private 150 | def has_transactional_callbacks? 151 | _rollback_callbacks.present? || _commit_callbacks.present? || _before_commit_callbacks.present? # steep:ignore 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hamajyotan@gmail.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /test/active_record_compose/model_with_destroy_context_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record_compose/model" 5 | 6 | class ActiveRecordCompose::ModelWithDestroyContextTest < ActiveSupport::TestCase 7 | class WithDestroyContext < ActiveRecordCompose::Model 8 | def initialize(account, attributes = {}) 9 | @account = account 10 | @profile = account.profile || account.build_profile 11 | super(attributes) 12 | models.push(account) 13 | push_profile_to_models 14 | end 15 | 16 | delegate_attribute :name, :email, to: :account 17 | delegate_attribute :firstname, :lastname, :age, to: :profile 18 | 19 | private 20 | 21 | attr_reader :account, :profile 22 | 23 | def push_profile_to_models = raise NotImplementedError 24 | 25 | def blank_profile? = firstname.blank? && lastname.blank? && age.blank? 26 | end 27 | 28 | test "model with destroy: true should be ignored (always valid) in validation" do 29 | model_class = Class.new(WithDestroyContext) do 30 | def push_profile_to_models 31 | models.push(profile, destroy: true) 32 | end 33 | end 34 | 35 | account = Account.create!(name: "foo", email: "foo@example.com") 36 | account.create_profile!(firstname: "bar", lastname: "baz", age: 45) 37 | model = model_class.new(account) 38 | model.name = "bar" 39 | model.email = "bar@example.com" 40 | model.firstname = nil 41 | model.lastname = nil 42 | model.age = nil 43 | 44 | assert model.valid? 45 | end 46 | 47 | test "models with destroy: true must be deleted by a #save operation" do 48 | model_class = Class.new(WithDestroyContext) do 49 | def push_profile_to_models 50 | models.push(profile, destroy: true) 51 | end 52 | end 53 | 54 | account = Account.create!(name: "foo", email: "foo@example.com") 55 | account.create_profile!(firstname: "bar", lastname: "baz", age: 45) 56 | model = model_class.new(account) 57 | model.name = "bar" 58 | model.email = "bar@example.com" 59 | 60 | assert_difference -> { Profile.count } => -1 do 61 | model.save! 62 | end 63 | 64 | account.reload 65 | assert { account.name == "bar" } 66 | assert { account.email == "bar@example.com" } 67 | end 68 | 69 | test "proc with arguments is passed to destroy, save and destroy can be controlled by result of that evaluation." do 70 | model_class = Class.new(WithDestroyContext) do 71 | def push_profile_to_models 72 | destroy = ->(p) { p.firstname.blank? && p.lastname.blank? && p.age.blank? } 73 | models.push(profile, destroy:) 74 | end 75 | end 76 | 77 | account = Account.create!(name: "foo", email: "foo@example.com") 78 | account.create_profile!(firstname: "bar", lastname: "baz", age: 45) 79 | model = model_class.new(account) 80 | model.assign_attributes(firstname: "qux", lastname: "quux", age: 36) 81 | 82 | assert_no_changes -> { Profile.count } do 83 | model.save! 84 | end 85 | account.profile.reload 86 | assert { account.profile.firstname == "qux" } 87 | assert { account.profile.lastname == "quux" } 88 | assert { account.profile.age == 36 } 89 | 90 | model.assign_attributes(firstname: nil, lastname: nil, age: nil) 91 | assert_difference -> { Profile.count } => -1 do 92 | model.save! 93 | end 94 | end 95 | 96 | test "proc is passed to destroy with no arguments, save and destroy can be controlled by result of its evaluation." do 97 | model_class = Class.new(WithDestroyContext) do 98 | def push_profile_to_models 99 | models.push(profile, destroy: -> { blank_profile? }) 100 | end 101 | end 102 | 103 | account = Account.create!(name: "foo", email: "foo@example.com") 104 | account.create_profile!(firstname: "bar", lastname: "baz", age: 45) 105 | model = model_class.new(account) 106 | model.assign_attributes(firstname: "qux", lastname: "quux", age: 36) 107 | 108 | assert_no_changes -> { Profile.count } do 109 | model.save! 110 | end 111 | account.profile.reload 112 | assert { account.profile.firstname == "qux" } 113 | assert { account.profile.lastname == "quux" } 114 | assert { account.profile.age == 36 } 115 | 116 | model.assign_attributes(firstname: nil, lastname: nil, age: nil) 117 | assert_difference -> { Profile.count } => -1 do 118 | model.save! 119 | end 120 | end 121 | 122 | test "if method name symbol is passed to destroy, save and destroy can be controlled by result of its evaluation." do 123 | model_class = Class.new(WithDestroyContext) do 124 | def push_profile_to_models 125 | models.push(profile, destroy: :blank_profile?) 126 | end 127 | end 128 | 129 | account = Account.create!(name: "foo", email: "foo@example.com") 130 | account.create_profile!(firstname: "bar", lastname: "baz", age: 45) 131 | model = model_class.new(account) 132 | model.assign_attributes(firstname: "qux", lastname: "quux", age: 36) 133 | 134 | assert_no_changes -> { Profile.count } do 135 | model.save! 136 | end 137 | account.profile.reload 138 | assert { account.profile.firstname == "qux" } 139 | assert { account.profile.lastname == "quux" } 140 | assert { account.profile.age == 36 } 141 | 142 | model.assign_attributes(firstname: nil, lastname: nil, age: nil) 143 | assert_difference -> { Profile.count } => -1 do 144 | model.save! 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/active_record_compose/inspectable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/parameter_filter" 4 | require_relative "attributes" 5 | 6 | module ActiveRecordCompose 7 | # It provides #inspect behavior. 8 | # It tries to replicate the inspect format provided by ActiveRecord as closely as possible. 9 | # 10 | # @example 11 | # class Model < ActiveRecordCompose::Model 12 | # def initialize(ar_model) 13 | # @ar_model = ar_model 14 | # super 15 | # end 16 | # 17 | # attribute :foo, :date, default: -> { Date.today } 18 | # delegate_attribute :bar, to: :ar_model 19 | # 20 | # private attr_reader :ar_model 21 | # end 22 | # 23 | # m = Model.new(ar_model) 24 | # m.inspect #=> # 25 | # 26 | # @example 27 | # class Model < ActiveRecordCompose::Model 28 | # self.filter_attributes += %i[foo] 29 | # 30 | # # ... 31 | # end 32 | # 33 | # m = Model.new(ar_model) 34 | # m.inspect #=> # 35 | # 36 | module Inspectable 37 | extend ActiveSupport::Concern 38 | include ActiveRecordCompose::Attributes 39 | 40 | # steep:ignore:start 41 | 42 | # @private 43 | FILTERED_MASK = 44 | Class.new(DelegateClass(::String)) do 45 | def pretty_print(pp) 46 | pp.text __getobj__ 47 | end 48 | end.new(ActiveSupport::ParameterFilter::FILTERED).freeze 49 | private_constant :FILTERED_MASK 50 | 51 | # steep:ignore:end 52 | 53 | included do 54 | self.filter_attributes = [] 55 | end 56 | 57 | # steep:ignore:start 58 | 59 | class_methods do 60 | # Returns columns not to expose when invoking {#inspect}. 61 | # 62 | # @return [Array] 63 | # @see #inspect 64 | def filter_attributes 65 | if @filter_attributes.nil? 66 | superclass.filter_attributes 67 | else 68 | @filter_attributes 69 | end 70 | end 71 | 72 | # Specify columns not to expose when invoking {#inspect}. 73 | # 74 | # @param [Array] value 75 | # @see #inspect 76 | def filter_attributes=(value) 77 | @inspection_filter = nil 78 | @filter_attributes = value 79 | end 80 | 81 | # @private 82 | def inspection_filter 83 | if @filter_attributes.nil? 84 | superclass.inspection_filter 85 | else 86 | @inspection_filter ||= ActiveSupport::ParameterFilter.new(filter_attributes, mask: FILTERED_MASK) 87 | end 88 | end 89 | 90 | private 91 | 92 | def inherited(subclass) 93 | super 94 | 95 | subclass.class_eval do 96 | @inspection_filter = nil 97 | @filter_attributes ||= nil 98 | end 99 | end 100 | end 101 | 102 | # steep:ignore:end 103 | 104 | # Returns a formatted string representation of the record's attributes. 105 | # It tries to replicate the inspect format provided by ActiveRecord as closely as possible. 106 | # 107 | # @example 108 | # class Model < ActiveRecordCompose::Model 109 | # def initialize(ar_model) 110 | # @ar_model = ar_model 111 | # super 112 | # end 113 | # 114 | # attribute :foo, :date, default: -> { Date.today } 115 | # delegate_attribute :bar, to: :ar_model 116 | # 117 | # private attr_reader :ar_model 118 | # end 119 | # 120 | # m = Model.new(ar_model) 121 | # m.inspect #=> # 122 | # 123 | # @example use {.filter_attributes} 124 | # class Model < ActiveRecordCompose::Model 125 | # self.filter_attributes += %i[foo] 126 | # 127 | # # ... 128 | # end 129 | # 130 | # m = Model.new(ar_model) 131 | # m.inspect #=> # 132 | # 133 | # @return [String] formatted string representation of the record's attributes. 134 | # @see .filter_attributes 135 | def inspect 136 | inspection = 137 | if @attributes 138 | attributes.map { |k, v| "#{k}: #{format_for_inspect(k, v)}" }.join(", ") 139 | else 140 | "not initialized" 141 | end 142 | 143 | "#<#{self.class} #{inspection}>" 144 | end 145 | 146 | # It takes a PP and pretty prints that record. 147 | # 148 | def pretty_print(pp) 149 | pp.object_address_group(self) do 150 | if @attributes 151 | attrs = attributes 152 | pp.seplist(attrs.keys, proc { pp.text "," }) do |attr| 153 | pp.breakable " " 154 | pp.group(1) do 155 | pp.text attr 156 | pp.text ":" 157 | pp.breakable 158 | pp.text format_for_inspect(attr, attrs[attr]) 159 | end 160 | end 161 | else 162 | pp.breakable " " 163 | pp.text "not initialized" 164 | end 165 | end 166 | end 167 | 168 | private 169 | 170 | # @private 171 | def format_for_inspect(name, value) 172 | return value.inspect if value.nil? 173 | 174 | inspected_value = 175 | if value.is_a?(String) && value.length > 50 176 | "#{value[0, 50]}...".inspect 177 | elsif value.is_a?(Date) || value.is_a?(Time) 178 | %("#{value.to_fs(:inspect)}") 179 | else 180 | value.inspect 181 | end 182 | 183 | self.class.inspection_filter.filter_param(name, inspected_value) 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /test/use_cases/multiple_model_creation_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class MultipleModelCreationTest < ActiveSupport::TestCase 6 | # Here, we are testing the batch registration of multiple models as a single model. 7 | # 8 | # As a concrete example, 9 | # we are considering an operation such as user registration for a certain service. 10 | # 11 | # The model that constitutes the user is composed of `Account`, `Profile`, and `Credential`. 12 | # In other words, it is divided into multiple tables, 13 | # and it is required to register them all at once with a single “user registration” operation. 14 | # 15 | # We define this data operation event as a resource called Registration and create it as a normal Rails application operation. 16 | # 17 | # We are assuming cases where the following controllers are applied. 18 | # 19 | # # app/controllers/registrations_controller.rb 20 | # # 21 | # class RegistrationsController < ApplicationController 22 | # def new 23 | # @registration = Registration.new 24 | # end 25 | # 26 | # def create 27 | # @registration = Registration.new 28 | # if @registration.update(registration_params) 29 | # redirect_to root_path, notice: "registered." 30 | # else 31 | # render :new, status: :unprocessable_entity 32 | # end 33 | # end 34 | # 35 | # private 36 | # 37 | # def registration_params 38 | # params.expect(registration: %i[ 39 | # name email firstname lastname age 40 | # password password_confirmation terms_of_service 41 | # ]) 42 | # end 43 | # end 44 | # 45 | class Registration < ActiveRecordCompose::Model 46 | def initialize 47 | @account = Account.new 48 | @profile = account.build_profile 49 | @credential = account.build_credential 50 | 51 | super 52 | 53 | models << account << profile << credential 54 | end 55 | 56 | delegate_attribute :name, :email, to: :account 57 | delegate_attribute :firstname, :lastname, :age, to: :profile 58 | delegate_attribute :password, :password_confirmation, to: :credential 59 | attribute :terms_of_service, :boolean, default: false 60 | 61 | validates :password_confirmation, presence: true 62 | validates :terms_of_service, acceptance: true 63 | 64 | after_commit :send_registered_mail 65 | 66 | attr_accessor :send_registered_mail_called 67 | 68 | private 69 | 70 | attr_reader :account, :profile, :credential 71 | 72 | def send_registered_mail 73 | # It is similar to sending an email to notify users 74 | # that their registration has been completed after they register. 75 | # 76 | # ex. AccountMailer.with(account:).registered.deliver_later 77 | self.send_registered_mail_called = true 78 | end 79 | end 80 | 81 | setup do 82 | @registration = Registration.new 83 | end 84 | 85 | test "When invalid, no updates will be made to the data, and error information can be obtained." do 86 | registration_params = { 87 | name: "alice-in-wonderland", 88 | email: "alice@example.com", 89 | firstname: "Alice", 90 | lastname: "Smith", 91 | age: 18, 92 | password: nil, 93 | password_confirmation: nil, 94 | terms_of_service: false 95 | } 96 | 97 | assert_not @registration.update(registration_params) 98 | 99 | assert { @registration.errors.count == 3 } 100 | assert @registration.errors.of_kind?(:password, :blank) 101 | assert @registration.errors.of_kind?(:password_confirmation, :blank) 102 | assert @registration.errors.of_kind?(:terms_of_service, :accepted) 103 | assert @registration.errors.to_a.include?("Password can't be blank") 104 | assert @registration.errors.to_a.include?("Password confirmation can't be blank") 105 | assert @registration.errors.to_a.include?("Terms of service must be accepted") 106 | 107 | registration_params = { 108 | name: "alice-in-wonderland", 109 | email: "alice@example.com", 110 | firstname: "Alice", 111 | lastname: "Smith", 112 | age: 18, 113 | password: "P@ssW0rd", 114 | password_confirmation: nil, 115 | terms_of_service: true 116 | } 117 | 118 | assert_not @registration.update(registration_params) 119 | 120 | assert { @registration.errors.count == 1 } 121 | assert @registration.errors.of_kind?(:password_confirmation, :blank) 122 | assert @registration.errors.to_a.include?("Password confirmation can't be blank") 123 | 124 | registration_params = { 125 | name: "alice-in-wonderland", 126 | email: "alice@example.com", 127 | firstname: "Alice", 128 | lastname: "Smith", 129 | age: 18, 130 | password: "P@ssW0rd", 131 | password_confirmation: "P@ssW0rd!!!", 132 | terms_of_service: true 133 | } 134 | 135 | assert_not @registration.update(registration_params) 136 | 137 | assert { @registration.errors.count == 1 } 138 | assert @registration.errors.of_kind?(:password_confirmation, :confirmation) 139 | assert @registration.errors.to_a.include?("Password confirmation doesn't match Password") 140 | end 141 | 142 | test "When all the attributes required for registration are present, data operations required for resigning are completed." do 143 | registration_params = { 144 | name: "alice-in-wonderland", 145 | email: "alice@example.com", 146 | firstname: "Alice", 147 | lastname: "Smith", 148 | age: 18, 149 | password: "P@ssW0rd", 150 | password_confirmation: "P@ssW0rd", 151 | terms_of_service: true 152 | } 153 | 154 | assert_difference -> { Account.count } => 1, -> { Profile.count } => 1, -> { Credential.count } => 1 do 155 | assert_changes -> { @registration.send_registered_mail_called } do 156 | assert @registration.update(registration_params) 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/active_record_compose/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "attributes/attribute_predicate" 4 | require_relative "attributes/delegation" 5 | require_relative "attributes/querying" 6 | 7 | module ActiveRecordCompose 8 | # Provides attribute-related functionality for use within ActiveRecordCompose::Model. 9 | # 10 | # This module allows you to define attributes on your composed model, including support 11 | # for query methods (e.g., `#attribute?`) and delegation of attributes to underlying 12 | # ActiveRecord instances via macros. 13 | # 14 | # For example, `.delegate_attribute` defines attribute accessors that delegate to 15 | # a specific model, similar to: 16 | # 17 | # delegate :name, :name=, to: :account 18 | # 19 | # Additionally, delegated attributes are included in the composed model's `#attributes` 20 | # hash. 21 | # 22 | # @example 23 | # class AccountRegistration < ActiveRecordCompose::Model 24 | # def initialize(account, attributes = {}) 25 | # @account = account 26 | # super(attributes) 27 | # models.push(account) 28 | # end 29 | # 30 | # attribute :original_attribute, :string, default: "qux" 31 | # delegate_attribute :name, to: :account 32 | # 33 | # private 34 | # 35 | # attr_reader :account 36 | # end 37 | # 38 | # account = Account.new 39 | # account.name = "foo" 40 | # 41 | # registration = AccountRegistration.new(account) 42 | # registration.name # => "foo" (delegated) 43 | # registration.name? # => true (delegated attribute method + `?`) 44 | # 45 | # registration.name = "bar" # => updates account.name 46 | # account.name # => "bar" 47 | # account.name? # => true 48 | # 49 | # registration.attributes 50 | # # => { "original_attribute" => "qux", "name" => "bar" } 51 | # 52 | module Attributes 53 | extend ActiveSupport::Concern 54 | include ActiveModel::Attributes 55 | 56 | included do 57 | # @type self: Class 58 | 59 | include Querying 60 | 61 | class_attribute :delegated_attributes, instance_writer: false 62 | end 63 | 64 | # steep:ignore:start 65 | 66 | class_methods do 67 | # Provides a method of attribute access to the encapsulated model. 68 | # 69 | # It provides a way to access the attributes of the model it encompasses, 70 | # allowing transparent access as if it had those attributes itself. 71 | # 72 | # @param [Array] attributes 73 | # attributes A variable-length list of attribute names to delegate. 74 | # @param [Symbol, String] to 75 | # The target object to which attributes are delegated (keyword argument). 76 | # @param [Boolean] allow_nil 77 | # allow_nil Whether to allow nil values. Defaults to false. 78 | # @example Basic usage 79 | # delegate_attribute :name, :email, to: :profile 80 | # @example Allowing nil 81 | # delegate_attribute :bio, to: :profile, allow_nil: true 82 | # @see Module#delegate for similar behavior in ActiveSupport 83 | def delegate_attribute(*attributes, to:, allow_nil: false) 84 | if to.start_with?("@") 85 | raise ArgumentError, "Instance variables cannot be specified in delegate to. (#{to})" 86 | end 87 | 88 | delegations = attributes.map { Delegation.new(attribute: _1, to:, allow_nil:) } 89 | delegations.each { _1.define_delegated_attribute(self) } 90 | 91 | self.delegated_attributes = (delegated_attributes.to_a + delegations).reverse.uniq { _1.attribute }.reverse 92 | end 93 | 94 | # Returns a array of attribute name. 95 | # Attributes declared with {.delegate_attribute} are also merged. 96 | # 97 | # @see #attribute_names 98 | # @return [Array] array of attribute name. 99 | def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name } 100 | end 101 | 102 | # steep:ignore:end 103 | 104 | # Returns a array of attribute name. 105 | # Attributes declared with {.delegate_attribute} are also merged. 106 | # 107 | # class Foo < ActiveRecordCompose::Base 108 | # def initialize(attributes = {}) 109 | # @account = Account.new 110 | # super 111 | # end 112 | # 113 | # attribute :confirmation, :boolean, default: false # plain attribute 114 | # delegate_attribute :name, to: :account # delegated attribute 115 | # 116 | # private 117 | # 118 | # attr_reader :account 119 | # end 120 | # 121 | # Foo.attribute_names # Returns the merged state of plain and delegated attributes 122 | # # => ["confirmation" ,"name"] 123 | # 124 | # foo = Foo.new 125 | # foo.attribute_names # Similar behavior for instance method version 126 | # # => ["confirmation", "name"] 127 | # 128 | # @see #attributes 129 | # @return [Array] array of attribute name. 130 | def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name } 131 | 132 | # Returns a hash with the attribute name as key and the attribute value as value. 133 | # Attributes declared with {.delegate_attribute} are also merged. 134 | # 135 | # class Foo < ActiveRecordCompose::Base 136 | # def initialize(attributes = {}) 137 | # @account = Account.new 138 | # super 139 | # end 140 | # 141 | # attribute :confirmation, :boolean, default: false # plain attribute 142 | # delegate_attribute :name, to: :account # delegated attribute 143 | # 144 | # private 145 | # 146 | # attr_reader :account 147 | # end 148 | # 149 | # foo = Foo.new 150 | # foo.name = "Alice" 151 | # foo.confirmation = true 152 | # 153 | # foo.attributes # Returns the merged state of plain and delegated attributes 154 | # # => { "confirmation" => true, "name" => "Alice" } 155 | # 156 | # @return [Hash] hash with the attribute name as key and the attribute value as value. 157 | def attributes 158 | super.merge(*delegated_attributes.to_a.map { _1.attribute_hash(self) }) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /test/active_record_compose/model_inspect_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pp" 4 | require "stringio" 5 | require "test_helper" 6 | require "active_record_compose/model" 7 | require "active_support/core_ext/time" 8 | 9 | class ActiveRecordCompose::ModelInspectTest < ActiveSupport::TestCase 10 | test "returns an ActiveRecord model-like #inspect" do 11 | klass = 12 | Class.new(ActiveRecordCompose::Model) do 13 | def self.to_s = "Klass" 14 | 15 | def initialize(account = Account.new) 16 | @account = account 17 | super() 18 | models << account 19 | end 20 | 21 | delegate_attribute :name, :email, :created_at, to: :account 22 | 23 | private attr_reader :account 24 | end 25 | 26 | account = Account.create!(name: "alice", email: "alice@example.com") 27 | model = klass.new(account) 28 | 29 | model.name = "1234567890" * 10 30 | model.created_at = Time.new(2025, 1, 2, 3, 4, 5, in: "-0800") 31 | 32 | expected = 33 | '#' 34 | assert { model.inspect == expected } 35 | 36 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 37 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 38 | name: "12345678901234567890123456789012345678901234567890...", 39 | email: "alice@example.com", 40 | created_at: "2025-01-02 03:04:05.000000000 -0800"> 41 | PRETTY_INSPECT 42 | end 43 | 44 | test "The attributes specified in `.filter_attributes` are masked." do 45 | klass = 46 | Class.new(ActiveRecordCompose::Model) do 47 | def self.to_s = "Klass" 48 | 49 | attribute :email, :string 50 | attribute :password, :string 51 | 52 | self.filter_attributes += %i[password] 53 | end 54 | model = klass.new(email: "alice@example.com", password: "Secret") 55 | 56 | assert { model.inspect == '#' } 57 | 58 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 59 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 60 | email: "alice@example.com", 61 | password: [FILTERED]> 62 | PRETTY_INSPECT 63 | end 64 | 65 | test "The settings of `.filter_attributes` are valid for sublcass as well." do 66 | klass = 67 | Class.new(ActiveRecordCompose::Model) do 68 | def self.to_s = "Klass" 69 | 70 | attribute :email, :string 71 | attribute :password, :string 72 | 73 | self.filter_attributes += %i[password] 74 | end 75 | subclass = 76 | Class.new(klass) do 77 | def self.to_s = "Subclass" 78 | 79 | attribute :age, :integer 80 | end 81 | 82 | model = klass.new(email: "alice@example.com", password: "Secret") 83 | 84 | assert { model.inspect == '#' } 85 | 86 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 87 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 88 | email: "alice@example.com", 89 | password: [FILTERED]> 90 | PRETTY_INSPECT 91 | 92 | model = subclass.new(email: "alice@example.com", password: "Secret", age: 25) 93 | 94 | assert { model.inspect == '#' } 95 | 96 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 97 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 98 | email: "alice@example.com", 99 | password: [FILTERED], 100 | age: 25> 101 | PRETTY_INSPECT 102 | end 103 | 104 | test "The `.filter_attributes` settings can be overwritten by sublcass." do 105 | klass = 106 | Class.new(ActiveRecordCompose::Model) do 107 | def self.to_s = "Klass" 108 | 109 | attribute :email, :string 110 | attribute :password, :string 111 | 112 | self.filter_attributes += %i[password] 113 | end 114 | subclass = 115 | Class.new(klass) do 116 | def self.to_s = "Subclass" 117 | 118 | attribute :age, :integer 119 | 120 | self.filter_attributes += %i[email] 121 | end 122 | 123 | model = klass.new(email: "alice@example.com", password: "Secret") 124 | 125 | assert { model.inspect == '#' } 126 | 127 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 128 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 129 | email: "alice@example.com", 130 | password: [FILTERED]> 131 | PRETTY_INSPECT 132 | model = subclass.new(email: "alice@example.com", password: "Secret", age: 25) 133 | 134 | assert { model.inspect == "#" } 135 | 136 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 137 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 138 | email: [FILTERED], 139 | password: [FILTERED], 140 | age: 25> 141 | PRETTY_INSPECT 142 | end 143 | 144 | test "The `.filter_attributes` setting can be overwritten to blanks using sublcass." do 145 | klass = 146 | Class.new(ActiveRecordCompose::Model) do 147 | def self.to_s = "Klass" 148 | 149 | attribute :email, :string 150 | attribute :password, :string 151 | 152 | self.filter_attributes += %i[password] 153 | end 154 | subclass = 155 | Class.new(klass) do 156 | def self.to_s = "Subclass" 157 | 158 | attribute :age, :integer 159 | 160 | self.filter_attributes = [] 161 | end 162 | 163 | model = klass.new(email: "alice@example.com", password: "Secret") 164 | 165 | assert { model.inspect == '#' } 166 | 167 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 168 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 169 | email: "alice@example.com", 170 | password: [FILTERED]> 171 | PRETTY_INSPECT 172 | 173 | model = subclass.new(email: "alice@example.com", password: "Secret", age: 25) 174 | 175 | assert { model.inspect == '#' } 176 | 177 | assert_pretty_inspect(model, <<~PRETTY_INSPECT) 178 | #{Kernel.instance_method(:to_s).bind_call(model).chop} 179 | email: "alice@example.com", 180 | password: "Secret", 181 | age: 25> 182 | PRETTY_INSPECT 183 | 184 | subclass.filter_attributes = %i[age] 185 | 186 | assert { model.inspect == '#' } 187 | end 188 | 189 | private 190 | 191 | def assert_pretty_inspect(object, expected) 192 | out = StringIO.new 193 | PP.pp(object, out) 194 | actual = out.string 195 | 196 | assert_equal expected, actual 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /sig/_internal/package_private.rbs: -------------------------------------------------------------------------------- 1 | module ActiveRecordCompose 2 | module Attributes 3 | extend ActiveSupport::Concern 4 | include ActiveModel::Attributes 5 | include Querying 6 | 7 | def self.delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: bool) -> untyped 8 | def self.delegated_attributes: () -> Array[Delegation] 9 | def self.delegated_attributes=: (Array[Delegation]) -> untyped 10 | def delegated_attributes: () -> Array[Delegation] 11 | 12 | @attributes: untyped 13 | 14 | class AttributePredicate 15 | def initialize: (untyped value) -> void 16 | def call: -> bool 17 | 18 | @value: untyped 19 | 20 | private 21 | attr_reader value: untyped 22 | end 23 | 24 | class Delegation 25 | def initialize: (attribute: String, to: Symbol, ?allow_nil: bool) -> void 26 | def attribute: () -> Symbol 27 | def attribute_name: () -> String 28 | def attribute_hash: (Object model) -> Hash[String, untyped] 29 | def define_delegated_attribute: ((Module & ActiveModel::AttributeMethods::ClassMethods) klass) -> void 30 | 31 | @attribute: Symbol 32 | @to: Symbol 33 | @allow_nil: bool 34 | 35 | private 36 | def to: () -> Symbol 37 | def allow_nil: () -> bool 38 | def reader: () -> String 39 | def writer: () -> String 40 | end 41 | 42 | module Querying 43 | include ActiveModel::AttributeMethods 44 | extend ActiveSupport::Concern 45 | extend ActiveModel::AttributeMethods::ClassMethods 46 | 47 | private 48 | def attribute?: (attribute_name) -> untyped 49 | def query?: (untyped value) -> bool 50 | end 51 | end 52 | 53 | module Callbacks 54 | include ActiveModel::Model 55 | include ActiveModel::Validations::Callbacks 56 | extend ActiveSupport::Concern 57 | extend ActiveModel::Callbacks 58 | 59 | private 60 | def with_callbacks: { () -> bool } -> bool 61 | def callback_context: -> (:create | :update) 62 | end 63 | 64 | class ComposedCollection 65 | def initialize: (Model) -> void 66 | 67 | @symbol_proc_map: Hash[Symbol, (destroy_context_type | condition_type)] 68 | 69 | private 70 | attr_reader owner: Model 71 | attr_reader models: Set[WrappedModel] 72 | def wrap: (ar_like, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> WrappedModel 73 | def symbol_proc_map: () -> Hash[Symbol, (destroy_context_type | condition_type)] 74 | 75 | module PackagePrivate 76 | def __wrapped_models: () -> Enumerable[WrappedModel] 77 | 78 | private 79 | def models: () -> Set[WrappedModel] 80 | end 81 | 82 | include PackagePrivate 83 | end 84 | 85 | module Inspectable 86 | extend ActiveSupport::Concern 87 | include Attributes 88 | 89 | def self.inspection_filter: () -> ActiveSupport::ParameterFilter 90 | def self.filter_attributes: () -> Array[untyped] 91 | def self.filter_attributes=: (Array[untyped]) -> void 92 | def inspect: () -> String 93 | def pretty_print: (untyped q) -> void 94 | 95 | private 96 | def format_for_inspect: (String name, untyped value) -> String 97 | end 98 | 99 | class Model 100 | include Attributes 101 | include TransactionSupport 102 | include Callbacks 103 | 104 | @__models: ComposedCollection 105 | 106 | private 107 | def validate_models: -> void 108 | def override_validation_context: -> validation_context 109 | end 110 | 111 | module TransactionSupport 112 | extend ActiveSupport::Concern 113 | include ActiveRecord::Transactions 114 | include ActiveSupport::Callbacks 115 | extend ActiveSupport::Callbacks::ClassMethods 116 | 117 | def self.before_commit: (*untyped) -> untyped 118 | def self.after_commit: (*untyped) -> untyped 119 | def self.after_rollback: (*untyped) -> untyped 120 | 121 | def save: (**untyped options) -> bool 122 | def save!: (**untyped options) -> untyped 123 | def _run_before_commit_callbacks: () -> untyped 124 | def _run_commit_callbacks: () -> untyped 125 | def _run_rollback_callbacks: () -> untyped 126 | 127 | private 128 | def default_ar_class: -> singleton(ActiveRecord::Base) 129 | def connection_pool: (?ar_class: singleton(ActiveRecord::Base)) -> ActiveRecord::ConnectionAdapters::ConnectionPool 130 | def with_pool_transaction_isolation_level: [T] (ActiveRecord::ConnectionAdapters::AbstractAdapter) { () -> T } -> T 131 | end 132 | 133 | module Persistence 134 | include Callbacks 135 | include TransactionSupport 136 | 137 | def save: (**untyped options) -> bool 138 | def save!: (**untyped options) -> untyped 139 | def update: (Hash[attribute_name, untyped]) -> bool 140 | def update!: (Hash[attribute_name, untyped]) -> untyped 141 | 142 | private 143 | def models: -> ComposedCollection 144 | def save_models: (bang: bool, **untyped options) -> bool 145 | def raise_on_save_error: -> bot 146 | def raise_on_save_error_message: -> String 147 | end 148 | 149 | class Railtie < Rails::Railtie 150 | extend Rails::Initializable::ClassMethods 151 | end 152 | 153 | module Validations : Model 154 | extend ActiveSupport::Concern 155 | extend ActiveModel::Validations::ClassMethods 156 | 157 | def save: (**untyped options) -> bool 158 | def save!: (**untyped options) -> untyped 159 | def valid?: (?validation_context context) -> bool 160 | 161 | @context_for_override_validation: OverrideValidationContext 162 | 163 | private 164 | def perform_validations: (::Hash[untyped, untyped]) -> bool 165 | def raise_validation_error: -> bot 166 | def context_for_override_validation: -> OverrideValidationContext 167 | def override_validation_context: -> validation_context 168 | 169 | class OverrideValidationContext 170 | @context: validation_context 171 | 172 | attr_reader context: validation_context 173 | 174 | def with_override: [T] (validation_context) { () -> T } -> T 175 | end 176 | end 177 | 178 | class WrappedModel 179 | def initialize: (ar_like, ?destroy: (bool | destroy_context_type), ?if: (nil | condition_type)) -> void 180 | def destroy_context?: -> bool 181 | def ignore?: -> bool 182 | def save: (**untyped options) -> bool 183 | def save!: (**untyped options) -> untyped 184 | def invalid?: (?validation_context context) -> bool 185 | def valid?: (?validation_context context) -> bool 186 | def is_a?: (untyped) -> bool 187 | def ==: (untyped) -> bool 188 | 189 | private 190 | attr_reader model: ar_like 191 | attr_reader destroy_context_type: (bool | destroy_context_type) 192 | attr_reader if_option: (nil | condition_type) 193 | def equality_key: () -> [ar_like, (bool | destroy_context_type), (nil | condition_type)] 194 | 195 | module PackagePrivate 196 | def __raw_model: () -> ar_like 197 | 198 | private 199 | def model: () -> ar_like 200 | end 201 | 202 | include PackagePrivate 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [1.1.1] - 2025-12-04 4 | 5 | * fix: the save method would return nil instead of false. 6 | * doc: We've simplified the documentation comment yard. 7 | 8 | ## [1.1.0] - 2025-11-19 9 | 10 | * Implemented ActiveRecord-like #inspect 11 | In activerecord's `#inspect`, the string is a list of attributes, and we have reproduced a similar format. 12 | (https://github.com/hamajyotan/active_record_compose/pull/45) 13 | * `.with_connection` `.lease_connection` and `.connection` are deprecated. Use `ActiveRecord::Base.with_connection` etc. instead. 14 | (https://github.com/hamajyotan/active_record_compose/pull/46) 15 | * refactor: Remove `ActiveRecord::Transactions` module dependency 16 | (https://github.com/hamajyotan/active_record_compose/pull/44) 17 | 18 | ## [1.0.1] - 2025-10-17 19 | 20 | * Removed the private interface `composite_primary_key?` 21 | This was previously an internal ActiveRecord dependency, but was not exposed in the release version. 22 | (https://github.com/hamajyotan/active_record_compose/pull/39) 23 | * Relaxed ActiveRecord dependency upper bound to < 8.2 24 | (https://github.com/hamajyotan/active_record_compose/pull/42) 25 | 26 | ## [1.0.0] - 2025-09-23 27 | 28 | - drop support rails 7.0.x 29 | 30 | ## [0.12.0] - 2025-08-21 31 | 32 | - Omits default arguments for `#update` and `#update!`. It's to align I/F with ActiveRecord. 33 | (https://github.com/hamajyotan/active_record_compose/pull/25) 34 | - `#update(attributes = {})` to `#update(attributes)` 35 | - `#update!(attributes = {})` to `#update!(attributes)` 36 | - Omitted Specify instance variables in the `:to` option of `delegate_attribute`. 37 | (https://github.com/hamajyotan/active_record_compose/pull/29) 38 | - Omitted `#destroy` and `#touch` from `ActiveRecordCompose::Model`. 39 | These were unintentionally provided by the `ActiveRecord::Transactions` module. The but in fact did not work correctly. 40 | (https://github.com/hamajyotan/active_record_compose/pull/27) 41 | 42 | ## [0.11.3] - 2025-07-13 43 | 44 | - refactor: Aggregation attribute module. 45 | (https://github.com/hamajyotan/active_record_compose/pull/24) 46 | - Warn against specifying instance variables, etc. directly in the `:to` option of `delegate_attribute`. 47 | - Deprecated: 48 | ```ruby 49 | delegate_attribute :foo, to: :@model 50 | ``` 51 | - Recommended: 52 | ```ruby 53 | delegate_attribute :foo, to: :model 54 | private 55 | attr_reader :model 56 | ``` 57 | - doc: Expansion of yard documentation comments. 58 | 59 | ## [0.11.2] - 2025-06-29 60 | 61 | - `ActiveModel::Attributes.attribute_names` now takes into account attributes declared in `.delegate_attribute` 62 | - relax rubocop config. 63 | - refactor: extract persistence module 64 | - Add upper version. 65 | - refactor: extract Delegation class 66 | 67 | ## [0.11.1] - 2025-06-10 68 | 69 | - fix: Because `define_attribute_methods` was not executed, `#attributes` was evaluated each time `attribute?` was called. 70 | 71 | ## [0.11.0] - 2025-05-30 72 | 73 | - `#attribute_names` now takes into account attributes declared in `.delegate_attribute` 74 | - Implemented query methods with a `?` suffix for each attribute. 75 | Their evaluation behavior is consistent with ActiveRecord. 76 | For example: 77 | - Defining `attribute :foo` allows calling `model.foo?`. 78 | - Defining `delegate_attribute :bar, to: :other` allows calling `model.bar?`. 79 | 80 | ## [0.10.0] - 2025-04-07 81 | 82 | - avoid twice validation. As a side effect, save must accept argument `#save(**options)`. 83 | In line with this, the model to be put into models must be 84 | at least responsive to `model.valid? && model.save(validate: false)`, not `model.save()` (no arguments). 85 | - supports context as the first argument of `#valid?`, for example `model.valid(:custom_context)`. 86 | At the same time, it accepts `options[:context]` in `#save(**options)`, such as `model.save(context: :custom_context)`. 87 | However, this is a convenience support to unify the interface, not a positive one. 88 | 89 | ## [0.9.0] - 2025-03-16 90 | 91 | - removed `persisted_flag_callback_control` support. 92 | - Omitted `:private` option from `delegate_attribute` because, assuming the 93 | behavior and use cases of `ActiveModel::Attributes.attribute`, making it private is unnecessary. 94 | - Added the URL for the sample application to the README 95 | 96 | ## [0.8.0] - 2025-02-22 97 | 98 | - changed `persisted_flag_callback_control` default from `false` to `true`. 99 | - adjusted to save errors as soon as an invalid is found. 100 | - drop support rails 6.1.x. 101 | 102 | ## [0.7.0] - 2025-02-12 103 | 104 | - rename ActiveRecordCompose::InnerModel to ActiveRecordCompose::WrappedModel 105 | - rename ActiveRecordCompose::InnerModelCollection to ActiveRecordCompose::ComposedCollection 106 | - A new callback control flag, `persisted_flag_callback_control`, has been defined. 107 | Currently, the default value is false, which does not change the existing behavior, but it will be deprecated in the future. 108 | When the flag is set to true, the behavior will be almost the same as the callback sequence in ActiveRecord. 109 | (https://github.com/hamajyotan/active_record_compose/issues/11) 110 | 111 | ## [0.6.3] - 2025-01-31 112 | 113 | - fix: type error in `ActiveRecordCompose::Model` subclass definitions. 114 | - fixed type errors in subclass callback definitions, etc. 115 | - doc: more detailed gem desciption. 116 | - rewrite readme. 117 | 118 | ## [0.6.2] - 2025-01-04 119 | 120 | - fix: `delegate_attribute` defined in a subclass had an unintended side effect on the superclass. 121 | - support ruby 3.4.x 122 | - refactor: remove some `steep:ignore` by private rbs. 123 | - refactor: place definitions that you don't want to be used much in private rbs. 124 | - make `DelegateAttribute` dependent on `ActiveModel::Attributes` since it will not change in practice. 125 | 126 | ## [0.6.1] - 2024-12-23 127 | 128 | - refactor: reorganize the overall structure of the test. Change from rspec to minitest 129 | - ci: fix CI for rails new version. 130 | 131 | ## [0.6.0] - 2024-11-11 132 | 133 | - refactor: limit the scope of methods needed only for internal library purposes. 134 | - support rails 8.0.x 135 | - add optional value `if` to exclude from save (or destroy). 136 | 137 | ## [0.5.0] - 2024-10-09 138 | 139 | - remove `:context` option. use `:destroy` option instead. 140 | - remove `:destroy` option from `InnerModelCollection#destroy`. 141 | 142 | ## [0.4.1] - 2024-09-20 143 | 144 | - Omitted optional argument for `InnerModelCollection#destroy`. 145 | `InnerModel` equivalence is always performed based on the instance of the inner `model`. 146 | Since there are no use cases that depend on the original behavior. 147 | 148 | ## [0.4.0] - 2024-09-15 149 | 150 | - support `destrpy` option. and deprecated `context` option. 151 | `:context` will be removed in 0.5.0. Use `:destroy` option instead. 152 | for example, 153 | - `models.push(model, context: :destroy)` is replaced by `models.push(model, destroy: true)` 154 | - `models.push(model, context: -> { foo? ? :destroy : :save })` is replaced by `models.push(model, destroy: -> { foo? })` 155 | - `models.push(model, context: ->(m) { m.bar? ? :destroy : :save })` is replaced by `models.push(model, destroy: ->(m) { m.bar? })` 156 | - `destroy` option can now be specified with a `Symbol` representing the method name. 157 | 158 | ## [0.3.4] - 2024-09-01 159 | 160 | - ci: removed sqlite3 version specifing for new AR. 161 | - `delegate_attribute` options are now specific and do not accept `prefix` 162 | 163 | ## [0.3.3] - 2024-06-24 164 | 165 | - use steep:ignore 166 | 167 | ## [0.3.2] - 2024-04-10 168 | 169 | - support `ActiveRecord::Base#with_connection` 170 | - rbs maintained. 171 | - relax context proc arity. 172 | 173 | ## [0.3.1] - 2024-03-17 174 | 175 | - purge nodoc definitions from type signature 176 | - support `ActiveRecord::Base#lease_connection` 177 | 178 | ## [0.3.0] - 2024-02-24 179 | 180 | - strictify type checking 181 | - testing with CI even in the head version of rails 182 | - consolidate the main process of saving into the `#save` method 183 | - leave transaction control to ActiveRecord::Transactions 184 | - execution of `before_commit`, `after_commit` and `after_rollback` hook is delayed until after the database commit (or rollback). 185 | 186 | ## [0.2.1] - 2024-01-31 187 | 188 | - in `#save` (without bang), `ActiveRecord::RecordInvalid` error is not passed outward. 189 | 190 | ## [0.2.0] - 2024-01-21 191 | 192 | - add i18n doc. 193 | - add sig/ 194 | - add typecheck for ci. 195 | 196 | ## [0.1.8] - 2024-01-16 197 | 198 | - avoid executing `#save!` from `Model#save` 199 | - on save, ignore nil elements from models. 200 | 201 | ## [0.1.7] - 2024-01-15 202 | 203 | - remove `add_development_dependency` from gemspec 204 | - add spec for DelegateAttribute module 205 | - add and refactor doc. 206 | 207 | ## [0.1.6] - 2024-01-14 208 | 209 | - add doc for `#save` and `#save!`. 210 | - implement `#save` for symmetry with `#save!` 211 | - add `InnerModel#initialize` doc. 212 | 213 | ## [0.1.5] - 2024-01-11 214 | 215 | - when invalid, raises ActiveRecord::RecordInvalid on #save! 216 | 217 | ## [0.1.4] - 2024-01-10 218 | 219 | - remove uniquely defined exception class. 220 | 221 | ## [0.1.3] - 2024-01-09 222 | 223 | - fix documentation uri. 224 | 225 | ## [0.1.2] - 2024-01-09 226 | 227 | - fix and add doc. 228 | - add development dependency 229 | - avoid instance variable name conflict (`@models` to `@__models`) 230 | - add #empty?, #clear to InnerModelCollection 231 | - add #delete to InnerModelCollection 232 | 233 | ## [0.1.1] - 2024-01-08 234 | 235 | - fix 0.1.0 release date. 236 | - add doc. 237 | - Make it easier for application developers to work with `#models` 238 | 239 | ## [0.1.0] - 2024-01-06 240 | 241 | - Initial release 242 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveRecordCompose 2 | 3 | ActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface. 4 | More than just a simple form object, it is designed as a **business-oriented composed model** that encapsulates complex operations-such as user registration spanning multiple tables-making them easier to write, validate, and maintain. 5 | 6 | [![Gem Version](https://badge.fury.io/rb/active_record_compose.svg)](https://badge.fury.io/rb/active_record_compose) 7 | ![CI](https://github.com/hamajyotan/active_record_compose/workflows/CI/badge.svg) 8 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hamajyotan/active_record_compose) 9 | 10 | ## Table of Contents 11 | 12 | - [Motivation](#motivation) 13 | - [Installation](#installation) 14 | - [Quick Start](#quick-start) 15 | - [Basic Example](#basic-example) 16 | - [Attribute Delegation](#attribute-delegation) 17 | - [Unified Error Handling](#unified-error-handling) 18 | - [I18n Support](#i18n-support) 19 | - [Advanced Usage](#advanced-usage) 20 | - [Destroy Option](#destroy-option) 21 | - [Callback ordering with `#persisted?`](#callback-ordering-with-persisted) 22 | - [Notes on adding models dynamically](#notes-on-adding-models-dynamically) 23 | - [Sample Application](#sample-application) 24 | - [Links](#links) 25 | - [Development](#development) 26 | - [Contributing](#contributing) 27 | - [License](#license) 28 | - [Code of Conduct](#code-of-conduct) 29 | 30 | ## Motivation 31 | 32 | In Rails, `ActiveRecord::Base` is responsible for persisting data to the database. 33 | By defining validations and callbacks, you can model use cases effectively. 34 | 35 | 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)`. 36 | This mixes unrelated concerns into one model, leading to unnecessary complexity. 37 | 38 | `ActiveModel::Model` helps here — it provides the familiar API (`attribute`, `errors`, validations, callbacks) without persistence, so you can isolate logic per use case. 39 | 40 | **ActiveRecordCompose** builds on `ActiveModel::Model` and is a powerful **business object** that acts as a first-class model within Rails. 41 | - Transparently accesses attributes across multiple models 42 | - Saves all associated models atomically in a transaction 43 | - Collects and exposes error information consistently 44 | 45 | This leads to cleaner domain models, better separation of concerns, and fewer surprises in validations and callbacks. 46 | 47 | ## Installation 48 | 49 | To install `active_record_compose`, just put this line in your Gemfile: 50 | 51 | ```ruby 52 | gem 'active_record_compose' 53 | ``` 54 | 55 | Then bundle 56 | 57 | ```sh 58 | $ bundle 59 | ``` 60 | 61 | ## Quick Start 62 | 63 | ### Basic Example 64 | 65 | Suppose you have two models: 66 | 67 | ```ruby 68 | class Account < ApplicationRecord 69 | has_one :profile 70 | validates :name, :email, presence: true 71 | end 72 | 73 | class Profile < ApplicationRecord 74 | belongs_to :account 75 | validates :firstname, :lastname, :age, presence: true 76 | end 77 | ``` 78 | 79 | You can compose them into one form object: 80 | 81 | ```ruby 82 | class UserRegistration < ActiveRecordCompose::Model 83 | def initialize(attributes = {}) 84 | @account = Account.new 85 | @profile = @account.build_profile 86 | super(attributes) 87 | models << account << profile 88 | end 89 | 90 | attribute :terms_of_service, :boolean 91 | validates :terms_of_service, presence: true 92 | validates :email, confirmation: true 93 | 94 | after_commit :send_email_message 95 | 96 | delegate_attribute :name, :email, to: :account 97 | delegate_attribute :firstname, :lastname, :age, to: :profile 98 | 99 | private 100 | 101 | attr_reader :account, :profile 102 | 103 | def send_email_message 104 | SendEmailConfirmationJob.perform_later(account) 105 | end 106 | end 107 | ``` 108 | 109 | Usage: 110 | 111 | ```ruby 112 | # === Standalone script === 113 | registration = UserRegistration.new 114 | registration.update!( 115 | name: "foo", 116 | email: "bar@example.com", 117 | firstname: "taro", 118 | lastname: "yamada", 119 | age: 18, 120 | email_confirmation: "bar@example.com", 121 | terms_of_service: true, 122 | ) 123 | # `#update!` SQL log 124 | # BEGIN immediate TRANSACTION 125 | # INSERT INTO "accounts" ("created_at", "email", "name", "updated_at") VALUES (... 126 | # INSERT INTO "profiles" ("account_id", "age", "created_at", "firstname", "lastname", ... 127 | # COMMIT TRANSACTION 128 | 129 | 130 | # === Or, in a Rails controller with strong parameters === 131 | class UserRegistrationsController < ApplicationController 132 | def create 133 | @registration = UserRegistration.new(user_registration_params) 134 | if @registration.save 135 | redirect_to root_path, notice: "Registered!" 136 | else 137 | render :new 138 | end 139 | end 140 | 141 | private 142 | def user_registration_params 143 | params.require(:user_registration).permit( 144 | :name, :email, :firstname, :lastname, :age, :email_confirmation, :terms_of_service 145 | ) 146 | end 147 | end 148 | ``` 149 | 150 | Both `Account` and `Profile` will be updated **atomically in one transaction**. 151 | 152 | ### Attribute Delegation 153 | 154 | `delegate_attribute` allows transparent access to attributes of inner models: 155 | 156 | ```ruby 157 | delegate_attribute :name, :email, to: :account 158 | delegate_attribute :firstname, :lastname, :age, to: :profile 159 | ``` 160 | 161 | They are also included in `#attributes`: 162 | 163 | ```ruby 164 | registration.attributes 165 | # => { 166 | # "terms_of_service" => true, 167 | # "email" => nil, 168 | # "name" => "foo", 169 | # "age" => nil, 170 | # "firstname" => nil, 171 | # "lastname" => nil 172 | # } 173 | ``` 174 | 175 | ### Unified Error Handling 176 | 177 | Validation errors from inner models are collected into the composed model: 178 | 179 | ```ruby 180 | user_registration = UserRegistration.new( 181 | email: "foo@example.com", 182 | email_confirmation: "BAZ@example.com", 183 | age: 18, 184 | terms_of_service: true, 185 | ) 186 | 187 | user_registration.save # => false 188 | 189 | user_registration.errors.full_messages 190 | # => [ 191 | # "Name can't be blank", 192 | # "Firstname can't be blank", 193 | # "Lastname can't be blank", 194 | # "Email confirmation doesn't match Email" 195 | # ] 196 | ``` 197 | 198 | ### I18n Support 199 | 200 | When `#save!` raises `ActiveRecord::RecordInvalid`, 201 | make sure you have locale entries such as: 202 | 203 | ```yaml 204 | en: 205 | activemodel: 206 | errors: 207 | messages: 208 | record_invalid: 'Validation failed: %{errors}' 209 | ``` 210 | 211 | For more complete usage patterns, see the [Sample Application](#sample-application) below. 212 | 213 | ## Advanced Usage 214 | 215 | ### Destroy Option 216 | 217 | ```ruby 218 | models.push(profile, destroy: true) 219 | ``` 220 | 221 | This deletes the model on `#save` instead of persisting it. 222 | Conditional deletion is also supported: 223 | 224 | ```ruby 225 | models.push(profile, destroy: -> { profile_field_is_blank? }) 226 | ``` 227 | 228 | ### Callback ordering with `#persisted?` 229 | 230 | The result of `#persisted?` determines **which callbacks are fired**: 231 | 232 | - `persisted? == false` -> create callbacks (`before_create`, `after_create`, ...) 233 | - `persisted? == true` -> update callbacks (`before_update`, `after_update`, ...) 234 | 235 | This matches the behavior of normal ActiveRecord models. 236 | 237 | ```ruby 238 | class ComposedModel < ActiveRecordCompose::Model 239 | before_save { puts "before_save" } 240 | before_create { puts "before_create" } 241 | before_update { puts "before_update" } 242 | after_create { puts "after_create" } 243 | after_update { puts "after_update" } 244 | after_save { puts "after_save" } 245 | 246 | def persisted? 247 | account.persisted? 248 | end 249 | end 250 | ``` 251 | 252 | Example: 253 | 254 | ```ruby 255 | # When persisted? == false 256 | model = ComposedModel.new 257 | 258 | model.save 259 | # => before_save 260 | # => before_create 261 | # => after_create 262 | # => after_save 263 | 264 | # When persisted? == true 265 | model = ComposedModel.new 266 | def model.persisted?; true; end 267 | 268 | model.save 269 | # => before_save 270 | # => before_update 271 | # => after_update 272 | # => after_save 273 | ``` 274 | 275 | ### Notes on adding models dynamically 276 | 277 | Avoid adding `models` to the models array **after validation has already run** 278 | (for example, inside `after_validation` or `before_save` callbacks). 279 | 280 | ```ruby 281 | class Example < ActiveRecordCompose::Model 282 | before_save { models << AnotherModel.new } 283 | end 284 | ``` 285 | 286 | In this case, the newly added model will **not** run validations for the current save cycle. 287 | This may look like a bug, but it is the expected behavior: validations are only applied 288 | to models that were registered before validation started. 289 | 290 | We intentionally do not restrict this at the framework level, since there may be valid 291 | advanced use cases where models are manipulated dynamically. 292 | Instead, this behavior is documented here so that developers can make an informed decision. 293 | 294 | ## Sample Application 295 | 296 | The sample app demonstrates a more complete usage of ActiveRecordCompose 297 | (e.g., user registration flows involving multiple models). 298 | It is not meant to cover every possible pattern, but can serve as a reference 299 | for putting the library into practice. 300 | 301 | Try it out in your browser with GitHub Codespaces (or locally): 302 | 303 | - https://github.com/hamajyotan/active_record_compose-example 304 | 305 | ## Links 306 | 307 | - [API Documentation (YARD)](https://hamajyotan.github.io/active_record_compose/) 308 | - [Blog article introducing the concept](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6) 309 | 310 | ## Development 311 | 312 | 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. 313 | 314 | 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). 315 | 316 | ## Contributing 317 | 318 | 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). 319 | 320 | ## License 321 | 322 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 323 | 324 | ## Code of Conduct 325 | 326 | 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). 327 | 328 | --------------------------------------------------------------------------------