Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add embedded associations in ActiveModel #43399

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions activemodel/lib/active_model.rb
Expand Up @@ -40,6 +40,7 @@ module ActiveModel
autoload :Conversion
autoload :Dirty
autoload :EachValidator, "active_model/validator"
autoload :Embedding
autoload :ForbiddenAttributesProtection
autoload :Lint
autoload :Model
Expand Down
10 changes: 10 additions & 0 deletions activemodel/lib/active_model/embedding.rb
@@ -0,0 +1,10 @@
# frozen_string_literal: true

module ActiveModel
module Embedding
require "active_model/embedding/associations"
require "active_model/embedding/document"
require "active_model/embedding/collecting"
require "active_model/embedding/collection"
end
end
79 changes: 79 additions & 0 deletions activemodel/lib/active_model/embedding/associations.rb
@@ -0,0 +1,79 @@
# frozen_string_literal: true

module ActiveModel
module Embedding
module Associations
def self.included(klass)
klass.class_eval do
extend ClassMethods

class_variable_set :@@embedded_associations, []

around_save :save_embedded_documents

def save_embedded_documents
klass = self.class

if klass.embedded_associations.present?
associations = klass.embedded_associations

targets = associations.filter_map do |association_name|
public_send association_name
end

targets.each(&:save)
end

yield
end
end
end

module ClassMethods
def embeds_many(attr_name, class_name: nil, cast_type: nil, collection: nil)
class_name = cast_type ? nil : class_name || infer_class_name_from(attr_name)

attribute :"#{attr_name}", :document,
class_name: class_name,
cast_type: cast_type,
collection: collection || true,
context: self.to_s

register_embedded_association attr_name

nested_attributes_for attr_name
end

def embeds_one(attr_name, class_name: nil, cast_type: nil)
class_name = cast_type ? nil : class_name || infer_class_name_from(attr_name)

attribute :"#{attr_name}", :document,
class_name: class_name,
cast_type: cast_type,
context: self.to_s

register_embedded_association attr_name

nested_attributes_for attr_name
end

def embedded_associations
class_variable_get :@@embedded_associations
end

private
def infer_class_name_from(attr_name)
attr_name.to_s.singularize.camelize
end

def register_embedded_association(name)
embedded_associations << name
end

def nested_attributes_for(attr_name)
delegate :attributes=, to: :"#{attr_name}", prefix: true
end
end
end
end
end
117 changes: 117 additions & 0 deletions activemodel/lib/active_model/embedding/collecting.rb
@@ -0,0 +1,117 @@
# frozen_string_literal: true

module ActiveModel
module Embedding
module Collecting
include ActiveModel::ForbiddenAttributesProtection

attr_reader :documents, :document_class
alias_method :to_a, :documents
alias_method :to_ary, :to_a

def initialize(documents)
@documents = documents
@document_class = documents.first.class
end

def attributes=(documents_attributes)
documents_attributes = sanitize_for_mass_assignment(documents_attributes)

case documents_attributes
when Hash
documents_attributes.each do |index, document_attributes|
index = index.to_i
id = fetch_id(document_attributes) || index
document = find_by_id id if id

unless document
document = documents[index] || build
end

document.attributes = document_attributes
end
when Array
documents_attributes.each do |document_attributes|
id = fetch_id(document_attributes)
document = find_by_id id if id

unless document
document = build
end

document.attributes = document_attributes
end
else
raise_attributes_error
end
end

def find_by_id(id)
documents.find { |document| document.id == id }
end

def build(attributes = {})
case attributes
when Hash
document = document_class.new(attributes)

append document

document
when Array
attributes.map do |document_attributes|
build(document_attributes)
end
else
raise_attributes_error
end
end

def push(*new_documents)
new_documents = new_documents.flatten

valid_documents = new_documents.all? { |document| document.is_a? document_class }

unless valid_documents
raise ArgumentError, "Expect arguments to be of class #{document_class}"
end

@documents.push(*new_documents)
end

alias_method :<<, :push
alias_method :append, :push

def save
documents.all?(&:save)
end

def each(&block)
return self.to_enum unless block_given?

documents.each(&block)
end

def as_json
documents.as_json
end

def to_json
as_json.to_json
end

def ==(other)
documents.map(&:attributes) == other.map(&:attributes)
end

private
def fetch_id(attributes)
attributes["id"].to_i
end

def raise_attributes_error
raise ArgumentError, "Expect attributes to be a Hash or Array, but got a #{attributes.class}"
end
end
end
end
12 changes: 12 additions & 0 deletions activemodel/lib/active_model/embedding/collection.rb
@@ -0,0 +1,12 @@
# frozen_string_literal: true

require "active_model/embedding/collecting"

module ActiveModel
module Embedding
class Collection
include Enumerable
include Embedding::Collecting
end
end
end
48 changes: 48 additions & 0 deletions activemodel/lib/active_model/embedding/document.rb
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module ActiveModel
module Embedding
module Document
def self.included(klass)
klass.class_eval do
extend ClassMethods
extend ActiveModel::Callbacks

define_model_callbacks :save

include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Serializers::JSON
include Embedding::Associations

attribute :id, :integer

def save
run_callbacks :save do
return false unless valid?

self.id = object_id unless persisted?

true
end
end

def persisted?
id.present?
end

def ==(other)
attributes == other.attributes
end
end
end

module ClassMethods
def validates_associated(*attr_names)
validates_with ActiveRecord::Validations::AssociatedValidator,
_merge_attributes(attr_names)
end
end
end
end
end
2 changes: 2 additions & 0 deletions activemodel/lib/active_model/type.rb
Expand Up @@ -9,6 +9,7 @@
require "active_model/type/date"
require "active_model/type/date_time"
require "active_model/type/decimal"
require "active_model/type/document"
require "active_model/type/float"
require "active_model/type/immutable_string"
require "active_model/type/integer"
Expand Down Expand Up @@ -45,6 +46,7 @@ def default_value # :nodoc:
register(:date, Type::Date)
register(:datetime, Type::DateTime)
register(:decimal, Type::Decimal)
register(:document, Type::Document)
register(:float, Type::Float)
register(:immutable_string, Type::ImmutableString)
register(:integer, Type::Integer)
Expand Down