Skip to content

ActiveRecord document storage using postgres JSON column - persistent, schemaless virtual attributes, embedded models, oh my!

License

Notifications You must be signed in to change notification settings

dfurber/ar_doc_store

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ArDocStore

ArDocStore (pronounced R-Dock-Store) is a gem helps you implement a document store in ActiveRecord models that have access to the PostgreSQL JSON data type. You add a json column to your table called "data", include the ArDocStore::Model module, and then add schema-less attributes that get stored in the data column. With Ransack, these attributes are searchable as if they were real columns.

There is also support for embedding models within models. These embedded models can be accessed from Rails form builders using fields_for.

The use case is primarily when you have a rapidly evolving schema with scores of attributes, where you would like to use composition but don't want to have a bajillion tables for data that fits squarely under the umbrella of its parent entity. For example, a building has entrances and restrooms, and the entrance and restroom each have a door and a route to the door. You could have active record models for the doors, routes, restrooms, and entrances, but you know that you only ever need to access the bathroom door from within the context of the building's bathroom. You don't need to access all the doors as their own entities because their existence is entirely contingent upon the entity within which they are contained. ArDocStore makes this easy.

Learn more about the JSON column in Postgres and using it as a document store:

Installation

Add this line to your application's Gemfile:

gem 'ar_doc_store'

And then execute:

$ bundle

Or install it yourself as:

$ gem install ar_doc_store

Usage

The first thing you need to do is create a migration that adds a column called "data" of type ":json" or ":jsonb" to the table you want to turn into a document:

change_table :buildings do |t|
  t.jsonb :data
end

Then in the model file:

class Building < ActiveRecord::Base
  include ArDocStore::Model
end

You don't have to use "data" as the column name. Use something else like do:

class Building < ActiveRecord::Base
  include ArDocStore::Model
  self.json_column = :json_data
end

Attributes

Now there are several ways to play but all boil down to one method on the model:

class Building < ActiveRecord::Base
  include ArDocStore::Model

  json_attribute :name, :string
  json_attribute :width, :float
  json_attribute :height, as: :float # the :as is optional but if you like the consistency with SimpleForm
  json_attribute :storeys, :integer
  json_attribute :finished, :boolean
  json_attribute :construction_type, :enumeration,
            values: %w{wood plaster mud brick},
            multiple: true,
            strict: true
end

Now I can do some fun things:

building = Building.new name: 'Big Building', storeys: 45, construction_type: %w{mud brick}, finished: true
building.width = 42.5
building.height = 32.259
building.finished? # => true
building.save

You noticed the enumeration type on the construction_type takes an array. That's because I specified multiple: true. Let's say you can't have a mud and a brick building at the same time. Let's take off the multiple: true and see how it behaves:

class Building ...
  json_attribute :construction_type,
            :enumeration,
            values: %w{wood plaster mud brick},
            multiple: true,
            strict: true
end
building = Building.new
building.construction_type = 'wood'
building.construction_type  # => 'wood'

The strict: true option makes the enumeration strict, meaning only the choices in the enumeration are allowed. Maybe that should be the default choice, but the project in which this took shape had most of the enumerations with an "other" field. We took care of it with this and a custom SimpleForm input.

Embedding Models

Let's say that a building has a door. The building is not the only thing in our world that has a door, so we want to be able to embed doors in other models, such as skating rinks, comfort stations, and gyms. First let's make an embeddable model called Door:

class Door
  include ArDocStore::EmbeddableModel

  enumerates :door_type,
             multiple: true,
             values: %w{single double french sliding push pull}
  json_attribute :open_handle,  
            as: :enumeration,
            multiple: true,
            values: %w{push pull plate knob handle}
  json_attribute :close_handle,
            as: :enumeration,
            multiple: true,
            values: %w{push pull plate knob handle}
  json_attribute :clear_distance, as: :integer
  json_attribute :opening_force, as: :integer
  json_attribute :clear_space, as: :integer
end

Now let's put a Door on a Building:

class Building ...
  embeds_one :door
  embeds_one :secret_door, class_name: 'Door'
end

Now let's make a building with a door:

building = Building.new
building.build_door
building.door.clear_distance = 30
building.door.opening_force = 20
building.door.open_handle = %w{pull knob}
building.build_secret_door
building.secret_door.clear_distance = 3
building.save

We probably have a form for the building, so here goes:

# in the controller:
def new
  @building = Building.new
end

def resource_params
  params.require(:building).permit :name, :height, door_attributes: [:door_type]
end

# in the view, with a bonus plug for Slim templates:
= simple_form_for @building do |form|
  = form.input :name, as: :string
  = form.input :height, as: :float
  = form.object.ensure_door
  = form.fields_for :door do |door_form|
    = door_form.input :id # <-- because fields_for won't output the hidden id field for us - not a bad thing to put it where you want it instead of where they put it
    = door_form.input :door_type, as: :check_boxes, collection: Door.door_type_choices

What's to see here? Notice that I was able to "ensure_door", which means that if there already is a door, it keeps that one, otherwise it builds a new door object. Also on the door_type input, notice the collection comes from a door_type_choices that came from the enumeration attribute. Also notice that the embedded model conforms to the API for accepts_nested_attributes, for both assignment and validation, only you don't have to specify it because the _attributes= method comes for free.

You can also embeds_many. It works the same way:

class Room
  include ArDocStore::EmbeddableModel
  json_attribute :length, as: :float
  json_attribute :width, as: :float
  json_attribute :height, as: :float
  enumerates :light_switch_type, %w{flip knob switchplate clapper}
end

class Building ...
  embeds_many :rooms
  embeds_one :foyer, class_name: 'Room'
end

building = Building.new
building.build_room # a bit different from active record here, I with that has_many used the has_one API for build_association.
building.ensure_room # if there are no rooms, then add one to the rooms collection, otherwise do nothing
building.rooms << Room.new(width: 12, height: 18, length: 20, light_switch_type: 'flip')
building.save  # saves the room

Searching Models

In my dreams, I could type: Building.where(rooms: { length: 20 }). If somebody can guide me toward that dream, please do. In the meantime, there is Ransack. When you call "attribute" on a Model (not yet an embedded model, sorry) and you've got the Ransack gem installed, then you will get a free custom ransacker. So you can still do this, and it's pretty awesome:

Building.ransack name_cont: 'tall', height_lteq: 20

Custom attribute types

ArDocStore comes with several basic attribute types: array, boolean, enumeration, float, integer, and string. The implementation and extension points are inspired by SimpleForm. You can either create a new attribute type or overwrite an existing one. Forewarned is forestalled, maybe: as with SimpleForm, the custom input system is way easier to use if you were the one who built it, and it's still a little raw.

All attributes inherit from AttributeTypes::BaseAttribute and custom attributes are implemented by overriding that class's methods. Let's start with the implementation of :integer :

class IntegerAttribute < ArDocStore::AttributeTypes::BaseAttribute
  def conversion
    :to_i
  end

  def predicate
    'int'
  end

  def type
    :number
  end
end

The data is put into a JSON column, so we want to make sure we serialize it correctly. The conversion method is a symbol or a string. It is called by the dump and load (in BaseAttribute) methods and is sent to the data when it is written and read. This makes sure that the data doesn't go in a integer and come back as a string. The predicate method tells Postgres (via the ransacker) how to cast the JSON for searching.

Not all attribute types are that simple. Sometimes we have to put all the juice in the build method, and take care to define the getter and setter ourselves. In this example, we want to replace the boolean attribute with a similar boolean that can do Yes, No, and N/A, where N/A isn't simply nil but something the user has to choose. Here goes:

class BooleanAttribute < ArDocStore::AttributeTypes::BaseAttribute
  def build
    key = attribute.to_sym
    model.class_eval do
      store_accessor :data, key
      define_method "#{key}?".to_sym, -> { key == 1 }
      define_method "#{key}=".to_sym, -> (value) {
        res = nil
        res = 1 if value == 'true' || value == true || value == '1' || value == 1
        res = 0 if value == 'false' || value == false || value == '0' || value == 0
        res = -1 if value == '-1'
        write_store_attribute(:data, key, value)
      }
      add_ransacker(key, 'bool')
    end
  end
end

ArDocStore.mappings.merge! boolean: '::BooleanAttribute'

class BooleanInput < SimpleForm::Inputs::CollectionRadioButtonsInput
  def self.boolean_collection
    i18n_cache :boolean_collection do
      [ [I18n.t(:"simple_form.yes", default: 'Yes'), 1],
        [I18n.t(:"simple_form.no", default: 'No'), 0],
        [I18n.t(:"simple_form.na", default: 'N/A'), -1]]
    end
  end

  def input_type
    :radio_buttons
  end

end

What if I need different conversions for reading and writing?

The :datetime attribute requires the data be cast to a string when written, but cast to a Time object when read. This overrides the dump and load methods instead. Here is the implementation:

class DatetimeAttribute < ArDocStore::AttributeTypes::BaseAttribute
  def type
    :datetime
  end

  def dump
    :to_time
  end

  def load
    :to_s
  end
end

Roadmap

  1. Ransackers for embedded model attributes. Deliberately left out because when you have nested JSON, Postgres searches on any keys within the JSON become 10x slower.
  2. It would be nice if you could use the AR fluent query API on stored attributes, where is knows to replace, say, "name" with "data->>name" but I don't see how to do that, and Ransack provides a nice enough wrapper around ARel to get the job done another way.
  3. Migration support to rename attributes, remove attributes. (Right now just query the documents you need to change, iterate and update the JSON directly.)

Contributing

  1. Fork it ( https://github.com/dfurber/ar_doc_store/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

About

ActiveRecord document storage using postgres JSON column - persistent, schemaless virtual attributes, embedded models, oh my!

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages