diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index 8af205bf593a3..c99980dd8dbdd 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -40,6 +40,7 @@ module ActiveModel autoload :Conversion autoload :Dirty autoload :EachValidator, "active_model/validator" + autoload :Embedding autoload :ForbiddenAttributesProtection autoload :Lint autoload :Model diff --git a/activemodel/lib/active_model/embedding.rb b/activemodel/lib/active_model/embedding.rb new file mode 100644 index 0000000000000..74d11af7df6c6 --- /dev/null +++ b/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 diff --git a/activemodel/lib/active_model/embedding/associations.rb b/activemodel/lib/active_model/embedding/associations.rb new file mode 100644 index 0000000000000..89acf6f953c9a --- /dev/null +++ b/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 diff --git a/activemodel/lib/active_model/embedding/collecting.rb b/activemodel/lib/active_model/embedding/collecting.rb new file mode 100644 index 0000000000000..c8064b844fe78 --- /dev/null +++ b/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 diff --git a/activemodel/lib/active_model/embedding/collection.rb b/activemodel/lib/active_model/embedding/collection.rb new file mode 100644 index 0000000000000..a8645fba00464 --- /dev/null +++ b/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 diff --git a/activemodel/lib/active_model/embedding/document.rb b/activemodel/lib/active_model/embedding/document.rb new file mode 100644 index 0000000000000..9d332e2f759a9 --- /dev/null +++ b/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 diff --git a/activemodel/lib/active_model/type.rb b/activemodel/lib/active_model/type.rb index 3a0e8abcc3f43..eb68e9a0d3312 100644 --- a/activemodel/lib/active_model/type.rb +++ b/activemodel/lib/active_model/type.rb @@ -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" @@ -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) diff --git a/activemodel/lib/active_model/type/document.rb b/activemodel/lib/active_model/type/document.rb new file mode 100644 index 0000000000000..9b6af79fae52d --- /dev/null +++ b/activemodel/lib/active_model/type/document.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module ActiveModel + module Type + class Value + end + + class Document < Value + attr_reader :document_class, :cast_type + attr_reader :collection + attr_reader :context + + def initialize(class_name: nil, cast_type: nil, collection: false, context: nil) + @document_class = resolve_constant class_name, from: context if class_name + @cast_type = lookup_or_return cast_type if cast_type + @collection = collection + @context = context + end + + def collection? + collection + end + + def default_collection? + collection == true + end + + def collection_class + return unless collection? + + if default_collection? + @collection_class ||= ActiveModel::Embedding::Collection + else + @collection_class ||= resolve_constant collection, from: context + end + end + + def cast(value) + return unless value + + if collection? + return value if value.respond_to? :document_class + + documents = value.map { |attributes| process attributes } + + collection_class.new(documents) + else + return value if value.respond_to? :id + + process value + end + end + + def process(value) + cast_type ? cast_type.cast(value) : document_class.new(value) + end + + def serialize(value) + value.to_json + end + + def deserialize(json) + return unless json + + value = ActiveSupport::JSON.decode(json) + + cast value + end + + def changed_in_place?(old_value, new_value) + deserialize(old_value) != new_value + end + + private + def resolve_constant(name, from: nil) + name = clean_scope(name) + + if from + context = from.split("::") + + context.each do + scope = context.join("::") + constant = "::#{scope}::#{name}".constantize rescue nil + + return constant if constant + + context.pop + end + end + + "::#{name}".constantize + end + + def clean_scope(name) + name.gsub(/^::/, "") + end + + def lookup_or_return(cast_type) + case cast_type + when Symbol + begin + Type.lookup(cast_type) + rescue + ActiveRecord::Type.lookup(cast_type) + end + else + cast_type + end + end + end + end +end diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index dadc02095e6b5..24200b8ed6adf 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -60,6 +60,7 @@ def current_adapter_name Binary = ActiveModel::Type::Binary Boolean = ActiveModel::Type::Boolean Decimal = ActiveModel::Type::Decimal + Document = ActiveModel::Type::Document Float = ActiveModel::Type::Float Integer = ActiveModel::Type::Integer ImmutableString = ActiveModel::Type::ImmutableString @@ -72,6 +73,7 @@ def current_adapter_name register(:date, Type::Date, override: false) register(:datetime, Type::DateTime, override: false) register(:decimal, Type::Decimal, override: false) + register(:document, Type::Document, override: false) register(:float, Type::Float, override: false) register(:integer, Type::Integer, override: false) register(:immutable_string, Type::ImmutableString, override: false) diff --git a/activerecord/test/cases/embedding_tests.rb b/activerecord/test/cases/embedding_tests.rb new file mode 100644 index 0000000000000..f5a98f11ed2d3 --- /dev/null +++ b/activerecord/test/cases/embedding_tests.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "cases/helper" +require "action_controller" +require "models/marc" +require "models/marc/record" + +class SomeCollection + include Enumerable + include ActiveModel::Embedding::Collecting +end + +class SomeType < ActiveModel::Type::Value + def cast(value) + value.cast_type = self.class + super + end +end + +ActiveModel::Type.register(:some_type, SomeType) + +class SomeOtherType < ActiveModel::Type::Value + attr_reader :context + + def initialize(context:) + @context = context + end + + def cast(value) + value.cast_type = self.class + value.context = context + super + end +end + +class Thing + attr_accessor :cast_type + attr_accessor :context +end + +class SomeModel + include ActiveModel::Embedding::Document + + embeds_many :things, collection: "SomeCollection", cast_type: :some_type + embeds_many :other_things, cast_type: SomeOtherType.new(context: self) +end + +class EmbeddingTest < ActiveRecord::TestCase + fixtures "marc/records" + + setup do + @record = marc_records(:hamlet) + @some_model = SomeModel.new things: Array.new(3) { Thing.new }, other_things: Array.new(3) { Thing.new } + end + + test "should handle mass assignment correctly" do + field = ::MARC::Record::Field.new tag: "200" + subfields_attributes = [{ code: "a", value: "Getting Real" }, { code: "3", value: "..." }] + + field.subfields = subfields_attributes + assert_equal MARC::Record::Field::Subfield, field.subfields.document_class + + params = ::ActionController::Parameters.new(subfields_attributes: { "0" => { value: "Rework" } }) + permitted = params.permit(subfields_attributes: [:id, :value]) + + field.subfields_attributes = permitted[:subfields_attributes] + assert_equal "Rework", field.subfields.first.value + + assert field.subfields.save + assert field.subfields.all?(&:id) + + id = field.subfields.first.id + random_index = rand 100 + params = ::ActionController::Parameters.new(subfields_attributes: { "#{random_index}" => { id: id, value: "ShapeUp" } }) + permitted = params.permit(subfields_attributes: [:id, :value]) + + field.subfields_attributes = permitted[:subfields_attributes] + assert_equal "ShapeUp", field.subfields.first.value + + params = ::ActionController::Parameters.new(subfields_attributes: { "#{random_index}" => { id: id, value: "..." } }) + + assert_raises { field.subfields_attributes = params } + end + + test "should autosave embedded documents" do + @record["245"]["a"].value = "Romeo and Juliet" + + assert @record.save + + @record.reload + + assert_equal "Romeo and Juliet", @record["245"]["a"].value + + assert @record["245"]["a"].persisted? + end + + test "should perform validations" do + assert @record.valid? + + last_field = @record.fields.to_a.last + + last_field.subfields.first.code = "" + + assert_not @record.valid? + assert_not @record.save + + last_field.subfields.first.code = "a" + + assert @record.valid? + assert @record.save + end + + test "should track changes" do + assert_not @record.changed? + + @record["245"]["a"].value = "Romeo and Juliet" + + assert @record.changed? + end + + test "should handle custom collections" do + assert_equal SomeCollection, @some_model.things.class + assert_equal Thing, @some_model.things.document_class + end + + test "should handle custom types" do + assert_equal SomeType, @some_model.things.first.cast_type + assert_equal SomeOtherType, @some_model.other_things.first.cast_type + assert_equal SomeModel, @some_model.other_things.first.context + end + + test "should handle values that are already type casted" do + fields = @record.fields + + @record.fields = nil + @record.fields = fields + + assert @record.fields + end +end diff --git a/activerecord/test/fixtures/marc/records.yml b/activerecord/test/fixtures/marc/records.yml new file mode 100644 index 0000000000000..78c8dc0121f60 --- /dev/null +++ b/activerecord/test/fixtures/marc/records.yml @@ -0,0 +1,26 @@ +hamlet: + leader: "00815nam 2200289 a 4500" + fields: [ + { "tag": "001", "value": "ocm30152659" }, + { "tag": "003", "value": "OCoLC" }, + { "tag": "005", "value": "19971028235910.0" }, + { "tag": "008", "value": "940909t19941994ilua 000 0 eng " }, + { "tag": "010", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "92060871" }] }, + { "tag": "020", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "0844257443" }] }, + { "tag": "040", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "DLC" }, { "code": "c", "value": "DLC" }, { "code": "d", "value": "BKL" }, { "code": "d", "value": "UtOrBLW" } ] }, + { "tag": "049", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "BKLA" }] }, + { "tag": "099", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "822.33" }, { "code": "a", "value": "S52" }, { "code": "a", "value": "S7" } ] }, + { "tag": "100", "indicator1": "1", "indicator2": " ", "subfields": [{ "code": "a", "value": "Shakespeare, William," }, { "code": "d", "value": "1564-1616." } ] }, + { "tag": "245", "indicator1": "1", "indicator2": "0", "subfields": [{ "code": "a", "value": "Hamlet" }, { "code": "c", "value": "William Shakespeare." } ] }, + { "tag": "264", "indicator1": " ", "indicator2": "1", "subfields": [{ "code": "a", "value": "Lincolnwood, Ill. :" }, { "code": "b", "value": "NTC Pub. Group," }, { "code": "c", "value": "[1994]" } ] }, + { "tag": "264", "indicator1": " ", "indicator2": "4", "subfields": [{ "code": "c", "value": "©1994." }] }, + { "tag": "300", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "xiii, 295 pages :" }, { "code": "b", "value": "illustrations ;" }, { "code": "c", "value": "23 cm." } ] }, + { "tag": "336", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "text" }, { "code": "b", "value": "txt" }, { "code": "2", "value": "rdacontent." } ] }, + { "tag": "337", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "unmediated" }, { "code": "b", "value": "n" }, { "code": "2", "value": "rdamedia." } ] }, + { "tag": "338", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "volume" }, { "code": "b", "value": "nc" }, { "code": "2", "value": "rdacarrier." } ] }, + { "tag": "490", "indicator1": "1", "indicator2": " ", "subfields": [{ "code": "a", "value": "NTC Shakespeare series." }] }, + { "tag": "830", "indicator1": " ", "indicator2": "0", "subfields": [{ "code": "a", "value": "NTC Shakespeare series." }] }, + { "tag": "907", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": ".b108930609" }] }, + { "tag": "948", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "LTI 2018-07-09" }] }, + { "tag": "948", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "MARS" }] } + ] diff --git a/activerecord/test/models/marc.rb b/activerecord/test/models/marc.rb new file mode 100644 index 0000000000000..f6810dbdf7ca6 --- /dev/null +++ b/activerecord/test/models/marc.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MARC + def self.table_name_prefix + "marc_" + end +end + +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "MARC" +end diff --git a/activerecord/test/models/marc/record.rb b/activerecord/test/models/marc/record.rb new file mode 100644 index 0000000000000..aa9167f7f25a2 --- /dev/null +++ b/activerecord/test/models/marc/record.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class MARC::Record < ActiveRecord::Base + class Field + class Subfield + include ActiveModel::Embedding::Document + + attribute :code, :string + attribute :value, :string + + validates :code, presence: true, format: { with: /\w/ } + end + + include ActiveModel::Embedding::Document + + attribute :tag, :string + attribute :value, :string + attribute :indicator1, :string, default: " " + attribute :indicator2, :string, default: " " + + embeds_many :subfields + + validates :tag, presence: true, format: { with: /\d{3}/ } + + validates :subfields, presence: true, unless: :control_field? + validates_associated :subfields, unless: :control_field? + + def attributes + if control_field? + { + "id" => id, + "tag" => tag, + "value" => value + } + else + { + "id" => id, + "tag" => tag, + "indicator1" => indicator1, + "indicator2" => indicator2, + "subfields" => subfields, + } + end + end + + def control_field? + /00\d/ === tag + end + + # Yet another Hash-like reader method + def [](code) + occurrences = subfields.select { |subfield| subfield.code == code } + occurrences.first unless occurrences.count > 1 + end + end + + include ActiveModel::Embedding::Associations + + embeds_many :fields + + validates :fields, presence: true + validates_associated :fields + + # Hash-like reader method + def [](tag) + occurrences = fields.select { |field| field.tag == tag } + occurrences.first unless occurrences.count > 1 + end +end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 3fbd8570d42b8..5b073f5008f43 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -1278,6 +1278,11 @@ t.integer :id t.datetime :created_at end + + create_table :marc_records, force: true do |t| + t.string :leader + t.json :fields + end end Course.connection.create_table :courses, force: true do |t|