Coder Social home page Coder Social logo

store_model's Introduction

StoreModel Gem Version

StoreModel gem allows you to wrap JSON-backed DB columns with ActiveModel-like classes.

  • 💪 Powered with Attributes API. You can use a number of familiar types or write your own
  • 🔧 Works like ActiveModel. Validations, enums and nested attributes work very similar to APIs provided by Rails
  • 1️⃣ Follows single responsibility principle. Keep the logic around the data stored in a JSON column separated from the model
  • 👷‍♂️ Born in production.
class Configuration
  include StoreModel::Model

  attribute :model, :string
  enum :status, %i[active archived], default: :active

  validates :model, :status, presence: true
end

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type
end

Why should I wrap my JSON columns?

Imagine that you have a model Product with a jsonb column called configuration. This is how you likely gonna work with this column:

product = Product.find(params[:id])
if product.configuration["model"] == "spaceship"
  product.configuration["color"] = "red"
end
product.save

This approach works fine when you don't have a lot of keys with logic around them and just read the data. However, when you start working with that data more intensively–you may find the code a bit verbose and error-prone.

For instance, try to find a way to validate :model value to be required. Despite of the fact, that you'll have to write this validation by hand, it violates the single-responsibility principle: why parent model (Product) should know about the logic related to a child (Configuration)?

📖 Read more about the motivation in the Wrapping JSON-based ActiveRecord attributes with classes post

Getting started

Start with creating a class for representing the hash as an object:

class Configuration
  include StoreModel::Model

  attribute :model, :string
  attribute :color, :string
end

Attributes should be defined using Rails Attributes API. There is a number of types available out of the box, and you can always extend the type system.

Register the field in the ActiveRecord model class:

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type
end

When you're done, the initial snippet could be rewritten in the following way:

product = Product.find(params[:id])
if product.configuration.model == "spaceship"
  product.configuration.color = "red"
end
product.save

Usage note: Rails and assigning Arrays/Hashes to records

  • Assigned attributes must be a String, Hash, Array of Hashes, or StoreModel. For example, if the attributes are coming from a controller, be sure to convert any ActionController::Parameters as needed.
  • Any changes made to a StoreModel instance requires the attribute be flagged as dirty, either by reassignment (self.my_stored_models = my_stored_models.map(&:as_json)) or by will_change! (self.my_stored_models_will_change!)
  • Mixing StoreModel::NestedAttributes into your model will allow you to use accepts_nested_attributes_for in the same way as ActiveRecord.
class Supplier < ActiveRecord::Base
  include StoreModel::NestedAttributes

  has_many :bicycles, dependent: :destroy

  attribute :products, Product.to_array_type

  accepts_nested_attributes_for :bicycles, :products, allow_destroy: true
end

This will allow the form builders to work their magic:

<%= form_with model: @supplier do |form| %>
  <%= form.fields_for :products do |product_fields| %>
    <%= product_fields.text_field :name %>
  <% end %>
<% end %>

Resulting in:

<input type="text" name="supplier[products_attributes][0][name]" id="supplier_products_attributes_0_name">

In the controller:

def create
  @supplier = Supplier.new(supplier_params)
  @supplier.save
end

private

def supplier_params
  params.require(:supplier).permit(products_attributes: [:name])
end

Documentation

  1. Installation
  2. StoreModel::Model API:
  1. Array of stored models
  2. One of
  3. Alternatives
  4. Defining custom types
  5. Disabling Parent Tracking

Credits

Initially sponsored by Evil Martians.

License

The gem is available as open source under the terms of the MIT License.

store_model's People

Contributors

agiveygives avatar akinomaeni avatar alexeevit avatar bf4 avatar blaze182 avatar codemogul avatar danielvdao avatar dmitrytsepelev avatar dush avatar f-mer avatar holywalley avatar iarie avatar jas14 avatar jonspalmer avatar mateusnava avatar morgangrubb avatar nikokon avatar osanay avatar palkan avatar paneq avatar petergoldstein avatar raphox avatar rudskikhivan avatar scottwater avatar skryukov avatar supernich avatar thomasklemm avatar timhwang21 avatar zokioki avatar zooip avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

store_model's Issues

Time zone aware date and time attributes

ActiveRecord decorates :datetime and :time attributes with a TimeZoneConverter type.
This ensures the time is converted automatically into the current Time.zone.

I'm currently working around this issue by explicitly decorating the type.

class Todo
  include StoreModel::Model

  attribute :due_at, ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter.new(ActiveModel::Type::Time.new)
end

Any thoughts on if it is desirable to have the same behaviour as ActiveRecord for dates and times?
This could be also made configurable through something like StoreModel.config.time_zone_aware_attributes = true and StoreModel.config.time_zone_aware_types = [:datetime, :time].

[Question] How would I get the name of the attribute a store_model belongs to?

I have a class namedShop::OptinSettings with nested store_models

class Shop::OptinSettings
  include StoreModel::Model

  attribute :terms_of_service, :string, default: I18n.t("shop.optin_settings.terms_of_service")
  attribute :modal, Shop::OptinSettings::Details.to_type, default: Shop::OptinSettings::Details.new
  attribute :page, Shop::OptinSettings::Details.to_type, default: Shop::OptinSettings::Details.new
end

In my class Shop::OptinSettings::Details I aim to dynamically set a default value dependent on the attribute having the name modal or page.

class Shop::OptinSettings::Details
  include StoreModel::Model

  def self.parent_attribute
    ...
  end

  attribute :body, :string, default: I18n.t("shop.optin_settings.#{parent_attribute}.title")
  attribute :title, :string, default: I18n.t("shop.optin_settings.#{parent_attribute}.title")
end

Is there a method that would allow me to return this value?

Merging errors is supressing errors based on order of validations included

Hi,

I have encountered an issue with errors being supressed from the main model. I am using the following versions.

  • Rails: 6.0.3.4

  • store_model 0.8.1

  • ruby version - 2.7.2

Here is my code -

class NotificationConfiguration
  include StoreModel::Model
  EMAIL_REGEXP = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i.freeze
  validates :secondary_email, format: { with: EMAIL_REGEXP }, allow_blank: true
end

class User < ApplicationRecord
  attribute :configuration, BackupConfiguration.to_type

  validates_presence_of :name
  validates :configuration, store_model: { merge_errors: true }
  before_validation :build_notification_configuration, on: [:create]
end

user = User.create(name: 'test')
user.configuration.secondary_email = 'example'
user.name = ''
user.valid?
user.errors # => shows only 1 error instead of 2. 

If we modify the order of validations to maintain validates :configuration ... before validates_presence_of :name, it displays 2 errors instead of 1.

Unable to Save with Array of StoreModel

I have similar setup as below documentation to store json array.

https://github.com/DmitryTsepelev/store_model/blob/master/docs/array_of_stored_models.md

I'm getting below error when I tried to save the model that uses array of store model.

class Product < Application Record
  attribute :variants, Variant.to_array_type
end

class Variant
  include StoreModel::Model

  attribute :name, :string
end

product = Product.new
product.variants = [{ name: 'foo' }]
product.variants # [#<Variant name: 'foo'>]
product.save

> NoMethodError: undefined method `to_hash' for nil:NilClass
from /Users/taufek/.asdf/installs/ruby/2.7.1/lib/ruby/gems/2.7.0/gems/activemodel-6.0.3.1/lib/active_model/attributes.rb:97:in `attributes'


Predicate method for boolean

Hi! Why is a predicate method not defined for boolean attributes?
Are there any obstacles here?

class Settings
  include StoreModel::Model

  attribute :something_enabled, :boolean, default: false
end

record.settings.something_enabled? #=> undefined method ((

merge_array_errors documentation correction

The array example here
https://github.com/DmitryTsepelev/store_model/blob/master/docs/validations.md

should use merge_array_errors vs merge_array:

class Product < ApplicationRecord
  attribute :configurations, Configuration.to_array_type

  validates :configurations, store_model: { merge_array_errors: true }    # <--- TWEAK
end

product = Product.new
product.configurations << Configuration.new
puts product.valid? # => false
puts product.errors.messages # => { color: ["[0] Color can't be blank"] }

FactoryBot::InvalidFactoryError: When assigning attributes, you must pass a hash as an argument

Thanks for the great gem! First time user.

Implementation works well except when I run my specs I get the error noted above after switching my attributes from :jsonb to store_model. Nothing complex about the app/implementation.

Any thoughts on how to best handle?

class WorkerOption < ApplicationRecord
  # attribute :options, :jsonb, default: {}
  # attribute :data, :jsonb, default: {}
  attribute :data, WoCases.to_type
  attribute :options, Options.to_type
end

schema.rb

  create_table "worker_options", id: :serial, force: :cascade do |t|
    t.jsonb "options", default: "{}", null: false
    t.jsonb "data", default: "{}", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

running rspec

An error occurred in a `before(:suite)` hook.
Failure/Error: FactoryBot.lint

FactoryBot::InvalidFactoryError:
  The following factories are invalid:

  * worker_option - When assigning attributes, you must pass a hash as an argument. (ArgumentError)
# /Users/jrpowell/.gem/ruby/2.6.5/gems/factory_bot-5.1.1/lib/factory_bot/linter.rb:13:in `lint!'
# /Users/jrpowell/.gem/ruby/2.6.5/gems/factory_bot-5.1.1/lib/factory_bot.rb:74:in `lint'
# ./spec/support/init/factory_bot_rails.rb:21:in `block (2 levels) in <top (required)>'

/spec/support/init/factory_bot_rails.rb

RSpec.configure do |config|
  # Allows the use of "create :user" as a shorthand for "FactoryBot.create :user".
  config.include FactoryBot::Syntax::Methods

  config.before(:suite) do
    # This forces FactoryBot to recognize changes in factories.  It's useful for process forking with spring & spork.
    FactoryBot.reload

    begin
      # Prep DatabaseCleaner because the call to FactoryBot.lint below will leave models in the database.
      DatabaseCleaner.start
      # Ensure factories generate valid objects
      FactoryBot.lint
    ensure
      # Clean the database because FactoryBot.lint left models in database.
      DatabaseCleaner.clean
    end
  end
end

factories/worker_option.rb

FactoryBot.define do
  factory :worker_option, class: 'WorkerOption' do
    data { { "uri" => 'http://localhost:3000/api/v1/work_flows/1/case_machines/1/cases' } }

    trait :valid_options do
      options { { "id" => 666, "number" => "NUMBER", "receipt_number" => "RECEIPT_NUMBER" } }
    end
  end
end

simple_form incompatiblity

Now that store_model respond to a method named type_for_attribute, it is incompatible with simple_form gem because of these two lines as you can see here.

I had to copy and paste has_attribute? implementation from
ActiveRecord::AttributeMethods
module to make it work again with simple_form.

def has_attribute?(attr_name)
  @attributes.key?(attr_name.to_s)
end

@DmitryTsepelev Can you include the above method on the StoreModel base class, please?

Dynamic method chaining possible?

Is it possible to method chain dynamically? Something like the following?

def self.send_chain(methods)
  methods.inject(self, :send)
end

Where I could:

> Article.send_chain(["with_author", "pending_review"])

Saving Unknown Attributes

Is it possible to have store_model to store unknown attributes? The .unknown_attributes method is extremely useful however upon saving the record the unknown attributes are lost. It would be useful to save these attributes so they can later be added to the model and their value would then be accessible.

Attribute string as array not Working

Hi @DmitryTsepelev ,

the gem work with Rails Attributes API, but with type array or range it does not work (). (e.g. attribute :my_int_array, :integer, array: true you get error ArgumentError: unknown keyword: array).

I need an attribute with array of strings e.g.:

class Configuration
  include StoreModel::Model

  attribute :colors, :string, array: true, default: []
end

get error:

ArgumentError: unknown keyword: array

It will be great to have this function. Do you planning to add custom type for it?

ArrayOfStringsType and ArrayOfIntegersType DEPRECATION WARNING

Got this warning during rake task implementation.

DEPRECATION WARNING: Initialization autoloaded the constants ArrayOfStringsType and ArrayOfIntegersType.

Being able to do this is deprecated. Autoloading during initialization is going
to be an error condition in future versions of Rails.

Reloading does not reboot the application, and therefore code executed during
initialization does not run again. So, if you reload ArrayOfStringsType, for example,
the expected changes won't be reflected in that stale Class object.

These autoloaded constants have been unloaded.

Please, check the "Autoloading and Reloading Constants" guide for solutions.
class ArrayOfIntegersType < ActiveRecord::Type::Value
  def type
    :array_of_integers
  end

  def cast(values)
    return if values.blank?
    values.map(&:to_i)
  end
end
class ArrayOfStringsType < ActiveRecord::Type::Value
  def type
    :array_of_strings
  end
end

to_array_type giving error with jRuby

I am getting below error while saving the object into the database having store model attribute of to_array_type

Java::JavaLang::ClassCastException (class org.jruby.RubyNil cannot be cast to class org.jruby.RubyHash (org.jruby.RubyNil and org.jruby.RubyHash are in unnamed module of loader 'bootstrap'))

Set to not raise_cast_error

Hi there! First off thanks for the gem, secondly this is a feature request which I'm happy to work at.

Our use case has a client, and this client can turn on certain customizations. If customization is turned on then all items in that client can set there own individual values for that customization.

For example, let's say a client has the customization foo and bar turned on an item inside of that client will have a jsonb data structure of

{
   foo: {
   },
  bar: {
  }
}

And another client only has the customization type foobar turned on the jsonb data for an item will look like

{
   foobar: {
   }
}

We have about maybe 40 of these customizations (and this could grow dynamically), which means that creating StoreModel class for each of the customizations is not an ideal option. I'm handling this currently by using a mix of method_missing and the unknown_attributes method in your gem

class Configuration
  include StoreModel::Model

  def method_missing(m, *args, &block)
    unknown_attributes[m.to_s]
  end

end

This works great for reading data, but the problem comes when I'm trying to update or save data. From what I can tell, b/c there are no attributes assigned to the Configuration class, when it tries to cast the values it just raises the raise_cast_error(value) and does not accept the params being passed. I want to add some sort of override which will just push whatever data I give to it and ignore the validation in the json_type file.

Let me know if that all makes sense, and let me know if this is something you would be willing to add to the gem. I'm happy to make a PR for it.

Make attributes deserialization "relaxed"

Hi!

So I was playing around with this gem, and tried the following:

I have defined some JSON-backed model, let's say it was:

class Configuration
  include StoreModel::Model

  attribute :model, :string
end

Then I have created some record in my DB with filled model attribute.
Then I have renamed that model attribute to something_else.

And form this point if I load my stored record from DB, and trying to access JSON-backed model - I'm getting an error: ActiveModel::UnknownAttributeError.

So I think it's not very flexible. This makes it harder to change that JSON-schema over time.
I think that we could benefit from using same approach as mongoid uses here:
if JSON has some extra attributes - no errors raised, there's no such attribute, so just "ignore" it, but, that data is still accessible from attributes hash.

What do you think?

Nested Models do not persist on save

If I have a nested model setup like so:

class Story
  attribute :primary_visuals, PrimaryVisuals.to_type
end

class PrimaryVisuals
  include StoreModel::Model

  attribute :primary_image, Image.to_type
  attribute :primary_video, Video.to_type
end

class Image
  include StoreModel::Model

  attribute :slug, :string
  attribute :guid, :string
  attribute :long_caption, :string
  attribute :short_caption, :string
end

I'd expect to be able to do something like:

story = Story.new
story.primary_visuals = PrimaryVisuals.new
story.primary_visuals.primary_image = Image.new(slug: "test")
story.save!
story.primary_visuals.primary_image # =>  #<Image slug: test...

But instead I get nil on the primary_image

Is this expected behavior or have I tripped over a bug?

Validation fails when attribute has .to_array_type

Hi @DmitryTsepelev

I think I've found one more issue.

After updating to 0.3.1 and trying to save model with attribute .to_array_type

NoMethodError - undefined method `invalid?' for #<Array:0x00007f97219194d8>:

from

After adding:

elsif value.respond_to?(:invalid) && value.invalid?

(value is Array)

Seems to be fine.

Let me know if you need more info.

Thanks!

Model with "audited" gem raise an error on save

I'm getting below error when using store_model with audited gem in a model.

class Product < Application Record
  audited
  attribute :info, Info.to_type
end

class Info
  include StoreModel::Model

  attribute :name, :string
end

product = Product.new
product.info = { name: 'foo' }
product.save

> NoMethodError: undefined method `to_hash' for nil:NilClass
from /Users/taufek/.asdf/installs/ruby/2.7.1/lib/ruby/gems/2.7.0/gems/activemodel-6.0.3.1/lib/active_model/attributes.rb:97:in `attributes'

Nested Attributes Array

Hello,

I am trying to create a form where my models can be created in a nested fashion as well as multiple at once.

Here is my code:

class Order < ApplicationRecord
  attribute :treatment, Treatment.to_type, default: Treatment.default
end
class Treatment
  include StoreModel::Model

  MATERIALS = %i[zirkon ceramik metal]
  COLORS = %i[A2 A1]

  enum :material, MATERIALS, default: :zirkon
  enum :color, COLORS, default: :A2

  attribute :teeth, Teeth.to_array_type, default: []

  accepts_nested_attributes_for :teeth

  # https://www.dentforme.de/service/hkp_verstehen/kuerzel_behandlungsplanung/
  def self.default
    new.tap do |treatment|
      treatment.teeth << Teeth.new(id: '18')
      treatment.teeth << Teeth.new(id: '17')
      treatment.teeth << Teeth.new(id: '16')
    end
  end
end
class Teeth
  include StoreModel::Model

  APPROACHES = %i[NONE PKM]

  attribute :id, :string
  enum :approach, APPROACHES, default: :NONE
end
= form_for @order do |f|
  - if @order.errors.any?
    #error_explanation
      %h2= "#{pluralize(@order.errors.count, "error")} prohibited this order from being saved:"
      %ul
        - @order.errors.full_messages.each do |message|
          %li= message

  .field
    = f.label :treatment
    = f.fields_for :treatment do |tf|
      .field
        = tf.label :material
        = tf.select :material, Treatment::MATERIALS.map { |w| [w.to_s.humanize, w] }
      .field
        = tf.label :color
        = tf.select :color, Treatment::COLORS.map { |w| [w.to_s.humanize, w] }
      .field
        = tf.label :teeth
        = tf.fields_for :teeth do |ttf|
          - @order.treatment.teeth.each do |teeth|
            = ttf.fields_for teeth.id, teeth do |tttf| 
              = tttf.label :approach
              = tttf.select :approach, Teeth::APPROACHES.map { |w| [w.to_s, w] }

With this setup, I get the following error:

StoreModel::Types::CastError Exception: failed casting {"18"=>{"approach"=>"NONE"}, "17"=>{"approach"=>"PKM"}, "16"=>{"approach"=>"NONE"}}, only String or Array instances are allowed

I feel that my form does feel very nice, as I have to iterate over the teeth instances. But otherwise it does not work either, as the fields for helper does not detect the array.

Define scopes for array type

I guess it's a feature request, so let me know if I can help in any way:

Is it possible to define ActiveRecord-like scopes when using #to_array_type? For example I have this models:

class Product < ApplicationRecord
  attribute :properties, Property.to_array_type
end

class Property
  include StoreModel::Model

  attribute :key, :string
  attribute :value, :string
  attribute :locale, :string

  scope :hidden, ->{ select { |prop| prop.key.start_with?("_") } }
  scope :english, locale: "en"
end

Now when I want every property where the key is beginning with an underscore (it's a convention we use) it would be very handy to define them like this, and access them like Product.first.properties.hidden.english.

And it would be more awesome if you could use array operations like intersection, uniq, addition, substraction... and still be able to use this scopes, for example:

props1 = Product.first.properties
props2 = Product.last.properties

(props1 + props2).uniq.hidden.english

But I'm not sure if this is a good idea.

Access to class constants

Hi!
How I can get access to class constants?
For example I have:
promo.rb

class Promo < ApplicationRecord
  USER_PERMISSIONS = %w[update_profile upload_receipt].freeze

  attribute :settings, PromoSettings.to_type, default: {}
end

and promo_settings.rb

class PromoSettings
  include StoreModel::Model

  attribute :user_permissions, :array_of_strings, default: []

  validates :user_permissions,
    inclusion: {
      in: ::Promo::USER_PERMISSIONS
   }
end

returns uninitialized constant Promo::USER_PERMISSIONS
Is it ok? Can you help with this? tnx

Nested Attributes get not serialized correctly

Hello,

I was wondering, why during serialization my custom types don't get used to determine the json format:

class DateOrTimeType < ActiveRecord::Type::Value
  def type
    :json
  end

  def cast_value(value)
    case value
    when String
      decoded = ActiveSupport::JSON.decode(value) rescue nil
      build_from(decoded)
    when Hash
      build_from(value)
    when Date, Time, DateTime
      value
    end
  end

  def serialize(value)
    case value
    when Date
      ActiveSupport::JSON.encode(date: value)
    when Time, DateTime
      ActiveSupport::JSON.encode(datetime: value)
    else
      super
    end
  end

  def changed_in_place?(raw_old_value, new_value)
    cast_value(raw_old_value) != new_value
  end

  private

  def build_from(hash)
    if hash['date'].present?
      Date.parse(hash['date'])
    else
      Time.parse(hash['datetime'])
    end
  end

end
class Appointments::Schedule
  include StoreModel::Model

  attribute :from, DateOrTimeType.new
  attribute :to, :date_or_time

but during serialization, the default json serialisation of the given type is used.

Introduce #assign_preserving method

Which will preserve existing nested attributes :)
For me, it's quite a boilerplate code now, and it's useful, so I think it might be handy to be here out of the box.
Here's an example of something I already have:

class Site
  class Settings
    include StoreModel::Model

    NESTED_MODELS_TO_PRESERVE = %i[colours style].freeze

    attribute :twitter_handle, :string
    # ...

    attribute :colours, ColourScheme.to_type, default: -> { ColourScheme.new }
    attribute :style, Styling.to_type, default: -> { Styling.new }
    attribute :footer_pages, FooterPage.to_array_type, default: -> { [] }

    def assign_preserving(attrs)
      attrs = attrs.dup

      NESTED_MODELS_TO_PRESERVE.each do |model_key|
        next unless (nested_model = send(model_key))
        next unless (nested_attrs = attrs.delete(model_key))

        nested_model.assign_preserving(nested_attrs)
      end

      # NOTE: footer_pages are not being "preserved"
      #       and are overwritten if given, because it is an array
      assign_attributes(attrs)
    end
  end
end

And all the nested models have the same assign_preserving method, however their NESTED_MODELS_TO_PRESERVE might be blank.
Yeah, and I guess we might have something more sophisticated than a constant of all nested models.

Suggestion: Add prefix to enum types

Hi,

Great library, have migrated already out settings stores to this library and works great.

One thing that it would be great is for enum types to support prefix settings.

Currently to avoid conflicts for many enum settings it would be good to have this similar feature from enum rails core

eg.

enum :authentication_method, %i[email phone], default: :email
enum :notification_method, %i[email phone], default: :email

currently we get arround this by doing this,

enum :authentication_method, %i[authentication_method_email authentication_method_phone], default: :authentication_method_email
enum :notification_method, %i[notification_method_email notification_method_phone], default: :notification_method_email

however, would be cleaner to have

enum :authentication_method, %i[email phone], default: :email, _prefix: true
enum :notification_method, %i[email phone], default: :email, _prefix: true

Using key `attributes` with nested model causes infinite loop when attributes' value is hash with incorrect keys

When the attributes key is used with a hash, and the hash keys are unknown, an infinite loop is triggered in the unknown attributes handler. value_symbolized.except(:attributes) doesn't remove :attributes, and instead tries to remove the incorrect key inside the attributes hash.

class Configuration
  include StoreModel::Model

  attribute :color, :string
end

class ConfigurationWithNesting
  include StoreModel::Model
  attribute :configuration, Configuration.to_type
end


# Non-nested creation with incorrect attribute, with attributes: key
Configuration.new(attributes: { test: 123 })
# Errors correctly
# ActiveModel::UnknownAttributeError: unknown attribute 'test' for Configuration.

# Creation with correct attribute, with attributes: key
ConfigurationWithNesting.new(configuration: { attributes: { color: 123 } })
# Works correctly
# #<ConfigurationWithNesting configuration: #<Configuration:0x00005588e7084c40>>

# Creation with incorrect attribute, with attributes: key
ConfigurationWithNesting.new(configuration: { attributes: { test: 123 } })
# Infinite loop

After interrupting:

(pry) output error: Interrupt
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:52:in `_assign_attribute'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:43:in `block in _assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:42:in `each'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:42:in `_assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:35:in `assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:50:in `public_send'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:50:in `_assign_attribute'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:43:in `block in _assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:42:in `each'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:42:in `_assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attribute_assignment.rb:35:in `assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/model.rb:81:in `initialize'
/usr/local/bundle/gems/activemodel-6.0.3.4/lib/active_model/attributes.rb:79:in `initialize'
/usr/local/bundle/gems/store_model-0.8.0/lib/store_model/types/one.rb:65:in `new'
/usr/local/bundle/gems/store_model-0.8.0/lib/store_model/types/one.rb:65:in `model_instance'
/usr/local/bundle/gems/store_model-0.8.0/lib/store_model/types/one.rb:33:in `cast_value'
/usr/local/bundle/gems/store_model-0.8.0/lib/store_model/types/one_base.rb:60:in `handle_unknown_attribute'
/usr/local/bundle/gems/store_model-0.8.0/lib/store_model/types/one.rb:38:in `rescue in cast_value'

... loops endlessly after this

In both cases, value_symbolized.except(attribute) tries to remove :test, but in the nested case, :attributes should be removed instead. This seems to be because ActiveModel::UnknownAttributeError is not raised with :attributes.

ActiveModel::UnkownAttributeError in Rails 5.2

Hi there,

It looks like when an attribute is removed from a StoreModel::Model and a value exists for it in the JSON, Rails is raising an ActiveModel::UnkownAttributeError when deserialising from the database. This is happening to me on a nested model.

Possibly related to issue #22

Thanks!

Custom Error Strategy

Hi, I wondered if this behavior is intentional:

    lambda_strategy = lambda do |attribute, base_errors, _store_model_errors|
      _store_model_errors.each do |field, message|
        base_errors.add("#{attribute}.#{field}", message)
      end
    end

    validates :thing, store_model: lambda_strategy, on: :create

I like it. If it's intentional, I would be nice to get it documented

StoreModel::Model#== cannot properly compare enums

Here:

    def ==(other)
      return super unless other.is_a?(self.class)

      attributes.all? { |name, value| value == other.send(name) }
    end

The value in this case is going to be an integer, while the result of other.send(name) will be a string enum key.

Nested forms not working when using accepts_nested_attributes_for

Hi I am having issues when trying to update settings using fields_for nested attributes in forms. Below the the following way i am trying to implement it based on the comments in read in the PR

in view

 <%= form_for @tenant, url: settings_authentications_url(), method: :put do |f| %>
      <%= render 'shared/error_messages', object: @tenant.settings %>

      <%= f.fields_for :settings do |o| %>
          ...
      <% end %>
  <% end %>
class Tenant
  attribute :settings, TenantConfiguration.to_type
  validates :settings, store_model: true
  accepts_nested_attributes_for :settings
end
class TenantConfiguration
  include StoreModel::Model

  accepts_nested_attributes_for :settings
end

image

Custom validations

Hello, I have a a json column called data as follow. It has name, email, description.
How can I custom validate the email for example?
I'm successful with validating using presence, format and other validations

But how can I use a custom validator? So I want to validate the email using a 3rd party gem.

Something like this:

address = ValidEmail2::Address.new(value)
errors.add(key.to_s, "isn't a valid email") unless address.valid?

How can I access the value of this attribute and the errors?

Also, is it possible to pass a parameter to the configuration?

Thank you

Adding before_validation and and after_validation hooks

First of all, StoreModel is awesome and we've been using it more and more in my teams project. We've been using it mostly for config type of data but I'm experimenting with using it for polymorphic records stored on a ActiveRecord model. One thing I think would be helpful (but maybe out of scope of this gem) is implementing before_validation and and after_validation hooks.

This could be useful in a number of ways. We could use before_validation to auto set values of fields. E.g. if we want to set randomly generated token as a unique identifier.

class FooStoreModel 
  include StoreModel::Model
  attribute :token, :string

  before_validation :set_token, on: :create


  private
    def set_token
      token = SecureRandom.uuid
    end
end

Another way this could be useful is for replicating STI type of inheritance. The polymorphic typing is awesome and I think it should conform to the Rails way of polymorphic STI (using a type variable).

class AbstractStoreModel 
  include StoreModel::Model
  attribute :type, :string

  before_validation :set_type, on: :create

  private
    def set_type
      type= self.class.name
    end
end

class FooStoreModel < AbstractStoreModel
end

class BarStoreModel < AbstractStoreModel
  attribute :value, :string
end


class FooRecord < ActiveRecord::Base
  StoreModelsType = StoreModel.one_of do |json|
    case json['type']
    when 'FooStoreModel'
      FooStoreModel
    when 'BarStoreModel'
      BarStoreModel
    else
      raise InvalidTypeError
    end
  end

  attribute :data, StoreModelsType.to_array_type
end

This would allow something like

  record = FooRecord.create!
  record.data << BarStoreModel.new(value: 'baz')
  record.save

which would auto cast and infer types

Array of enums

Hi! We often have the situation where a model has an array of strings that needs to be validated to only include certain values, and we need some predicate methods to check if a model has a certain value inside this array:

class User < ApplicationRecord
  after_initialize { self.roles = Array.wrap(roles) }

  ROLES = %w[admin user reporter].freeze
  validate do
    errors.add(:roles, :invalid_roles) if (roles - ROLES).any?
  end

  ROLES.each do |role|
    define_method("#{role}?") do
      roles.include?(role)
    end
  end
end

And IMHO, that looks suspiciously like a StoreModel use case: An array of enums. Only difference would be that a wrong role would raise an exception instead of a validation error (but in my case this would be ok).

As far as I can tell, this isn't possible right now with StoreModel, am I right? Would it be a feature you would consider to include? I'd be happy to try for a PR if you could give me some hints how to implement a feature like this.

Connect store models with their parents

It would be nice to have something like #parent method, to get store model's parent object.

I just thought it might be useful. And I would definitely appreciate it in my project :)

It seems like it might be an additional step during object initialization. (and in more places)

I also think it is potentially related to #30, as it kind of "tightens" the connection between parent and store model.

How to model polymorphic types

I have a type X and X has one Y, but Y can be Y1 or Y2 or Y3, and each of them has different attributes, but they all subclass Y_base

Defaults are not set on new attributes

When I create a new attribute that has a default and validates its presence, when I save my model I get Validation failed: <attribute> cannot be blank. It should set the attribute to its default value

attribute :syncing, :boolean, default: false
validates :syncing, presence: true

Passing parameter to Configuration

Is it possible to pass a parameter to the configuration?
So I have a json column I'm validating, but the keys I'm validating depend on an enum
So instead of using multiple configurations for each key in the enum, I want to use just one configuration and pass the enum key to it, since all enums have the same validations logic, just different keys

[Question] Can attributes be accessed programatically?

Should it be possible to access the attribute details programatically as per a normal ActiveRecord class?

ie should the following work (or is there another way)

class Configuration
  include StoreModel::Model
  attribute :model, :string
  attribute :foo, :string
end

Configuration.attribute_names
=> ["model", "foo"]

I can do it ok on an instance with

Configuration.new.attributes.keys
=> ["model","foo"]

I use this so that any new attributes are available via the API.

consider linking to my attr_json gem too?

Hi, this is neat! I've used a similar approach with Rails Attributes API, in a somewhat different direction, in attr_json.

I see you link in the README to store_attribute and jsonb_accessor, would you consider linking to attr_json too?

Currently attr_json meets a somewhat different use case than store_accessor: I wanted to store both arrays of primitives and models in a single json column, to sort of give you some embedded "NoSQL"-like functionality in an activerecord. I also wanted to support nested models (a model can include other models as an attribute), and convenient support for using rails form builder (or simple_form) with all your embedded/nested models.

attr_json doesn't at the moment support the particular use case of store_model, with a single model that maps to an entire json column (instead attr_json wants it to map to a key in a hash in a json column). It might in the future though, it's been asked for.

I wonder if there's a way for us to collaborate/join forces, but also think it's not too much of a problem that we both are doing our thing, that's how innovation and experimentation works! I think it's neat that the Rails Attribute API allows us to do these things!

Allow_nil: true not working

Hi @DmitryTsepelev,

Thanks for the gem! It seems allow_nil: true not working, when I try to save model with json attributes set to nil it raises

raise StoreModel::Types::CastError,
                "failed casting #{value.inspect}, only String or Array instances are allowed"

or

raise StoreModel::Types::CastError,
                "failed casting #{value.inspect}, only String, " \
                "Hash or #{@model_klass.name} instances are allowed"

If I add

when nil then value

in both array_type.rb and json_type.rb everything works fine.

Lacking info on README

@DmitryTsepelev Can you share how you're using this gem on the frontend layer (meaning controller params and form views)? I'm facing some issues with assign_attributes overriding the entire hash. If possible, provide more information about these issues on the README file.

JSONB not supported?

The documentation does not specify a serialized column type, however the opening line suggests JSONB is supported
"Imagine that you have a model Product with a jsonb column called configuration. This is how you likely gonna work with this column:"

However, creating a column with jsonb does no persist the serialized values, a json column does
This is with Rails 6, postgres. Are you aware of issues around jsonb?

Migration workflow

How can we handle data migration in underlying jsonb field?

Here's an example. I've defined the following structure for AssetIssueList v1

class AssetIssue
  include StoreModel::Model

  attribute :key, :string, default: ''
  attribute :name, :string, default: ''
  attribute :exists, :boolean, default: false

  validates :key, name, :exists, presence: true
end

class AssetIssueList
  include StoreModel::Model

  attribute :version, :integer, default: 1
  attribute :list, AssetIssue.to_array_type, default: [ ]

  validates :version, :list, presence: true
end

class Asset < ApplicationRecord
  attribute :issues, AssetIssueList.to_type

  after_find :validateIssues
  def validateIssues
    if issues.nil? || issues.version != ISSUES_VERSION
      updateDefaultIssues
    end
  end
end

After some time, I've decided to remove attribute :name from AssetIssue. Application is unable to start due to error

ActiveModel::UnknownAttributeError (unknown attribute 'name' for AssetIssue.)

/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_assignment.rb:53:in `_assign_attribute'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_assignment.rb:44:in `block in _assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_assignment.rb:43:in `each'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_assignment.rb:43:in `_assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_assignment.rb:35:in `assign_attributes'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/model.rb:81:in `initialize'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attributes.rb:80:in `initialize'
/usr/local/bundle/gems/store_model-0.5.2/lib/store_model/types/array_type.rb:78:in `new'
/usr/local/bundle/gems/store_model-0.5.2/lib/store_model/types/array_type.rb:78:in `block in ensure_model_class'
/usr/local/bundle/gems/store_model-0.5.2/lib/store_model/types/array_type.rb:77:in `map'
/usr/local/bundle/gems/store_model-0.5.2/lib/store_model/types/array_type.rb:77:in `ensure_model_class'
/usr/local/bundle/gems/store_model-0.5.2/lib/store_model/types/array_type.rb:34:in `cast_value'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/type/value.rb:38:in `cast'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute.rb:174:in `type_cast'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute.rb:42:in `value'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_set.rb:41:in `fetch_value'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attributes.rb:130:in `attribute'
/usr/local/bundle/gems/activemodel-6.0.0/lib/active_model/attribute_methods.rb:383:in `list'

/app/models/navigation_asset.rb:14:in `validateIssues'

How to deal with that? Couldn't be not existent static keys (name in my example) just dynamic in AssetIssueList so we can migrate underlying data storage with ease?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.