Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Add embedded associations in ActiveModel#43399

Open
mansakondo wants to merge 1 commit into
rails:mainrails/rails:mainfrom
mansakondo:embedded-associationsmansakondo/rails:embedded-associationsCopy head branch name to clipboard
Open

Add embedded associations in ActiveModel#43399
mansakondo wants to merge 1 commit into
rails:mainrails/rails:mainfrom
mansakondo:embedded-associationsmansakondo/rails:embedded-associationsCopy head branch name to clipboard

Conversation

@mansakondo

@mansakondo mansakondo commented Oct 7, 2021

Copy link
Copy Markdown
Contributor

Relational databases are very powerful. Their power comes from their ability to...

  • Preserve data integrity with a predefined schema.
  • Make complex relationships through joins.

But sometimes, we can stumble accross data that don't fit in the relational model. We call this kind of data: semi-structured data. When this happens, the things that makes relational databases powerful are the things that gets in our way, and complicate our model instead of simplifying it.

That's why document databases exist, to model and store semi structured data. However, if we choose to use a document database, we'll loose all the power of using a relational database.

Luckily for us, relational databases like Postgres and MySQL now has good JSON support. So most of us won't need to use a document database like MongoDB, as it would be overkill. Most of the time, we only need to denormalize some parts of our model. So it makes more sense to use simple JSON columns for those, instead of going all-in, and dump your beloved relational database for MongoDB.

Currently in Rails, we can have full control over how our JSON data is stored and retrieved from the database, by using the Attributes API to serialize and deserialize our data.

That's what this extension does, in order to provide a convenient way to model semi-structured data in a Rails application.

Usage

Let's say that we need to store books in our database. We might want to "embed" data such as parts, chapters and sections without creating additional tables. By doing so, we can retrieve all the embedded data of a book in a single read operation, instead of performing expensive multi-table joins.

We can then model our data this way:

class Book < ApplicationRecord
  include ActiveModel::Embedding::Associations

  embeds_many :parts
end

class Book::Part
  include ActiveModel::Embedding::Document

  attribute :title, :string

  embeds_many :chapters
end

class Book::Part::Chapter
  include ActiveModel::Embedding::Document

  attribute :title, :string

  embeds_many :sections
end

class Book::Part::Chapter::Section
  include ActiveModel::Embedding::Document

  attribute :title, :string
  attribute :content, :string
end

And display it like this (with nested attributes support out-of-the-box):

# app/views/books/_form.html.erb
<%= form_with model: @book do |book_form| %>
  <%= book_form.fields_for :parts do |part_fields| %>

    <%= part_fields.label :title %>
    <%= part_fields.text_field :title %>

    <%= part_fields.fields_for :chapters do |chapter_fields| %>
      <%= chapter_fields.label :title %>
      <%= chapter_fields.text_field :title %>

      <%= chapter_fields.fields_for :sections do |section_fields| %>
        <%= section_fields.label :title %>
        <%= section_fields.text_field :title %>
        <%= section_fields.text_area :content %>
      <% end %>
    <% end %>
  <% end %>

  <%= book_form.submit %>
<% end %>

Validations

class SomeModel < ApplicationRecord
  include ActiveModel::Embedding::Associations

  embeds_many :things

  validates_associated :things
end

class Thing
  include ActiveModel::Embedding::Document

  embeds_many :other_things

  validates_associated :other_things
end

class OtherThing
  include ActiveModel::Embedding::Document

  attribute :some_attribute, :string

  validates :some_attribute, presence: true
end
things = Array.new(3) { Thing.new(other_things: Array.new(3) { OtherThing.new } }
record = SomeModel.new things: things

record.valid? # => false
record.save # => false

record.things.other_things = Array.new(3) { OtherThing.new(some_attribute: "present") }

record.valid? # => true
record.save # => true

Custom collections

class SomeCollection
  include ActiveModel::Embedding::Collecting
end

class Thing
  # ...
end

class SomeModel
  include ActiveModel::Embedding::Document

  embeds_many :things, collection: "SomeCollection"
end

some_model = SomeModel.new things: Array.new(3) { Thing.new }
some_model.things.class
# => SomeCollection

Custom types

# config/initializers/types.rb
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, cast_type: :some_type
  embeds_many :other_things, cast_type: SomeOtherType.new(context: self)
end

some_model = SomeModel.new(
  things: Array.new(3) { Thing.new },
  other_things: Array.new(3) { Thing.new }
)

some_model.things.first.cast_type
# => SomeType
some_model.other_things.first.cast_type
# => SomeOtherType
some_model.other_things.first.context
# => SomeModel

Associations

embeds_many

Maps a JSON array to a collection.

Options:

  • :class_name: Specify the class of the documents in the collection. Inferred by default.
  • :collection: Specify a custom collection class which includes ActiveModel:: Embedding::Collecting (ActiveModel::Embedding::Collection by default).
  • :cast_type: Specify a custom type that should be used to cast the documents in the collection. (the :class_name is ignored if this option is present.)

embed_one

Maps a JSON object to a document.

Options:

  • :class_name: Same as above.
  • :cast_type: Same as above.

⚠️ Warning

Embedded associations should only be used if you're sure that the data you want to embed is encapsulated. Which means, that embedded associations should only be accessed through the parent, and not from the outside. Thus, this should only be used if performing joins isn't a viable option.

Read this section from the original README (and this article) for more insights on the use cases of this feature.

Concepts

Document

A JSON object mapped to a PORO which includes ActiveModel::Embedding::Document. Usually part of a collection.

Collection

A JSON array mapped to an ActiveModel::Embedding::Collection (or any class that includes ActiveModel::Embedding::Collecting). Stores collections of documents.

Embedded associations

Models structural hierarchies in semi-structured data, by "embedding" the content of children directly in the parent, instead of using references like foreign keys. See Embedded Data Models from MongoDB's docs.

Semi-structured data

Data that don't fit in the relational model.

Semi-structured data is a form of structured data that does not obey the tabular structure of data models associated with relational databases or other forms of data tables, but nonetheless contains tags or other markers to separate semantic elements and enforce hierarchies of records and fields within the data. Therefore, it is also known as self-describing structure. - Wikipedia

@mansakondo mansakondo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design

ActiveModel::Type::Document

A polymorphic cast type (registered as :document). Maps JSON objects to POROs that includes ActiveModel::Embedding::Document. Provides support for defining collections

ActiveModel::Embedding::Associations

API for defining embedded associations. Uses the Attributes API with the :document type.

ActiveModel::Embedding::Document

A mixin which includes everything needed to work with the :document type (ActiveModel::Model, ActiveModel::Attributes, ActiveModel::Serializers::JSON, ActiveModel::Embedding::Associations). Provides an id attribute and implements methods like #persisted? and #save to emulate persistence.

ActiveModel::Embedding::Collecting

A mixin which provides capabailities similar to ActiveRecord collection proxies. Provides support for nested attributes.

ActiveModel::Embedding::Collection

Default collection class. Includes ActiveModel::Embedding::Collecting.

Fix typo

Handle case when `value` is already type casted

Rename `#find` to `#find_by_id`
@mansakondo
mansakondo force-pushed the embedded-associations branch from 1ae629e to b480a5b Compare October 14, 2021 20:52
@rafaelfranca

Copy link
Copy Markdown
Member

Thank you so much for the pull request and all the work you put on this. I have someone in my team working in the same idea, but with a very different API and implementation, so it is very likely that I'll not merge this PR.

I'll keep it open though, in case our implementation end up not working as we like.

I'll update this PR with more information.

Thank you again.

@mansakondo

Copy link
Copy Markdown
Contributor Author

Ok thanks for the reply.

@rafaelfranca

Copy link
Copy Markdown
Member

I think there are more feature here than what I was planning to implement like embedding in JSON columns. As we have the API we want I'll probaly ask here if you are interested in porting this implementation to use the new API.

@mansakondo

Copy link
Copy Markdown
Contributor Author

Yeah sure with pleasure.

@rails-bot

rails-bot Bot commented Jan 26, 2022

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.
Thank you for your contributions.

@rails-bot rails-bot Bot added the stale label Jan 26, 2022
@rails-bot rails-bot Bot closed this Feb 2, 2022
@mansakondo

Copy link
Copy Markdown
Contributor Author

Hi @rafaelfranca. Since this PR was closed, I wanted to know if you and your team are still working on a way to model semi-structured data in Rails ?

@rafaelfranca rafaelfranca reopened this Feb 10, 2022
@rails-bot rails-bot Bot removed the stale label Feb 10, 2022
@rafaelfranca

Copy link
Copy Markdown
Member

Yes! The PRs are open right now #44380 #44324

@mansakondo

Copy link
Copy Markdown
Contributor Author

Ok thanks for letting me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.