├── .tool-versions ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── main.yml ├── bin ├── test ├── setup └── console ├── Rakefile ├── .gitignore ├── lib ├── active_record │ ├── associated_object │ │ ├── version.rb │ │ ├── railtie.rb │ │ └── object_association.rb │ └── associated_object.rb └── generators │ └── associated │ ├── templates │ ├── associated.rb.tt │ └── associated_test.rb.tt │ ├── USAGE │ └── associated_generator.rb ├── Gemfile ├── test ├── active_record │ ├── associated_object │ │ ├── object_association_test.rb │ │ └── integration_test.rb │ └── associated_object_test.rb ├── test_helper.rb ├── boot │ ├── active_record.rb │ └── associated_object.rb └── lib │ └── generators │ └── associated_generator_test.rb ├── LICENSE.txt ├── active_record-associated_object.gemspec ├── CHANGELOG.md ├── Gemfile.lock └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [kaspth] 2 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | bundle exec minitest "$@" 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 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 | -------------------------------------------------------------------------------- /lib/active_record/associated_object/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | class AssociatedObject 5 | VERSION = "0.9.3" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/associated/templates/associated.rb.tt: -------------------------------------------------------------------------------- 1 | class <%= name %> < ActiveRecord::AssociatedObject 2 | extension do 3 | # Extend <%= record_klass %> here 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/associated/templates/associated_test.rb.tt: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class <%= name %>Test < ActiveSupport::TestCase 4 | setup do 5 | # @<%= record_path %> = <%= record_path.pluralize %>(:TODO_fixture_name) 6 | # @<%= associated_object_path %> = @<%= record_path %>.<%= associated_object_path %> 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rake" 8 | gem "minitest" 9 | gem "debug" 10 | 11 | gem "sqlite3" 12 | 13 | # Integrations to setup and test with. 14 | gem "kredis" 15 | gem "activejob" 16 | gem "active_job-performs" 17 | gem "railties", "~> 7.1.0" # TODO: Remove lock after dropping 3.1 support. 18 | 19 | gem "minitest-sprint" 20 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "active_record/associated_object" 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 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/generators/associated/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Create a PORO collaborator associated object inheriting from `ActiveRecord::AssociatedObject` that's associated with an Active Record record class. 3 | 4 | It'll be associated on the record with `has_object`. 5 | 6 | Note: associated object names support pluralized class names. So "Seats" remain "seats" in all cases, and "Seat" remains "seat" in all cases. 7 | Example: 8 | bin/rails generate associated Organization::Seats 9 | 10 | This will create: 11 | app/models/organization/seats.rb 12 | test/models/organization/seats_test.rb 13 | 14 | And in Organization, this will insert: 15 | has_object :seats 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | continue-on-error: true 15 | strategy: 16 | matrix: 17 | ruby: 18 | - "3.1" 19 | - "3.4" 20 | 21 | steps: 22 | - uses: actions/checkout@v6 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Set up Redis 30 | uses: supercharge/redis-github-action@v2 31 | with: 32 | redis-version: 4 33 | 34 | - name: Tests 35 | run: bin/test 36 | -------------------------------------------------------------------------------- /lib/active_record/associated_object/railtie.rb: -------------------------------------------------------------------------------- 1 | class ActiveRecord::AssociatedObject::Railtie < Rails::Railtie 2 | initializer "integrations.include" do 3 | config.after_initialize do 4 | ActiveRecord::AssociatedObject.include Kredis::Attributes if defined?(Kredis) 5 | ActiveRecord::AssociatedObject.include GlobalID::Identification if defined?(GlobalID) 6 | end 7 | end 8 | 9 | initializer "active_job.performs" do 10 | require "active_job/performs" 11 | ActiveRecord::AssociatedObject.extend ActiveJob::Performs if defined?(ActiveJob::Performs) 12 | rescue LoadError 13 | # We haven't bundled active_job-performs, so we're continuing without it. 14 | end 15 | 16 | initializer "object_association.setup" do 17 | ActiveSupport.on_load :active_record do 18 | require "active_record/associated_object/object_association" 19 | include ActiveRecord::AssociatedObject::ObjectAssociation 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/active_record/associated_object/object_association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveRecord::AssociatedObject::ObjectAssociationTest < ActiveSupport::TestCase 6 | test "standard PORO can be accessed" do 7 | assert_kind_of Post::Mailroom, Post.first.mailroom 8 | 9 | author = Author.first 10 | assert_kind_of Author::Archiver, author.archiver 11 | assert_kind_of Author::Classified, author.classified 12 | assert_kind_of Author::Fortifications, author.fortifications 13 | end 14 | 15 | test "callback passing for standard PORO" do 16 | Post::Mailroom.touched = false 17 | 18 | Post.first.touch 19 | assert Post.first.mailroom.touched 20 | end 21 | 22 | test "descriptive error when encountering unknown associated object" do 23 | assert_raise { Post.has_object :unknown } => error 24 | assert_equal "The Post::Unknown associated object referenced from Post doesn't exist", error.message 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Kasper Timm Hansen 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 | -------------------------------------------------------------------------------- /lib/generators/associated/associated_generator.rb: -------------------------------------------------------------------------------- 1 | class AssociatedGenerator < Rails::Generators::NamedBase 2 | source_root File.expand_path("templates", __dir__) 3 | 4 | def generate_associated_object_files 5 | template "associated.rb", "app/models/#{name.underscore}.rb" 6 | template "associated_test.rb", "test/models/#{name.underscore}_test.rb" 7 | end 8 | 9 | def connect_associated_object 10 | record_file = "#{destination_root}/app/models/#{record_path}.rb" 11 | 12 | raise "Record class '#{record_klass}' does not exist" unless File.exist?(record_file) 13 | 14 | inject_into_class record_file, record_klass do 15 | optimize_indentation "has_object :#{associated_object_path}", 2 16 | end 17 | end 18 | 19 | private 20 | 21 | # The `:name` argument can handle model names, but associated object class names aren't singularized. 22 | # So these record and associated_object methods prevent that. 23 | def record_path = record_klass.downcase.underscore 24 | def record_klass = name.camelize.deconstantize 25 | 26 | def associated_object_path = associated_object_class.underscore 27 | def associated_object_class = name.camelize.demodulize 28 | end 29 | -------------------------------------------------------------------------------- /lib/active_record/associated_object/object_association.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord::AssociatedObject::ObjectAssociation 2 | def self.included(klass) = klass.extend(ClassMethods) 3 | 4 | using Module.new { 5 | refine Module do 6 | def extend_source_from(chunks, &block) 7 | location = caller_locations(1, 1).first 8 | source_chunks = Array(chunks).flat_map(&block) 9 | class_eval source_chunks.join("\n\n"), location.path, location.lineno 10 | end 11 | end 12 | } 13 | 14 | module ClassMethods 15 | def has_object(*names, **callbacks) 16 | extend_source_from(names) do |name| 17 | const_get object_name = name.to_s.camelize 18 | "def #{name}; (@associated_objects ||= {})[:#{name}] ||= #{object_name}.new(self); end" 19 | rescue NameError 20 | raise "The #{self}::#{object_name} associated object referenced from #{self} doesn't exist" 21 | end 22 | 23 | extend_source_from(names) do |name| 24 | callbacks.map do |callback, method| 25 | "#{callback} { #{name}.#{method == true ? callback : method} }" 26 | end 27 | end 28 | end 29 | end 30 | 31 | def init_internals 32 | @associated_objects = nil 33 | super 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | 5 | require "rails" 6 | require "kredis" 7 | require "debug" 8 | require "logger" 9 | 10 | require "active_record" 11 | require "active_record/associated_object" 12 | 13 | require "global_id" 14 | require "active_job" 15 | require "active_job/performs" 16 | 17 | require "minitest/autorun" 18 | 19 | # Simulate Rails app boot and run the railtie initializers manually. 20 | class ActiveRecord::AssociatedObject::Application < Rails::Application 21 | end 22 | 23 | ActiveRecord::AssociatedObject::Railtie.run_initializers 24 | ActiveSupport.run_load_hooks :after_initialize, Rails.application 25 | 26 | Kredis.configurator = Class.new do 27 | def config_for(name) = { db: "1" } 28 | def root = Pathname.new(".") 29 | end.new 30 | 31 | GlobalID.app = "test" 32 | 33 | class ApplicationJob < ActiveJob::Base 34 | end 35 | 36 | require_relative "boot/active_record" 37 | require_relative "boot/associated_object" 38 | 39 | author = Author.create! 40 | post = author.posts.create! id: 1, title: "First post" 41 | author.comments.create! post: post, body: "First!!!!" 42 | 43 | class ActiveSupport::TestCase 44 | teardown { Kredis.clear_all } 45 | end 46 | -------------------------------------------------------------------------------- /active_record-associated_object.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/active_record/associated_object/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "active_record-associated_object" 7 | spec.version = ActiveRecord::AssociatedObject::VERSION 8 | spec.authors = ["Kasper Timm Hansen"] 9 | spec.email = ["hey@kaspth.com"] 10 | 11 | spec.summary = "Associate a Ruby PORO with an Active Record class and have it quack like one." 12 | spec.homepage = "https://github.com/kaspth/active_record-associated_object" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 3.0.0" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = spec.homepage 18 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(__dir__) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 25 | end 26 | end 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_dependency "activerecord", ">= 6.1" 30 | end 31 | -------------------------------------------------------------------------------- /test/boot/active_record.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 2 | ActiveRecord::Base.logger = Logger.new(STDOUT) if ENV["VERBOSE"] || ENV["CI"] 3 | 4 | ActiveRecord::Schema.define do 5 | create_table :authors, force: true do |t| 6 | t.timestamps 7 | end 8 | 9 | create_table :posts, force: true do |t| 10 | t.string :title 11 | t.integer :author_id 12 | t.timestamps 13 | end 14 | 15 | create_table :post_comments, primary_key: [:post_id, :author_id] do |t| 16 | t.integer :post_id, null: false 17 | t.integer :author_id, null: false 18 | t.string :body 19 | t.timestamps 20 | end 21 | end 22 | 23 | # Shim what an app integration would look like. 24 | class ApplicationRecord < ActiveRecord::Base 25 | self.abstract_class = true 26 | self.cache_versioning = true # Rails sets this during application booting, so we need to do it manually here. 27 | end 28 | 29 | class Author < ApplicationRecord 30 | has_many :posts, dependent: :destroy 31 | has_many :comments, dependent: :destroy, class_name: "Post::Comment" 32 | end 33 | 34 | class Post < ApplicationRecord 35 | belongs_to :author 36 | has_many :comments, dependent: :destroy 37 | end 38 | 39 | class Post::Comment < ApplicationRecord 40 | belongs_to :post 41 | belongs_to :author 42 | end 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.4.0] - 2022-09-25 2 | 3 | - Extract `performs` into the `active_job-performs` gem with some fixes and extra features, but include it as a dependency. 4 | 5 | ## [0.3.0] - 2022-09-25 6 | 7 | - Add `performs` to help cut down Active Job boilerplate. 8 | 9 | ```ruby 10 | class Post::Publisher < ActiveRecord::AssociatedObject 11 | performs :publish, queue_as: :important 12 | 13 | def publish 14 | … 15 | end 16 | end 17 | ``` 18 | 19 | The above is the same as writing: 20 | 21 | ```ruby 22 | class Post::Publisher < ActiveRecord::AssociatedObject 23 | class Job < ApplicationJob; end 24 | class PublishJob < Job 25 | queue_as :important 26 | 27 | def perform(publisher, *arguments, **options) 28 | publisher.publish(*arguments, **options) 29 | end 30 | end 31 | 32 | def publish_later(*arguments, **options) 33 | PublishJob.perform_later(self, *arguments, **options) 34 | end 35 | 36 | def publish 37 | … 38 | end 39 | end 40 | ``` 41 | 42 | See the README for more details. 43 | 44 | ## [0.2.0] - 2022-04-21 45 | 46 | - Require a `has_object` call on the record side to associate an object. 47 | 48 | ```ruby 49 | class Post < ActiveRecord::Base 50 | has_object :publisher 51 | end 52 | ``` 53 | 54 | - Allow `has_object` to pass callbacks onto the associated object. 55 | 56 | ```ruby 57 | class Post < ActiveRecord::Base 58 | has_object :publisher, after_touch: true, before_destroy: :prevent_errant_post_destroy 59 | end 60 | ``` 61 | 62 | ## [0.1.0] - 2022-04-19 63 | 64 | - Initial release 65 | -------------------------------------------------------------------------------- /test/boot/associated_object.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord::AssociatedObject < ActiveRecord::AssociatedObject; end 2 | 3 | class Author::Archiver < ApplicationRecord::AssociatedObject; end 4 | # TODO: Replace with Data.define once on Ruby 3.2. 5 | Author::Classified = Struct.new(:author) 6 | Author::Fortifications = Struct.new(:author) 7 | 8 | Author.has_object :archiver, :classified, :fortifications 9 | 10 | class Post::Mailroom < Struct.new(:record) 11 | mattr_accessor :touched, default: false 12 | 13 | def after_touch 14 | self.touched = true 15 | end 16 | end 17 | 18 | class Post::Publisher < ApplicationRecord::AssociatedObject 19 | mattr_accessor :performed, default: false 20 | mattr_accessor :captured_title, default: nil 21 | 22 | kredis_datetime :publish_at 23 | 24 | performs queue_as: :not_really_important 25 | performs :publish, queue_as: :important, discard_on: ActiveJob::DeserializationError 26 | 27 | def after_update_commit 28 | self.captured_title = post.title 29 | end 30 | 31 | def prevent_errant_post_destroy 32 | throw :abort 33 | end 34 | 35 | def publish 36 | self.performed = true 37 | end 38 | end 39 | 40 | Post.has_object :mailroom, after_touch: true 41 | Post.has_object :publisher, after_update_commit: true, before_destroy: :prevent_errant_post_destroy 42 | 43 | class Post::Comment::Rating < ActiveRecord::AssociatedObject 44 | record.scope :great, -> { where(body: "First!!!!") } 45 | extension do 46 | def rated_great? = rating.great? 47 | end 48 | 49 | kredis_flag :moderated 50 | 51 | def great? 52 | comment.body == "First!!!!" 53 | end 54 | end 55 | 56 | Post::Comment.has_object :rating 57 | -------------------------------------------------------------------------------- /test/active_record/associated_object/integration_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "action_controller" 3 | require "action_view" 4 | 5 | class Post::PublishersController < ActionController::Base 6 | def show 7 | fresh_when Post::Publisher.find(params[:id]) 8 | head :no_content unless performed? 9 | end 10 | end 11 | 12 | Rails.logger = Logger.new "/dev/null" 13 | Rails.application.middleware.delete ActionDispatch::HostAuthorization 14 | 15 | Rails.application.routes.draw do 16 | namespace(:post) { resources :publishers } 17 | end 18 | 19 | class ActiveRecord::AssociatedObject::IntegrationTest < ActionDispatch::IntegrationTest 20 | self.app = Rails.application 21 | setup { @post, @publisher = Post.first.then { [_1, _1.publisher] } } 22 | 23 | test "url helper" do 24 | assert_equal "/post/publishers/#{@post.id}", post_publisher_path(@publisher) 25 | end 26 | 27 | test "fresh_when" do 28 | get "/post/publishers/#{@post.id}" 29 | assert_response :no_content 30 | 31 | get "/post/publishers/#{@post.id}", headers: { HTTP_IF_NONE_MATCH: headers["etag"] } 32 | assert_response :not_modified 33 | end 34 | end 35 | 36 | class ActiveRecord::AssociatedObject::ViewTest < ActionView::TestCase 37 | ActionController::Base.cache_store = :memory_store 38 | include Rails.application.routes.url_helpers 39 | 40 | setup { @post, @publisher = Post.first.then { [_1, _1.publisher] } } 41 | 42 | test "form_with" do 43 | concat form_with(model: @publisher) 44 | assert_select "form[action='/post/publishers/#{@post.id}']" 45 | end 46 | 47 | test "cache" do 48 | cache(@publisher) { concat "initial" } 49 | assert_equal "initial", fragment_for(@publisher, {}) { "second" } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/active_record/associated_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveRecord::AssociatedObject 4 | extend ActiveModel::Naming 5 | include ActiveModel::Conversion 6 | 7 | class << self 8 | def inherited(new_object) 9 | new_object.associated_via(new_object.module_parent) 10 | end 11 | 12 | def associated_via(record) 13 | unless record.respond_to?(:descends_from_active_record?) && record.descends_from_active_record? 14 | raise ArgumentError, "#{record} isn't a valid namespace; can only associate with ActiveRecord::Base subclasses" 15 | end 16 | 17 | @record, @attribute_name = record, model_name.element.to_sym 18 | alias_method record.model_name.element, :record 19 | end 20 | 21 | attr_reader :record, :attribute_name 22 | delegate :primary_key, :unscoped, :transaction, to: :record 23 | 24 | def extension(&block) 25 | record.class_eval(&block) 26 | end 27 | 28 | def method_missing(meth, ...) 29 | if !record.respond_to?(meth) || meth.end_with?("?", "=") then super else 30 | record.public_send(meth, ...).then do |value| 31 | value.respond_to?(:each) ? value.map(&attribute_name) : value&.public_send(attribute_name) 32 | end 33 | end 34 | end 35 | 36 | def respond_to_missing?(meth, ...) 37 | (record.respond_to?(meth, ...) && !meth.end_with?("?", "=")) || super 38 | end 39 | end 40 | 41 | module Caching 42 | def cache_key_with_version 43 | "#{cache_key}-#{cache_version}".tap { _1.delete_suffix!("-") } 44 | end 45 | delegate :cache_version, to: :record 46 | 47 | def cache_key = case 48 | when !record.cache_versioning? 49 | raise "ActiveRecord::AssociatedObject#cache_key only supports #{record.class}.cache_versioning = true" 50 | when new_record? 51 | "#{model_name.cache_key}/new" 52 | else 53 | "#{model_name.cache_key}/#{id}" 54 | end 55 | end 56 | include Caching 57 | 58 | attr_reader :record 59 | delegate :id, :new_record?, :persisted?, to: :record 60 | delegate :updated_at, :updated_on, to: :record # Helpful when passing to `fresh_when`/`stale?` 61 | delegate :transaction, to: :record 62 | 63 | def initialize(record) 64 | @record = record 65 | end 66 | 67 | def ==(other) 68 | other.is_a?(self.class) && id == other.id 69 | end 70 | end 71 | 72 | require_relative "associated_object/version" 73 | require_relative "associated_object/railtie" if defined?(Rails::Railtie) 74 | -------------------------------------------------------------------------------- /test/lib/generators/associated_generator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "pathname" 3 | require "rails/generators" 4 | require "generators/associated/associated_generator" 5 | 6 | class AssociatedGeneratorTest < Rails::Generators::TestCase 7 | tests AssociatedGenerator 8 | destination Pathname(__dir__).join("../../../tmp/generators") 9 | 10 | setup :prepare_destination, :create_record_file, :create_record_test_file 11 | arguments %w[Organization::Seats] 12 | 13 | test "generator runs without errors" do 14 | assert_nothing_raised { run_generator } 15 | end 16 | 17 | test "generates an object.rb file" do 18 | run_generator 19 | 20 | assert_file "app/models/organization/seats.rb", <<~RUBY 21 | class Organization::Seats < ActiveRecord::AssociatedObject 22 | extension do 23 | # Extend Organization here 24 | end 25 | end 26 | RUBY 27 | end 28 | 29 | test "generates an object_test.rb file" do 30 | run_generator 31 | 32 | assert_file "test/models/organization/seats_test.rb", /Organization::SeatsTest/ 33 | end 34 | 35 | test "connects record" do 36 | run_generator 37 | 38 | assert_file "app/models/organization.rb", <<~RUBY 39 | class Organization 40 | has_object :seats 41 | end 42 | RUBY 43 | end 44 | 45 | test "connects record: Camelized name" do 46 | run_generator ["Organization::SeatsManager"] 47 | 48 | assert_file "app/models/organization.rb", <<~RUBY 49 | class Organization 50 | has_object :seats_manager 51 | end 52 | RUBY 53 | end 54 | 55 | test "connects record: lower_snake_case name" do 56 | run_generator ["organization/seats_manager"] 57 | 58 | assert_file "app/models/organization.rb", <<~RUBY 59 | class Organization 60 | has_object :seats_manager 61 | end 62 | RUBY 63 | end 64 | 65 | 66 | 67 | test "raises error if associated record doesn't exist" do 68 | assert_raise RuntimeError do 69 | run_generator ["Business::Monkey"] 70 | end 71 | end 72 | 73 | private 74 | 75 | def create_record_file 76 | create_file "app/models/organization.rb", <<~RUBY 77 | class Organization 78 | end 79 | RUBY 80 | end 81 | 82 | def create_record_test_file 83 | create_file "test/models/organization_test.rb", <<~RUBY 84 | require "test_helper" 85 | 86 | class OrganizationTest < ActiveSupport::TestCase 87 | end 88 | RUBY 89 | end 90 | 91 | def create_file(path, content) 92 | destination_root.join(path).tap { _1.dirname.mkpath }.write content 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | active_record-associated_object (0.9.3) 5 | activerecord (>= 6.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actionpack (7.1.5.1) 11 | actionview (= 7.1.5.1) 12 | activesupport (= 7.1.5.1) 13 | nokogiri (>= 1.8.5) 14 | racc 15 | rack (>= 2.2.4) 16 | rack-session (>= 1.0.1) 17 | rack-test (>= 0.6.3) 18 | rails-dom-testing (~> 2.2) 19 | rails-html-sanitizer (~> 1.6) 20 | actionview (7.1.5.1) 21 | activesupport (= 7.1.5.1) 22 | builder (~> 3.1) 23 | erubi (~> 1.11) 24 | rails-dom-testing (~> 2.2) 25 | rails-html-sanitizer (~> 1.6) 26 | active_job-performs (0.3.2) 27 | activejob (>= 6.1) 28 | activejob (7.1.5.1) 29 | activesupport (= 7.1.5.1) 30 | globalid (>= 0.3.6) 31 | activemodel (7.1.5.1) 32 | activesupport (= 7.1.5.1) 33 | activerecord (7.1.5.1) 34 | activemodel (= 7.1.5.1) 35 | activesupport (= 7.1.5.1) 36 | timeout (>= 0.4.0) 37 | activesupport (7.1.5.1) 38 | base64 39 | benchmark (>= 0.3) 40 | bigdecimal 41 | concurrent-ruby (~> 1.0, >= 1.0.2) 42 | connection_pool (>= 2.2.5) 43 | drb 44 | i18n (>= 1.6, < 2) 45 | logger (>= 1.4.2) 46 | minitest (>= 5.1) 47 | mutex_m 48 | securerandom (>= 0.3) 49 | tzinfo (~> 2.0) 50 | base64 (0.2.0) 51 | benchmark (0.4.0) 52 | bigdecimal (3.1.9) 53 | builder (3.3.0) 54 | concurrent-ruby (1.3.5) 55 | connection_pool (2.5.0) 56 | crass (1.0.6) 57 | date (3.4.1) 58 | debug (1.10.0) 59 | irb (~> 1.10) 60 | reline (>= 0.3.8) 61 | drb (2.2.1) 62 | erubi (1.13.1) 63 | globalid (1.2.1) 64 | activesupport (>= 6.1) 65 | i18n (1.14.7) 66 | concurrent-ruby (~> 1.0) 67 | io-console (0.8.0) 68 | irb (1.15.2) 69 | pp (>= 0.6.0) 70 | rdoc (>= 4.0.0) 71 | reline (>= 0.4.2) 72 | kredis (1.7.0) 73 | activemodel (>= 6.0.0) 74 | activesupport (>= 6.0.0) 75 | redis (>= 4.2, < 6) 76 | logger (1.7.0) 77 | loofah (2.24.0) 78 | crass (~> 1.0.2) 79 | nokogiri (>= 1.12.0) 80 | minitest (5.25.5) 81 | minitest-sprint (1.3.0) 82 | path_expander (~> 1.1) 83 | mutex_m (0.3.0) 84 | nokogiri (1.18.7-arm64-darwin) 85 | racc (~> 1.4) 86 | nokogiri (1.18.7-x86_64-linux-gnu) 87 | racc (~> 1.4) 88 | path_expander (1.1.3) 89 | pp (0.6.2) 90 | prettyprint 91 | prettyprint (0.2.0) 92 | psych (5.2.3) 93 | date 94 | stringio 95 | racc (1.8.1) 96 | rack (3.1.12) 97 | rack-session (2.1.0) 98 | base64 (>= 0.1.0) 99 | rack (>= 3.0.0) 100 | rack-test (2.2.0) 101 | rack (>= 1.3) 102 | rackup (2.2.1) 103 | rack (>= 3) 104 | rails-dom-testing (2.2.0) 105 | activesupport (>= 5.0.0) 106 | minitest 107 | nokogiri (>= 1.6) 108 | rails-html-sanitizer (1.6.2) 109 | loofah (~> 2.21) 110 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 111 | railties (7.1.5.1) 112 | actionpack (= 7.1.5.1) 113 | activesupport (= 7.1.5.1) 114 | irb 115 | rackup (>= 1.0.0) 116 | rake (>= 12.2) 117 | thor (~> 1.0, >= 1.2.2) 118 | zeitwerk (~> 2.6) 119 | rake (13.2.1) 120 | rdoc (6.13.1) 121 | psych (>= 4.0.0) 122 | redis (5.4.0) 123 | redis-client (>= 0.22.0) 124 | redis-client (0.24.0) 125 | connection_pool 126 | reline (0.6.1) 127 | io-console (~> 0.5) 128 | securerandom (0.4.1) 129 | sqlite3 (2.6.0-arm64-darwin) 130 | sqlite3 (2.6.0-x86_64-linux-gnu) 131 | stringio (3.1.6) 132 | thor (1.3.2) 133 | timeout (0.4.3) 134 | tzinfo (2.0.6) 135 | concurrent-ruby (~> 1.0) 136 | zeitwerk (2.6.18) 137 | 138 | PLATFORMS 139 | arm64-darwin 140 | x86_64-linux 141 | 142 | DEPENDENCIES 143 | active_job-performs 144 | active_record-associated_object! 145 | activejob 146 | debug 147 | kredis 148 | minitest 149 | minitest-sprint 150 | railties (~> 7.1.0) 151 | rake 152 | sqlite3 153 | 154 | BUNDLED WITH 155 | 2.6.7 156 | -------------------------------------------------------------------------------- /test/active_record/associated_object_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveRecord::AssociatedObjectTest < ActiveSupport::TestCase 6 | include ActiveJob::TestHelper 7 | 8 | setup do 9 | @post = Post.first 10 | @publisher = @post.publisher 11 | 12 | @author = Author.first 13 | @archiver = @author.archiver 14 | 15 | @comment = @author.comments.first 16 | @rating = @comment.rating 17 | end 18 | 19 | test "associated object alias" do 20 | assert_equal @post, @publisher.post 21 | assert_equal @publisher.post, @publisher.record 22 | 23 | assert_equal @comment, @rating.comment 24 | assert_equal @rating.comment, @rating.record 25 | end 26 | 27 | test "associated object method missing extraction" do 28 | [1, @post.title, @author].tap do |id, title, author| 29 | assert_equal @publisher, Post::Publisher.first 30 | assert_equal @publisher, Post::Publisher.last 31 | assert_equal @publisher, Post::Publisher.find(id) 32 | assert_equal @publisher, Post::Publisher.find_by(id:) 33 | assert_equal @publisher, Post::Publisher.find_by(title:) 34 | assert_equal @publisher, Post::Publisher.find_by(author:) 35 | assert_equal @publisher, Post::Publisher.find_by!(id:) 36 | assert_equal @publisher, Post::Publisher.find_by!(title:) 37 | assert_equal @publisher, Post::Publisher.find_by!(author:) 38 | assert_equal [@publisher], Post::Publisher.where(id:) 39 | end 40 | 41 | [[@post, @author], @comment.body, @author].tap do |id, body, author| 42 | assert_equal @rating, Post::Comment::Rating.first 43 | assert_equal @rating, Post::Comment::Rating.last 44 | assert_equal @rating, Post::Comment::Rating.find(id) 45 | assert_equal @rating, Post::Comment::Rating.find_by(body:) 46 | assert_equal @rating, Post::Comment::Rating.find_by(author:) 47 | assert_equal @rating, Post::Comment::Rating.find_by!(body:) 48 | assert_equal @rating, Post::Comment::Rating.find_by!(author:) 49 | end 50 | 51 | { Post::Comment::Rating.primary_key => [[@post.id, @author.id]] }.tap do 52 | assert_equal @rating, Post::Comment::Rating.find_by(_1) 53 | assert_equal @rating, Post::Comment::Rating.find_by!(_1) 54 | assert_equal [@rating], Post::Comment::Rating.where(_1) 55 | end 56 | end 57 | 58 | test "associated object method missing extraction omittances" do 59 | refute_respond_to Post::Publisher, :abstract_class? 60 | refute_respond_to Post::Publisher, :descends_from_active_record? 61 | 62 | refute_respond_to Post::Publisher, :abstract_class= 63 | refute_respond_to Post::Publisher, :primary_abstract_class= 64 | 65 | assert_raise(NoMethodError) { Post::Publisher.abstract_class? } 66 | assert_raise(NoMethodError) { Post::Publisher.descends_from_active_record? } 67 | 68 | assert_raise(NoMethodError) { Post::Publisher.abstract_class = true } 69 | assert_raise(NoMethodError) { Post::Publisher.primary_abstract_class = true } 70 | end 71 | 72 | test "introspection" do 73 | assert_equal Post, Post::Publisher.record 74 | assert_equal :publisher, Post::Publisher.attribute_name 75 | 76 | assert_equal Author, Author::Archiver.record 77 | assert_equal :archiver, Author::Archiver.attribute_name 78 | 79 | assert_equal Post::Comment, Post::Comment::Rating.record 80 | assert_equal :rating, Post::Comment::Rating.attribute_name 81 | end 82 | 83 | test "unscoped passthrough" do 84 | # TODO: lol what's this actually supposed to do? Need to look more into GlobalID. 85 | # https://github.com/rails/globalid/blob/3ddb0f87fd5c22b3330ab2b4e5c41a85953ac886/lib/global_id/locator.rb#L164 86 | assert_equal [ @post ], @publisher.class.unscoped 87 | end 88 | 89 | test "transaction passthrough" do 90 | assert_equal @post, Post::Publisher.transaction { Post.first } 91 | assert_equal @post, @publisher.transaction { Post.first } 92 | end 93 | 94 | test "primary_key passthrough" do 95 | assert_equal Post.primary_key, Post::Publisher.primary_key 96 | end 97 | 98 | test "callback forwarding" do 99 | @post.update title: "Updated title" 100 | assert_equal "Updated title", @publisher.captured_title 101 | 102 | @post.destroy 103 | refute_predicate @post, :destroyed? 104 | refute_empty Post.all 105 | end 106 | 107 | test "initialization's instance variables" do 108 | # Confirm that it initializes the instance variable to nil 109 | assert_includes Author.new.instance_variables, :@associated_objects 110 | assert_nil Author.new.instance_variable_get(:@associated_objects) 111 | 112 | # It still includes the default Rails variables 113 | assert_equal Post.new.instance_variable_get(:@new_record), true 114 | end 115 | 116 | test "active model conversion integration" do 117 | assert_equal @publisher, @publisher.to_model 118 | assert_equal [@post.id], @publisher.to_key 119 | assert_equal @post.id.to_s, @publisher.to_param 120 | assert_equal "post/publishers/publisher", @publisher.to_partial_path 121 | 122 | assert_equal @rating, @rating.to_model 123 | assert_equal @comment.id, @rating.to_key 124 | assert_equal @comment.id.join("-"), @rating.to_param 125 | assert_equal "post/comment/ratings/rating", @rating.to_partial_path 126 | end 127 | 128 | test "cache_key integration" do 129 | assert_equal "post/publishers/new", Post.new.publisher.cache_key 130 | assert_equal "post/publishers/#{@post.id}", @publisher.cache_key 131 | 132 | assert_match /\d+/, @publisher.cache_version 133 | assert_equal @post.cache_version, @publisher.cache_version 134 | assert_match %r(post/publishers/#{@post.id}-\d+), @publisher.cache_key_with_version 135 | 136 | @post.with updated_at: nil do 137 | assert_equal "post/publishers/#{@post.id}", @publisher.cache_key_with_version 138 | end 139 | 140 | 141 | assert_equal "post/comment/ratings/new", Post::Comment.new.rating.cache_key 142 | assert_equal "post/comment/ratings/#{@comment.id}", @rating.cache_key 143 | 144 | assert_match /\d+/, @rating.cache_version 145 | assert_equal @comment.cache_version, @rating.cache_version 146 | assert_match %r(post/comment/ratings/.*?-\d+), @rating.cache_key_with_version 147 | 148 | @comment.with updated_at: nil do 149 | assert_equal "post/comment/ratings/#{@comment.id}", @rating.cache_key_with_version 150 | end 151 | end 152 | 153 | test "cache_key integration without cache_versioning" do 154 | previous_versioning, Post.cache_versioning = Post.cache_versioning, false 155 | error = assert_raises { @publisher.cache_key } 156 | assert_match /cache_key.*?Post.cache_versioning = true/, error.message 157 | ensure 158 | Post.cache_versioning = previous_versioning 159 | end 160 | 161 | test "kredis integration" do 162 | Time.new(2022, 4, 20, 1).tap do |publish_at| 163 | @publisher.publish_at.value = publish_at 164 | 165 | assert_equal "post:publishers:1:publish_at", @publisher.publish_at.key 166 | assert_equal publish_at, @publisher.publish_at.value 167 | end 168 | 169 | @rating.moderated.mark 170 | assert_equal "post:comment:ratings:[1, 1]:moderated", @rating.moderated.key 171 | assert @rating.moderated? 172 | end 173 | 174 | test "global_id integration" do 175 | assert_equal "gid://test/Post::Publisher/1", @publisher.to_gid.to_s 176 | assert_equal @publisher, GlobalID.find(@publisher.to_gid.to_s) 177 | 178 | assert_raises(ActiveRecord::RecordNotFound) { GlobalID::Locator.locate_many([ Post.new(id: 2).publisher.to_gid.to_s ]) } 179 | assert_equal [ @publisher ], GlobalID::Locator.locate_many([ @publisher.to_gid.to_s ]) 180 | assert_equal [ @publisher ], GlobalID::Locator.locate_many([ @publisher.to_gid.to_s, Post.new(id: 2).publisher.to_gid.to_s ], ignore_missing: true) 181 | 182 | assert_equal "gid://test/Post::Comment::Rating/1/1", @rating.to_gid.to_s 183 | assert_equal @rating, GlobalID.find(@rating.to_gid.to_s) 184 | 185 | missing_rating = Post::Comment.new(post_id: 2, author_id: 10).rating 186 | assert_raises(ActiveRecord::RecordNotFound) { GlobalID::Locator.locate_many([ missing_rating.to_gid.to_s ]) } 187 | assert_equal [ @rating ], GlobalID::Locator.locate_many([ @rating.to_gid.to_s ]) 188 | assert_equal [ @rating ], GlobalID::Locator.locate_many([ @rating.to_gid.to_s, missing_rating.to_gid.to_s ], ignore_missing: true) 189 | end 190 | 191 | test "Active Job integration" do 192 | @publisher.performed = false 193 | 194 | assert_performed_with job: Post::Publisher::PublishJob, args: [ @publisher ], queue: "important" do 195 | @publisher.publish_later 196 | end 197 | 198 | assert @publisher.performed 199 | end 200 | 201 | test "calling method" do 202 | assert @rating.great? 203 | end 204 | 205 | test "record_klass extension" do 206 | assert_predicate Post::Comment.great.first, :rated_great? 207 | assert_match /test\/boot\/associated_object/, Post::Comment.instance_method(:rated_great?).source_location.first 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveRecord::AssociatedObject 2 | 3 | Rails applications can end up with models that get way too big, and so far, the 4 | Ruby community response has been Service Objects. But sometimes `app/services` 5 | can turn into another junk drawer that doesn't help you build and make concepts for your Domain Model. 6 | 7 | `ActiveRecord::AssociatedObject` takes that head on. Associated Objects are a new domain concept, a context object, that's meant to 8 | help you tease out collaborator objects for your Active Record models. 9 | 10 | They're essentially POROs that you associate with an Active Record model to get benefits both in simpler code as well as automatic `app/models` organization. 11 | 12 | Let's look at an example. Say you have a `Post` model that encapsulates a blog post in a Content-Management-System: 13 | 14 | ```ruby 15 | class Post < ApplicationRecord 16 | end 17 | ``` 18 | 19 | You've identified that several things need to happen when a post gets published. 20 | But where does that behavior live; in `Post`? That might get messy. 21 | 22 | If we put it in a classic Service Object, we've got access to a `def call` method and that's it — what if we need other methods that operate on the state? And then having `PublishPost` or a similar ad-hoc name in `app/services` can pollute that folder over time. 23 | 24 | What if we instead identified a `Publisher` collaborator object, a Ruby class that handles publishing? What if we required it to be placed within `Post::` to automatically help connote the object as belonging to and collaborating with `Post`? Then we'd get `app/models/post/publisher.rb` which guides naming and gives more organization in your app automatically through that convention — and helps prevent a junk drawer from forming. 25 | 26 | This is what Associated Objects are! We'd define it like this: 27 | 28 | ```ruby 29 | # app/models/post/publisher.rb 30 | class Post::Publisher < ActiveRecord::AssociatedObject 31 | end 32 | ``` 33 | 34 | And then you can declare it in `Post`: 35 | 36 | ```ruby 37 | # app/models/post.rb 38 | class Post < ApplicationRecord 39 | has_object :publisher 40 | end 41 | ``` 42 | 43 | There isn't anything super special happening yet. Here's essentially what's happening under the hood: 44 | 45 | ```ruby 46 | class Post::Publisher 47 | attr_reader :post 48 | def initialize(post) = @post = post 49 | end 50 | 51 | class Post < ApplicationRecord 52 | def publisher = (@associated_objects ||= {})[:publisher] ||= Post::Publisher.new(self) 53 | end 54 | ``` 55 | 56 | Note: due to Ruby's Object Shapes, we use a single `@associated_objects` instance variable that's assigned to `nil` on `Post.new`. This prevents Active Record's from ballooning into many different shapes in Ruby's internals. 57 | We've fixed this so you don't need to care, but this is what's happening. 58 | 59 | > [!TIP] 60 | > `has_object` only requires a namespace and an initializer that takes a single argument. The above `Post::Publisher` is perfectly valid as an Associated Object — same goes for `class Post::Publisher < Data.define(:post); end`. 61 | 62 | > [!TIP] 63 | > You can pass multiple names too: `has_object :seats, :entitlements, :publisher, :classified, :fortification`. I recommend `-s`, `-[i]er`, `-[i]ed` and `-ion` as the general naming conventions for your Associated Objects. 64 | 65 | > [!TIP] 66 | > Plural Associated Object names are also supported: `Account.has_object :seats` will look up `Account::Seats`. 67 | 68 | > [!TIP] 69 | > For certain names you may want to define an acronym like so: 70 | > 71 | > ```ruby 72 | > # config/initializers/inflections.rb 73 | > ActiveSupport::Inflector.inflections(:en) do |inflect| 74 | > inflect.acronym "OAuth" # ‘oauth’ → ‘OAuth’ 75 | > end 76 | > ``` 77 | > 78 | > Then `has_object :oauth_scopes` will look up `OAuthScopes`, instead of `OauthScopes`. 79 | 80 | See how we're always expecting a link to the model, here `post`? 81 | 82 | Because of that, you can rely on `post` from the associated object: 83 | 84 | ```ruby 85 | class Post::Publisher < ActiveRecord::AssociatedObject 86 | def publish 87 | # `transaction` is syntactic sugar for `post.transaction` here. 88 | transaction do 89 | post.update! published: true 90 | post.subscribers.post_published post 91 | 92 | # There's also a `record` alias available if you prefer the more general reading version: 93 | # record.update! published: true 94 | # record.subscribers.post_published record 95 | end 96 | end 97 | end 98 | ``` 99 | 100 | ### See Associated Objects in action 101 | 102 | #### RubyEvents.org 103 | 104 | The https://www.rubyevents.org team has been using Associated Objects to clarify the boundaries of their Active Records and collaborator Associated Objects. 105 | 106 | See the usage in the source here: 107 | 108 | - [`ActiveRecord::AssociatedObject` instances](https://github.com/search?q=repo%3Arubyevents%2Frubyevents%20ActiveRecord%3A%3AAssociatedObject&type=code) 109 | - [`has_object` calls](https://github.com/search?q=repo%3Arubyevents%2Frubyevents+has_object&type=code) 110 | 111 | #### Flipper 112 | 113 | The team at [Flipper](https://www.flippercloud.io) used Associated Objects to help keep their new billing structure clean. 114 | 115 | You can see real life examples in these blog posts: 116 | 117 | - [Organizing Rails Code with ActiveRecord Associated Objects](https://garrettdimon.com/journal/posts/organizing-rails-code-with-activerecord-associated-objects) 118 | - [Data Modeling Entitlements and Pricing for SaaS Applications](https://garrettdimon.com/journal/posts/data-modeling-saas-entitlements-and-pricing) 119 | 120 | If your team is using Associated Objects, we're more than happy to feature any write ups here. 121 | 122 | ### Use the generator to help write Associated Objects 123 | 124 | To set up the `Post::Publisher` from above, you can call `bin/rails generate associated Post::Publisher`. 125 | 126 | See `bin/rails generate associated --help` for more info. 127 | 128 | ### Forwarding callbacks onto the associated object 129 | 130 | To further help illustrate how your collaborator Associated Objects interact with your domain model, you can forward callbacks. 131 | 132 | Say we wanted to have our `publisher` automatically publish posts after they're created. Or we need to refresh a publishing after a post has been touched. Or what if we don't want posts to be destroyed if they're published due to HAHA BUSINESS rules? 133 | 134 | So `has_object` can state this and forward those callbacks onto the Associated Object: 135 | 136 | ```ruby 137 | class Post < ActiveRecord::Base 138 | # Passing `true` forwards the same name, e.g. `after_touch`. 139 | has_object :publisher, after_touch: true, after_create_commit: :publish, 140 | before_destroy: :prevent_errant_post_destroy 141 | 142 | # The above is the same as writing: 143 | after_create_commit { publisher.publish } 144 | after_touch { publisher.after_touch } 145 | before_destroy { publisher.prevent_errant_post_destroy } 146 | end 147 | 148 | class Post::Publisher < ActiveRecord::AssociatedObject 149 | def publish 150 | end 151 | 152 | def after_touch 153 | # Respond to the after_touch on the Post. 154 | end 155 | 156 | def prevent_errant_post_destroy 157 | # Passed callbacks can throw :abort too, and in this example prevent post.destroy. 158 | throw :abort if haha_business? 159 | end 160 | end 161 | ``` 162 | 163 | ### Extending the Active Record from within the Associated Object 164 | 165 | Since `has_object` eager-loads the Associated Object class, you can also move 166 | any integrating code into the Associated Object. 167 | 168 | If you've got a few extensions, you can use `record` to access the Active Record class: 169 | 170 | ```ruby 171 | class Post::Publisher < ActiveRecord::AssociatedObject 172 | record.has_many :contracts, dependent: :destroy # `record` returns `Post` here. 173 | end 174 | ``` 175 | 176 | Alternatively, if you have many extensions, use the `extension` block: 177 | 178 | > [!NOTE] 179 | > Technically, `extension` is just `Post.class_eval` but with syntactic sugar. 180 | 181 | ```ruby 182 | class Post::Publisher < ActiveRecord::AssociatedObject 183 | extension do 184 | # Here we're within Post and can extend it: 185 | has_many :contracts, dependent: :destroy do 186 | def signed? = all?(&:signed?) 187 | end 188 | 189 | def self.with_contracts = includes(:contracts) 190 | 191 | after_create_commit :publish_later, if: -> { contracts.signed? } 192 | 193 | # An integrating method that operates on `publisher`. 194 | private def publish_later = publisher.publish_later 195 | end 196 | end 197 | ``` 198 | 199 | This is meant as an alternative to having a wrapping `ActiveSupport::Concern` in yet-another file like this: 200 | 201 | ```ruby 202 | class Post < ApplicationRecord 203 | include Published 204 | end 205 | 206 | # app/models/post/published.rb 207 | module Post::Published 208 | extend ActiveSupport::Concern 209 | 210 | included do 211 | has_many :contracts, dependent: :destroy do 212 | def signed? = all?(&:signed?) 213 | end 214 | 215 | has_object :publisher 216 | after_create_commit :publish_later, if: -> { contracts.signed? } 217 | end 218 | 219 | class_methods do 220 | def with_contracts = includes(:contracts) 221 | end 222 | 223 | # An integrating method that operates on `publisher`. 224 | private def publish_later = publisher.publish_later 225 | end 226 | ``` 227 | 228 | > [!NOTE] 229 | > Notice how in the `extension` version you don't need to: 230 | > 231 | > - have a naming convention for Concerns and where to place them. 232 | > - look up two files to read the feature (the concern and the associated object). 233 | > - wrap integrating code in an `included` block. 234 | > - wrap class methods in a `class_methods` block. 235 | 236 | ### Primary Benefit: Organization through Convention 237 | 238 | The primary benefit for right now is that by focusing the concept of namespaced Collaborator Objects through Associated Objects, you will start seeing them when you're modelling new features and it'll change how you structure and write your apps. 239 | 240 | This is what [@natematykiewicz](https://github.com/natematykiewicz) found when they started using the gem (we'll get to `ActiveJob::Performs` soon): 241 | 242 | > We're running `ActiveRecord::AssociatedObject` and `ActiveJob::Performs` (via the associated object) in 3 spots in production so far. It massively improved how I was architecting a new feature. I put a PR up for review and a coworker loved how organized and easy to follow the large PR was because of those 2 gems. I'm now working on another PR in our app where I'm using them again. I keep seeing use-cases for them now. I love it. Thank you for these gems! 243 | > 244 | > Anyone reading this, if you haven't checked them out yet, I highly recommend it. 245 | 246 | And about a month later it was still holding up: 247 | 248 | > Just checking in to say we've added like another 4 associated objects to production since my last message. `ActiveRecord::AssociatedObject` + `ActiveJob::Performs` is like a 1-2 punch super power. I'm a bit surprised that this isn't Rails core to be honest. I want to migrate so much of our code over to this. It feels much more organized and sane. Then my app/jobs folder won't have much in it because most jobs will actually be via some associated object's _later method. app/jobs will then basically be cron-type things (deactivate any expired subscriptions). 249 | 250 | Here's what [@nshki](https://github.com/nshki) found when they tried it: 251 | 252 | > Spent some time playing with [@kaspth](https://github.com/kaspth)'s `ActiveRecord::AssociatedObject` and `ActiveJob::Performs` and wow! The conventions these gems put in place help simplify a codebase drastically. I particularly love `ActiveJob::Performs`—it helped me refactor out all `ApplicationJob` classes I had and keep important context in the right domain model. 253 | 254 | Let's look at testing, then we'll get to passing these POROs to jobs like the quotes mentioned! 255 | 256 | ### Testing Associated Objects 257 | 258 | Follow the `app/models/post.rb` and `app/models/post/publisher.rb` naming structure in your tests and add `test/models/post/publisher_test.rb`. 259 | 260 | Then test it like any other object: 261 | 262 | ```ruby 263 | # test/models/post/publisher_test.rb 264 | class Post::PublisherTest < ActiveSupport::TestCase 265 | # You can use Fixtures/FactoryBot to get a `post` and then extract its `publisher`: 266 | setup { @publisher = posts(:one).publisher } 267 | setup { @publisher = FactoryBot.build(:post).publisher } 268 | 269 | test "publish updates the post" do 270 | @publisher.publish 271 | assert @publisher.post.reload.published? 272 | end 273 | end 274 | ``` 275 | 276 | ### Active Model integration 277 | 278 | Associated Objects quack like `ActiveModel`s because we: 279 | 280 | - [`extend ActiveModel::Naming`](https://api.rubyonrails.org/classes/ActiveModel/Naming.html) 281 | - [`include ActiveModel::Conversion`](https://api.rubyonrails.org/classes/ActiveModel/Conversion.html) 282 | 283 | This means you can pass them to helpers like `form_with` and route helpers like `url_for` too. 284 | 285 | > [!NOTE] 286 | > We don't `include ActiveModel::Model` since we don't need `assign_attributes` and validations really. 287 | 288 | ```ruby 289 | # app/controllers/post/publishers_controller.rb 290 | class Post::PublishersController < ApplicationController 291 | before_action :set_publisher 292 | 293 | def new 294 | end 295 | 296 | def create 297 | @publisher.publish params.expect(publisher: :toast) 298 | redirect_back_or_to root_url, notice: "Out it goes!" 299 | end 300 | 301 | private 302 | def set_publisher 303 | # Associated Objects are POROs, so behind the scenes we're really doing `Post.find(…).publisher`. 304 | @publisher = Post::Publisher.find(params[:id]) 305 | end 306 | end 307 | ``` 308 | 309 | And then on the view side, you can pass it into `form_with`: 310 | 311 | ```erb 312 | <%# app/views/post/publishers/new.html.erb %> 313 | <%# Here `form_with` calls `url_for(@publisher)` which calls `post_publisher_path(@publisher)`. %> 314 | <%= form_with model: @publisher do |form| %> 315 | <%= form.text_field :toast %> 316 | <%= form.submit "Publish with toast" %> 317 | <% end %> 318 | ``` 319 | 320 | Finally, the routing is pretty standard fare: 321 | 322 | ```ruby 323 | namespace :post do 324 | resources :publishers 325 | end 326 | ``` 327 | 328 | #### Rendering Associated Objects 329 | 330 | Associated Objects respond to `to_partial_path`, so you can pass them directly to `render`. 331 | 332 | We're using Rails' conventions here, so view paths look like this: 333 | 334 | ```erb 335 | <%# With a Post::Publisher, this renders app/views/post/publishers/_publisher.html.erb %> 336 | <%= render publisher %> 337 | 338 | <%# With a Post::Comment::Rating, this renders app/views/post/comment/ratings/_rating.html.erb %> 339 | <%= render rating %> 340 | ``` 341 | 342 | We've also got full support for fragment caching, so this is possible: 343 | 344 | ```erb 345 | <%# app/views/post/publishers/_publisher.html.erb %> 346 | <%= cache publisher do %> 347 | <%# More publishing specific view logic. %> 348 | <% end %> 349 | ``` 350 | 351 | > [!NOTE] 352 | > We only support recyclable cache keys which has been the default since Rails 5.2. 353 | > This means the Active Record you associate with must have `SomeModel.cache_versioning = true` enabled. 354 | > 355 | > Associated Objects respond to `cache_key`, `cache_version` and `cache_key_with_version` like Active Records. 356 | 357 | ### Polymorphic Associated Objects 358 | 359 | If you want to share logic between associated objects, you can do so via standard Ruby modules: 360 | 361 | ```ruby 362 | # app/models/pricing.rb 363 | module Pricing 364 | # If you need to share an `extension` across associated objects you can override `Module::included` like this: 365 | def self.included(object) = object.extension do 366 | # Add common integration methods onto `Account`/`User` when the module is included. 367 | # See the `extension` block in the `Extending` section above for an example. 368 | end 369 | 370 | def price_set? 371 | # Instead of referring to `account` or `user`, use the `record` method to target either. 372 | record.price_cents.positive? 373 | end 374 | end 375 | 376 | # app/models/account/pricing.rb 377 | class Account::Pricing < ActiveRecord::AssociatedObject 378 | include ::Pricing 379 | end 380 | 381 | # app/models/user/pricing.rb 382 | class User::Pricing < ActiveRecord::AssociatedObject 383 | include ::Pricing 384 | end 385 | ``` 386 | 387 | Now we can call `account.pricing.price_set?` & `user.pricing.price_set?`. 388 | 389 | > [!NOTE] 390 | > Polymorphic Associated Objects are definitely a more advanced topic, 391 | > so you need to know your Ruby module hierarchy and how to track what `self` changes to fairly well. 392 | 393 | #### Using `ActiveSupport::Concern` as an alternative 394 | 395 | If you prefer the look of Active Support concerns, here's the equivalent to the above Ruby module: 396 | 397 | ```ruby 398 | # app/models/pricing.rb 399 | module Pricing 400 | extend ActiveSupport::Concern 401 | 402 | included do 403 | extension do 404 | # Add common integration methods onto `Account`/`User` when the concern is included. 405 | end 406 | end 407 | 408 | def price_set? 409 | # Instead of referring to `account` or `user`, use the `record` method to target either. 410 | record.price_cents.positive? 411 | end 412 | end 413 | ``` 414 | 415 | Active Support concerns have some extra features that standard Ruby modules don't, like support for deeply-nested concerns and `class_methods do`. 416 | 417 | In this case, if you're reaching for those, you're probably building something too intricate and potentially brittle. 418 | 419 | ### Active Job integration via GlobalID 420 | 421 | Associated Objects include `GlobalID::Identification` and have automatic Active Job serialization support that looks like this: 422 | 423 | ```ruby 424 | class Post::Publisher < ActiveRecord::AssociatedObject 425 | class PublishJob < ApplicationJob 426 | def perform(publisher) = publisher.publish 427 | end 428 | 429 | def publish_later 430 | PublishJob.perform_later self # We're passing this PORO to the job! 431 | end 432 | 433 | def publish 434 | # … 435 | end 436 | end 437 | ``` 438 | 439 | > [!NOTE] 440 | > Internally, Active Job serializes Active Records as GlobalIDs. Active Record also includes `GlobalID::Identification`, which requires the `find` and `where(id:)` class methods. 441 | > 442 | > We've added `Post::Publisher.find` & `Post::Publisher.where(id:)` that calls `Post.find(id).publisher` and `Post.where(id:).map(&:publisher)` respectively. 443 | 444 | This pattern of a job `perform` consisting of calling an instance method on a sole domain object is ripe for a convention, here's how to do that. 445 | 446 | #### Remove Active Job boilerplate with `performs` 447 | 448 | If you also bundle [`active_job-performs`](https://github.com/kaspth/active_job-performs) in your Gemfile like this: 449 | 450 | ```ruby 451 | gem "active_job-performs" 452 | gem "active_record-associated_object" 453 | ``` 454 | 455 | Every Associated Object (and Active Records too) now has access to the `performs` macro, so you can do this: 456 | 457 | ```ruby 458 | class Post::Publisher < ActiveRecord::AssociatedObject 459 | performs queue_as: :important 460 | performs :publish 461 | performs :retract 462 | 463 | def publish 464 | end 465 | 466 | def retract(reason:) 467 | end 468 | end 469 | ``` 470 | 471 | which spares you writing all this: 472 | 473 | ```ruby 474 | class Post::Publisher < ActiveRecord::AssociatedObject 475 | # `performs` without a method defines a general job to share between method jobs. 476 | class Job < ApplicationJob 477 | queue_as :important 478 | end 479 | 480 | # Individual method jobs inherit from the `Post::Publisher::Job` defined above. 481 | class PublishJob < Job 482 | # Here's the GlobalID integration again, i.e. we don't have to do `post.publisher`. 483 | def perform(publisher, *, **) = publisher.publish(*, **) 484 | end 485 | 486 | class RetractJob < Job 487 | def perform(publisher, *, **) = publisher.retract(*, **) 488 | end 489 | 490 | def publish_later(*, **) = PublishJob.perform_later(self, *, **) 491 | def retract_later(*, **) = RetractJob.perform_later(self, *, **) 492 | end 493 | ``` 494 | 495 | Note: you can also pass more complex configuration like this: 496 | 497 | ```ruby 498 | performs :publish, queue_as: :important, discard_on: SomeError do 499 | retry_on TimeoutError, wait: :exponentially_longer 500 | end 501 | ``` 502 | 503 | See [the `ActiveJob::Performs` README](https://github.com/kaspth/active_job-performs) for more details. 504 | 505 | ### Automatic Kredis integration 506 | 507 | We've got automatic Kredis integration for Associated Objects, so you can use any `kredis_*` type just like in Active Record classes: 508 | 509 | ```ruby 510 | class Post::Publisher < ActiveRecord::AssociatedObject 511 | kredis_datetime :publish_at # Uses a namespaced "post:publishers::publish_at" key. 512 | end 513 | ``` 514 | 515 | > [!NOTE] 516 | > Under the hood, this reuses the same info we needed for automatic Active Job support. Namely, the Active Record class, here `Post`, and its `id`. 517 | 518 | ### Namespaced models 519 | 520 | If you have a namespaced Active Record like this: 521 | 522 | ```ruby 523 | # app/models/post/comment.rb 524 | class Post::Comment < ApplicationRecord 525 | belongs_to :post 526 | belongs_to :creator, class_name: "User" 527 | 528 | has_object :rating 529 | end 530 | ``` 531 | 532 | You can define the associated object in the same way it was done for `Post::Publisher` above, within the `Post::Comment` namespace: 533 | 534 | ```ruby 535 | # app/models/post/comment/rating.rb 536 | class Post::Comment::Rating < ActiveRecord::AssociatedObject 537 | def good? 538 | # A `comment` method is generated to access the associated comment. There's also a `record` alias available. 539 | comment.creator.subscriber_of? comment.post.creator 540 | end 541 | end 542 | ``` 543 | 544 | And then test it in `test/models/post/comment/rating_test.rb`: 545 | 546 | ```ruby 547 | class Post::Comment::RatingTest < ActiveSupport::TestCase 548 | setup { @rating = posts(:one).comments.first.rating } 549 | setup { @rating = FactoryBot.build(:post_comment).rating } 550 | 551 | test "pretty, pretty, pretty, pretty good" do 552 | assert @rating.good? 553 | end 554 | end 555 | ``` 556 | 557 | ### Composite primary keys 558 | 559 | We support Active Record models with composite primary keys out of the box. 560 | 561 | Just setup the associated objects like the above examples and you've got GlobalID/Active Job and Kredis support automatically. 562 | 563 | ## Risks of depending on this gem 564 | 565 | This gem is relatively tiny and I'm not expecting more significant changes on it, for right now. It's unofficial and not affiliated with Rails core. 566 | 567 | Though it's written and maintained by an ex-Rails core person, so I know my way in and out of Rails and how to safely extend it. 568 | 569 | ## Installation 570 | 571 | Install the gem and add to the application's Gemfile by executing: 572 | 573 | $ bundle add active_record-associated_object 574 | 575 | If bundler is not being used to manage dependencies, install the gem by executing: 576 | 577 | $ gem install active_record-associated_object 578 | 579 | ## Development 580 | 581 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 582 | 583 | 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). 584 | 585 | ## Contributing 586 | 587 | Bug reports and pull requests are welcome on GitHub at https://github.com/kaspth/active_record-associated_object. 588 | 589 | ## License 590 | 591 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 592 | --------------------------------------------------------------------------------