Code Monkey home page Code Monkey logo

attr_json's Introduction

AttrJson

CI Status CI Status Gem Version

ActiveRecord attributes stored serialized in a json column, super smooth. For Rails 6.0.x through 7.0.x. Ruby 2.7+.

Typed and cast like Active Record. Supporting nested models, dirty tracking, some querying (with postgres jsonb contains), and working smoothy with form builders.

Use your database as a typed object store via ActiveRecord, in the same models right next to ordinary ActiveRecord column-backed attributes and associations. Your json-serialized attr_json attributes use as much of the existing ActiveRecord architecture as we can.

Why might you want or not want this?

Developed for postgres, but most features should work with MySQL json columns too, although has not yet been tested with MySQL.

Basic Use

# migration, default column used is `json_attributes, but this can be changed
class CreatMyModels < ActiveRecord::Migration[5.0]
  def change
    create_table :my_models do |t|
      t.jsonb :json_attributes
    end

    # If you plan to do any querying with jsonb_contains below..
    add_index :my_models, :json_attributes, using: :gin
  end
end

# An embedded model, if desired
class LangAndValue
  include AttrJson::Model

  attr_json :lang, :string, default: "en"
  attr_json :value, :string
end

class MyModel < ActiveRecord::Base
   include AttrJson::Record

   # use any ActiveModel::Type types: string, integer, decimal (BigDecimal),
   # float, datetime, boolean.
   attr_json :my_string, :string
   attr_json :my_integer, :integer
   attr_json :my_datetime, :datetime

   # You can have an _array_ of those things too. It will ordinarily default to empty array.
   attr_json :int_array, :integer, array: true

   # The empty array default can be disabled with the following setting
   attr_json :str_array, :string, array: true, default: AttrJson::AttributeDefinition::NO_DEFAULT_PROVIDED

   #and/or defaults
   attr_json :str_with_default, :string, default: "default value"

   attr_json :embedded_lang_and_val, LangAndValue.to_type
end

model = MyModel.create!(
  my_integer: 101,
  my_datetime: DateTime.new(2001,2,3,4,5,6),
  embedded_lang_and_val: LangAndValue.new(value: "a sentance in default language english")
  )

What will get serialized to your json_attributes column will look like:

{
  "my_integer":101,
  "my_datetime":"2001-02-03T04:05:06Z",
  "str_with_default":"default value",
  "embedded_lang_and_val": {
    "lang":"en",
    "value":"a sentance in default language english"
  }
}

These attributes have type-casting behavior very much like ordinary ActiveRecord values.

model = MyModel.new
model.my_integer = "12"
model.my_integer # => 12
model.int_array = "12"
model.int_array # => [12]
model.my_datetime = "2016-01-01 17:45"
model.my_datetime # => a Time object representing that, just like AR would cast
model.embedded_lang_and_val = { value: "val"}
model.embedded_lang_and_val #=> #<LangAndVal:0x000000010c9a7ad8 @attributes={"value"=>"val"...>

You can use ordinary ActiveRecord validation methods with attr_json attributes.

All the attr_json attributes are serialized to json as keys in a hash, in a database jsonb/json column. By default, in a column json_attributes. If you look at model.json_attributes, you'll see values already cast to their ruby representations.

To see JSON representations, we can use Rails *_before_type_cast methods, *-in_database and [*_for_database] methods (Rails 7.0+ only).

These methods can all be called on the container json_attributes json hash attribute (generally showing serialized JSON to string), or any individual attribute (generally showing in-memory JSON-able object). [This is a bit confusing and possibly not entirely consistent, needs more investigation.]

Specifying db column to use

While the default is to assume you want to serialize in a column called json_attributes, no worries, of course you can pick whatever named jsonb column you like, class-wide or per-attribute.

class OtherModel < ActiveRecord::Base
  include AttrJson::Record

  # as a default for the model
  attr_json_config(default_container_attribute: :some_other_column_name)

  # now this is going to serialize to column 'some_other_column_name'
  attr_json :my_int, :integer

  # Or on a per-attribute basis
  attr_json :my_int, :integer, container_attribute: "yet_another_column_name"
end

Store key different than attribute name/methods

You can also specify that the serialized JSON key should be different than the attribute name/methods, by using the store_key argument.

class MyModel < ActiveRecord::Base
  include AttrJson::Record

  attr_json :special_string, :string, store_key: "__my_string"
end

model = MyModel.new
model.special_string = "foo"
model.json_attributes # => {"__my_string"=>"foo"}
model.save!
model.json_attributes_before_type_cast # => string containing: {"__my_string":"foo"}

You can of course combine array, default, store_key, and container_attribute params however you like, with whatever types you like: symbols resolvable with ActiveRecord::Type.lookup, or any ActiveModel::Type::Value subclass, built-in or custom.

You can register your custom ActiveModel::Type::Value in a Rails initializer or early on in your app boot sequence:

ActiveRecord::Type.register(:my_type, MyActiveModelTypeSubclass)

Querying

There is some built-in support for querying using postgres jsonb containment (@>) operator. (or see here or here). For now you need to additionally include AttrJson::Record::QueryScopes to get this behavior.

model = MyModel.create(my_string: "foo", my_integer: 100)

MyModel.jsonb_contains(my_string: "foo", my_integer: 100).to_sql
# SELECT "products".* FROM "products" WHERE (products.json_attributes @> ('{"my_string":"foo","my_integer":100}')::jsonb)
MyModel.jsonb_contains(my_string: "foo", my_integer: 100).first
# Implemented with scopes, this is an ordinary relation, you can
# combine it with whatever, just like ordinary `where`.

MyModel.not_jsonb_contains(my:string: "foo", my_integer: 100).to_sql
# SELECT "products".* FROM "products" WHERE NOT (products.json_attributes @> ('{"my_string":"foo","my_integer":100}')::jsonb)

# typecasts much like ActiveRecord on query too:
MyModel.jsonb_contains(my_string: "foo", my_integer: "100")
# no problem

# works for arrays too
model = MyModel.create(int_array: [10, 20, 30])
MyModel.jsonb_contains(int_array: 10) # finds it
MyModel.jsonb_contains(int_array: [10]) # still finds it
MyModel.jsonb_contains(int_array: [10, 20]) # it contains both, so still finds it
MyModel.jsonb_contains(int_array: [10, 1000]) # nope, returns nil, has to contain ALL listed in query for array args

jsonb_contains will handle any store_key you have set -- you should specify attribute name, it'll actually query on store_key. And properly handles any container_attribute -- it'll look in the proper jsonb column.

Anything you can do with jsonb_contains should be handled by a postgres USING GIN index. Figuring out how to use indexes for jsonb queries can be confusing, here is a good blog post.

Nested models -- Structured/compound data

The AttrJson::Model mix-in lets you make ActiveModel::Model objects that can be round-trip serialized to a json hash, and they can be used as types for your top-level AttrJson::Record. AttrJson::Models can contain other AJ::Models, singly or as arrays, nested as many levels as you like.

That is, you can serialize complex object-oriented graphs of models into a single jsonb column, and get them back as they went in.

AttrJson::Model has an identical attr_json api to AttrJson::Record, with the exception that container_attribute is not supported.

class LangAndValue
  include AttrJson::Model

  attr_json :lang, :string, default: "en"
  attr_json :value, :string

  # Validations work fine, and will post up to parent record
  validates :lang, inclusion_in: I18n.config.available_locales.collect(&:to_s)
end

class MyModel < ActiveRecord::Base
  include AttrJson::Record
  include AttrJson::Record::QueryScopes

  attr_json :lang_and_value, LangAndValue.to_type

  # YES, you can even have an array of them
  attr_json :lang_and_value_array, LangAndValue.to_type, array: true
end

# Set with a model object, in initializer or writer
m = MyModel.new(lang_and_value: LangAndValue.new(lang: "fr", value: "S'il vous plaît"))
m.lang_and_value = LangAndValue.new(lang: "es", value: "hola")
m.lang_and_value
# => #<LangAndValue:0x007fb64f12bb70 @attributes={"lang"=>"es", "value"=>"hola"}>
m.save!
m.attr_jsons_before_type_cast
# => string containing: {"lang_and_value":{"lang":"es","value":"hola"}}

# Or with a hash, no problem.

m = MyModel.new(lang_and_value: { lang: 'fr', value: "S'il vous plaît"})
m.lang_and_value = { lang: 'en', value: "Hey there" }
m.save!
m.attr_jsons_before_type_cast
# => string containing: {"lang_and_value":{"lang":"en","value":"Hey there"}}
found = MyModel.find(m.id)
m.lang_and_value
# => #<LangAndValue:0x007fb64eb78e58 @attributes={"lang"=>"en", "value"=>"Hey there"}>

# Arrays too, yup

m = MyModel.new(lang_and_value_array: [{ lang: 'fr', value: "S'il vous plaît"}, { lang: 'en', value: "Hey there" }])
m.lang_and_value_array
# => [#<LangAndValue:0x007f89b4f08f30 @attributes={"lang"=>"fr", "value"=>"S'il vous plaît"}>, #<LangAndValue:0x007f89b4f086e8 @attributes={"lang"=>"en", "value"=>"Hey there"}>]
m.save!
m.attr_jsons_before_type_cast
# => string containing: {"lang_and_value_array":[{"lang":"fr","value":"S'il vous plaît"},{"lang":"en","value":"Hey there"}]}

You can nest AttrJson::Model objects inside each other, as deeply as you like.

You can edit nested models "in place", they will be properly saved.

m.lang_and_value.lang = "de"
m.save! # no problem!

For use with Rails forms, you may want to use attr_json_accepts_nested_attributes_for (like Rails accepts_nested_attributes_for, see doc page on Use with Forms and Form Builders.

Model-type defaults

If you want to set a default for an AttrJson::Model type, you should use a proc argument for the default, to avoid accidentally re-using a shared global default value, similar to issues people have with ruby Hash default.

  attr_json :lang_and_value, LangAndValue.to_type, default: -> { LangAndValue.new(lang: "en", value: "default") }

You can also use a Hash value that will be cast to your model, no need for proc argument in this case.

  attr_json :lang_and_value, LangAndValue.to_type, default: { lang: "en", value: "default" }

Polymorphic model types

There is some support for "polymorphic" attributes that can hetereogenously contain instances of different AttrJson::Model classes, see comment docs at AttrJson::Type::PolymorphicModel.

class SomeLabels
  include AttrJson::Model

  attr_json :hello, LangAndValue.to_type, array: true
  attr_json :goodbye, LangAndValue.to_type, array: true
end
class MyModel < ActiveRecord::Base
  include AttrJson::Record
  include AttrJson::Record::QueryScopes

  attr_json :my_labels, SomeLabels.to_type
end

m = MyModel.new
m.my_labels = {}
m.my_labels
# => #<SomeLabels:0x007fed2a3b1a18>
m.my_labels.hello = [{lang: 'en', value: 'hello'}, {lang: 'es', value: 'hola'}]
m.my_labels
# => #<SomeLabels:0x007fed2a3b1a18 @attributes={"hello"=>[#<LangAndValue:0x007fed2a0eafc8 @attributes={"lang"=>"en", "value"=>"hello"}>, #<LangAndValue:0x007fed2a0bb4d0 @attributes={"lang"=>"es", "value"=>"hola"}>]}>
m.my_labels.hello.find { |l| l.lang == "en" }.value = "Howdy"
m.save!
m.attr_jsons
# => {"my_labels"=>#<SomeLabels:0x007fed2a714e80 @attributes={"hello"=>[#<LangAndValue:0x007fed2a714cf0 @attributes={"lang"=>"en", "value"=>"Howdy"}>, #<LangAndValue:0x007fed2a714ac0 @attributes={"lang"=>"es", "value"=>"hola"}>]}>}
m.attr_jsons_before_type_cast
# => string containing: {"my_labels":{"hello":[{"lang":"en","value":"Howdy"},{"lang":"es","value":"hola"}]}}

GUESS WHAT? You can QUERY nested structures with jsonb_contains, using a dot-keypath notation, even through arrays as in this case. Your specific defined attr_json types determine the query and type-casting.

MyModel.jsonb_contains("my_labels.hello.lang" => "en").to_sql
# => SELECT "products".* FROM "products" WHERE (products.json_attributes @> ('{"my_labels":{"hello":[{"lang":"en"}]}}')::jsonb)
MyModel.jsonb_contains("my_labels.hello.lang" => "en").first


# also can give hashes, at any level, or models themselves. They will
# be cast. Trying to make everything super consistent with no surprises.

MyModel.jsonb_contains("my_labels.hello" => LangAndValue.new(lang: 'en')).to_sql
# => SELECT "products".* FROM "products" WHERE (products.json_attributes @> ('{"my_labels":{"hello":[{"lang":"en"}]}}')::jsonb)

MyModel.jsonb_contains("my_labels.hello" => {"lang" => "en"}).to_sql
# => SELECT "products".* FROM "products" WHERE (products.json_attributes @> ('{"my_labels":{"hello":[{"lang":"en"}]}}')::jsonb)

Remember, we're using a postgres containment (@>) operator, so queries always mean 'contains' -- the previous query needs a my_labels.hello which is a hash that includes the key/value, lang: en, it can have other key/values in it too. String values will need to match exactly.

Single AttrJson::Model serialized to an entire json column

The main use case of the gem is set up to let you combine multiple primitives and nested models under different keys combined in a single json or jsonb column.

But you may also want to have one AttrJson::Model class that serializes to map one model class, as a hash, to an entire json column on it's own.

AttrJson::Model can supply a simple coder for the ActiveRecord serialization feature to easily do that.

class MyModel
  include AttrJson::Model

  attr_json :some_string, :string
  attr_json :some_int, :int
end

class MyTable < ApplicationRecord
  serialize :some_json_column, MyModel.to_serialization_coder

  # NOTE: In Rails 7.1+, write:
  # serialize :some_json_column, coder: MyModel.to_serialization_coder
end

MyTable.create(some_json_column: MyModel.new(some_string: "string"))

# will cast from hash for you
MyTable.create(some_json_column: { some_int: 12 })

# etc

To avoid errors raised at inconvenient times, we recommend you set these settings to make 'bad' data turn into nil, consistent with most ActiveRecord types:

class MyModel
  include AttrJson::Model

  attr_json_config(bad_cast: :as_nil, unknown_key: :strip)
  # ...
end

And/or define a setter method to cast, and raise early on data problems:

class MyTable < ApplicationRecord
  serialize :some_json_column, MyModel.to_serialization_coder

  def some_json_column=(val)
    super(   )
  end
end

Serializing a model to an entire json column is a relatively recent feature, please let us know how it's working for you.

Storing Arbitrary JSON data

Arbitrary JSON data (hashes, arrays, primitives of any depth) can be stored within attributes by using the rails built in ActiveModel::Type::Value as the attribute type. This is basically a "no-op" value type -- JSON alone will be used to serialize/deserialize whatever values you put there, because of the json type on the container field.

class MyModel < ActiveRecord::Base
  include AttrJson::Record

  attr_json :arbitrary_hash, ActiveModel::Type::Value.new
end

Forms and Form Builders

Use with Rails form builders is supported pretty painlessly. Including with simple_form and cocoon (integration-tested in CI).

If you have nested AttrJson::Models you'd like to use in your forms much like Rails associated records: Where you would use Rails accepts_nested_attributes_for, instead include AttrJson::NestedAttributes and use attr_json_accepts_nested_attributes_for. Multiple levels of nesting are supported.

For more info, see doc page on Use with Forms and Form Builders.

ActiveRecord Attributes and Dirty tracking

We endeavor to make record-level attr_json attributes available as standard ActiveRecord attributes, supporting that full API.

Standard Rails dirty tracking should work properly with AttrJson::Record attributes! We have a test suite demonstrating.

We actually keep the "canonical" copy of data inside the "container attribute" hash in the ActiveRecord model. This is because this is what will actually get saved when you save. So we have two copies, that we do our best to keep in sync.

They get out of sync if you are doing unusual things like using the ActiveRecord attribute API directly (like calling write_attribute with an attr_json attribute). Even if this happens, mostly you won't notice. But one thing it will effect is dirty tracking.

If you ever need to sync the ActiveRecord attribute values from the AttrJson "canonical" copies, you can call active_record_model.attr_json_sync_to_rails_attributes. If you wanted to be 100% sure of dirty tracking, I suppose you could always call this method first. Sorry, this is the best we could do!

Note that ActiveRecord DirtyTracking will give you ruby objects, for instance for nested models, you might get:

record_obj.attribute_change_to_be_saved(:nested_model)
# => [#<object>, #<object>]

If you want to see JSON instead, you could call #as_json on the values. The Rails *_before_type_cast and *-in_database methods may also be useful.

Do you want this?

Why might you want this?

  • You have complicated data, which you want to access in object-oriented fashion, but want to avoid very complicated normalized rdbms schema -- and are willing to trade the powerful complex querying support normalized rdbms schema gives you.

  • Single-Table Inheritance, with sub-classes that have non-shared data fields. You rather not make all those columns, some of which will then also appear to inapplicable sub-classes. (note you may have trouble with ActiveRecord #becomes in some versions of Rails due to Rails bug. See #189 and rails/rails#47538))

  • A "content management system" type project, where you need complex structured data of various types, maybe needs to be vary depending on plugins or configuration, or for different article types -- but doesn't need to be very queryable generally -- or you have means of querying other than a normalized rdbms schema.

  • You want to version your models, which is tricky with associations between models. Minimize associations by inlining the complex data into one table row.

  • Generally, we're turning postgres into a simple object-oriented document store. That can be mixed with an rdbms. The very same row in a table in your db can have document-oriented json data and foreign keys and real rdbms associations to other rows. And it all just feels like ActiveRecord, mostly.

Why might you not want this?

  • An rdbms and SQL is a wonderful thing, if you need sophisticated querying and reporting with reasonable performance, complex data in a single jsonb probably isn't gonna be the best.

  • This is pretty well-designed code that mostly only uses fairly stable and public Rails API, but there is still some risk of tying your boat to it, it's not Rails itself, and there is some risk it won't keep up with Rails in the future.

Note on Optimistic Locking

When you save a record with any changes to any attr_jsons, it will overwrite the whole json structure in the relevant column for that row. Unlike ordinary AR attributes where updates just touch changed attributes.

Becuase of this, you probably want to seriously consider using ActiveRecord Optimistic Locking to prevent overwriting other updates from processes.

State of Code, and To Be Done

This code is solid and stable and is being used in production. If you don't see a lot of activity, it might be because it's stable, rather than abandoned. Check to see if it's passing/supported on recent Rails? We test on "edge" unreleased rails to try to stay ahead of compatibility, and has worked through multiple major Rails verisons with few if any changes needed.

In order to keep the low-maintenace scenario sustainable, I am very cautious accepting new features, especially if they increase code complexity at all. Even if you have a working PR, I may be reluctant to accept it. I'm prioritizing sustainability and stability over new features, and so far this is working out well. However, discussion is always welcome! Especially when paired with code (failing tests for the bugfix or feature you want are super helpful on their own!).

We are committed to semantic versioning and will endeavor to release no backwards breaking changes without a major version. We are also serious about minimizing backwards incompat releases altogether (ie minimiing major version releases).

Feedback of any kind of very welcome, please feel free to use the issue tracker. It is hard to get a sense of how many people are actually using this, which is helpful both for my own sense of reward and for anyone to get a sense of the size of the userbase -- feel free to say hi and let us know how you are using it!

Except for the jsonb_contains stuff using postgres jsonb contains operator, I don't believe any postgres-specific features are used. It ought to work with MySQL, testing and feedback welcome. (Or a PR to test on MySQL?). My own interest is postgres.

This is still mostly a single-maintainer operation, so has all the sustainability risks of that. Although there are other people using and contributing to it, check out the Github Issues and Pull Request tabs yourself to get a sense.

Possible future features:

  • Make AttrJson::Model lean more heavily on ActiveModel::Attributes API that did not fully exist in first version of attr_json (perhaps not, see #18)

  • partial updates for json hashes would be really nice: Using postgres jsonb merge operators to only overwrite what changed. In my initial attempts, AR doesn't make it easy to customize this. [update: this is hard, probably not coming soon. See #143]

  • Should we give AttrJson::Model a before_serialize hook that you might want to use similar to AR before_save? Should AttrJson::Models raise on trying to serialize an invalid model? [update: eh, hasn't really come up]

  • There are limits to what you can do with just jsonb_contains queries. We could support operations like >, <, <> as jsonb_accessor, even accross keypaths. (At present, you could use a before_savee to denormalize/renormalize copy your data into ordinary AR columns/associations for searching. Or perhaps a postgres ts_vector for text searching. Needs to be worked out.) [update: interested, but not necessarily prioritized. This one would be interesting for a third-party PR draft!]

  • We could/should probably support jsonb_order clauses, even accross key paths, like jsonb_accessor. [update: interested but not necessarily prioritized]

  • Could we make these attributes work in ordinary AR where, same as they do in jsonb_contains? Maybe. [update: probably not]

Development

While attr_json depends only on active_record, we run integration tests in the context of a full Rails app, in order to test working with simple_form and cocoon, among other things. (Via combustion, with app skeleton at ./spec/internal).

At present this does mean that all our automated tests are run in a full Rails environment, which is not great (any suggestions or PR's to fix this while still running integration tests under CI with full Rails app).

Tests are in rspec, run tests simply with ./bin/rspec.

We use appraisal to test with multiple rails versions, including on travis. Locally you can run bundle exec appraisal rspec to run tests multiple times for each rails version, or eg bundle exec appraisal rails-5-1 rspec. If the Gemfile or Appraisal file changes, you may need to re-run bundle exec appraisal install and commit changes. (Try to put dev dependencies in gemspec instead of Gemfile, but sometimes it gets weird.)

  • If you've been switching between rails versions and you get integration test failures, try rm -rf spec/internal/tmp/cache. Rails 6 does some things in there apparently not compatible with Rails 5, at least in our setup, and vice versa.

There is a ./bin/console that will give you a console in the context of attr_json and all it's dependencies, including the combustion rails app, and the models defined there.

Acknowledements, Prior Art, alternatives

  • The excellent work sgrif did on ActiveModel::Type really lays the groundwork and makes this possible. Plus many other Rails developers. Rails has a reputation for being composed of messy or poorly designed code, but it's some really nice design in Rails that allows us to do some pretty powerful stuff here, in surprisingly few lines of code.

  • The existing jsonb_accessor was an inspiration, and provided some good examples of how to do some things with AR and ActiveModel::Types. I started out trying to figure out how to fit in nested hashes to jsonb_accessor... but ended up pretty much rewriting it entirely, to lean on object-oriented polymorphism and ActiveModel::Type a lot heavier and have the API and internals I wanted/imagined.

  • Took a look at existing active_model_attributes too.

  • Didn't actually notice existing json_attributes until I was well on my way here. I think it's not updated for Rails5 or type-aware, haven't looked at it too much.

  • store_model was created after attr_json, and has some overlapping functionality.

  • store_attribute is also a more recent addition. while it's not specifically about JSON, it could be used with an underlying JSON coder to give you typed json attributes.

attr_json's People

Contributors

aried3r avatar g13ydson avatar giovannibonetti avatar jochenlutz avatar joesouthan avatar jrochkind avatar lyricl-gitster avatar machty avatar nhemsley avatar onomated avatar patleb avatar petergoldstein avatar rdubya avatar sandrew avatar stevenharman avatar volkanunsal 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

attr_json's Issues

polymorphic model attributes

Store a __type attribute (or something like that) in hashes with ruby model class to translate to, similar to AR single table inheritance.

may not work great with the querying support, or may end up working out fine after all.

What next?

I like this implementation.

What's next for this gem and what are you going to call it?

I'd be interested in implementing this into my existing code for IncoDocs to make prototyping new documents easier.

I also had my own implementation for how to nest attributes. You can check it out here.

Access to the parent object

Hi,

We are currently in the process of implementing the Json_attribute.
Is there any way to access the parent object when in a JsonAttribute::Record object?

Default values aren't being persisted

I am interested in using AttrJson to store "notification settings" (think, subscribed/unsubscribed to various email, push notifications, etc...). As a very simple case, I have something like the following set up

class User
  include AttrJson::Record
  include AttrJson::Record::Dirty
  include AttrJson::Record::QueryScopes

  attr_json :subscribed_to_weekly_report, :boolean, default: :true, container_attribute: :notification_settings
end

class AddNotificationSettingsToUsers < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :notification_settings, :jsonb, default: {}
    add_index :users, :notification_settings, using: :gin
  end
end

The default value is present when I fetch back a record that's not yet had the key set, or when I create a new instance via User.new. But in neither case is the default value persisted back to the db upon user#save. That seems... odd. I even added dirty tracking to see if that would do it, but it's the same behavior.

u = User.new
#<User> { notification_settings: { "subscribed_to_weekly_report: => true } }
u.save!
# INSERT INTO "users" ("created_at", "updated_at") VALUES ($1, $2) RETURNING "id"
#<User> { created_at: <DateTime>, updated_at: <DateTime>, notification_settings: { "subscribed_to_weekly_report: => true } }

I have to set the attribute to a non-default value (e.g., false for the boolean attribute I've setup above) and then save. At which point it's persisted. And setting it back to true and saving then persists that value.

Is this expected? Am I doing something incorrectly?

Thank you.

currently failing on rails edge (6.1)

Looks like it may be particular to the new "single model serialized to json field" feature we just added in #89... they may have removed the feature we are using for this from Rails 6.1?? DOH!

Needs more investigation.

  1) AttrJson::Model with ActiveRecord serialize to one column will cast from hash
     Failure/Error: record_instance.other_attributes = embedded_model_attributes
     
     ArgumentError:
       wrong number of arguments (given 2, expected 1)
     # ./spec/single_serialized_model_attribute_spec.rb:58:in `block (2 levels) in <top (required)>'

  2) AttrJson::Model with ActiveRecord serialize to one column can assign and save from model class
     Failure/Error: record_instance.other_attributes = model_value
     
     ArgumentError:
       wrong number of arguments (given 2, expected 1)
     # ./spec/single_serialized_model_attribute_spec.rb:44:in `block (2 levels) in <top (required)>'
 
 3) AttrJson::Model with ActiveRecord serialize to one column with existing value can set to nil
     Failure/Error: let(:record_instance) { record_class.new(other_attributes: embedded_model_attributes)}
     
     ArgumentError:
       wrong number of arguments (given 2, expected 1)
     # ./spec/single_serialized_model_attribute_spec.rb:72:in `block (3 levels) in <top (required)>'
     # ./spec/single_serialized_model_attribute_spec.rb:75:in `block (3 levels) in <top (required)>'

Custom attribute type error

I get the following error

ArgumentError:
Second argument (Db::Dimensions) must be a symbol or ActiveModel::Type::Value subclass

for a class that is a subclass of ActiveModel::Type::Value

I have a device model defined as:

class Device < ApplicationRecord
  include JsonAttribute::Record

  json_attribute :size, Db::Dimensions
  # This fails also
  # json_attribute :size, :db_dimensions
end

Db::Dimensions subclasses ActiveRecord::Type::Value and is defined as

module Db
  class Dimensions < ActiveRecord::Type::Value
    include ActiveModel::Type::Helpers::Mutable

    def deserialize(value)
      case value
      when ::String
        return if value.blank?

        width, height = value.split('x')
        build_dimensions(width, height)
      when ::Array
        build_dimensions(*value)
      else
        value
      end
    end

    def serialize(value)
      case value
      when Dimensions
        "#{number_for_dimensions(value.x)}x#{number_for_dimensions(value.y)}"
      when ::Array
        serialize(build_dimensions(*value))
      else
        super
      end
    end

    private

    def number_for_dimensions(number)
      number.to_s.gsub(/\.0$/, '')
    end

    def build_dimensions(width, height)
      ::Dimensions.new(Float(width), Float(height))
    end
  end
end

This is registered in an initializer:

ActiveRecord::Type.register(:db_dimensions, Db::Dimensions)

Am I missing something here? I know this can probably be better implemented as a JsonAttribute::Model, just wanted to point this out since the readme states that custom types are supported as well.

improvements to "nested attributes"

This confusing to talk/think about. But some things have been a bit inconvenient with form handling, that we can possibly take care of with our _attributes= methods, which are of course pretty much just there for form handling (I think?), via Rails analogy for associations using _attributes= methods.

  • if i have a nested object on a form, and ALL it's inputs are left empty, I really want to remove it from the DB, not leave it in the DB with all empty-string attributes
  • for a single (not array) embedded model... if I leave all the inputs empty, I want to DELETE it, not just leave it there empty
  • For a single (not array) embedded model, an easier way to make a form taht automatically "builds" it when needed?

Not sure if these would be universal wants. Or if they'd need to be configurable somehow.

In general, to "solve" this I'd want to spend some more time testing what Rails does in "ordinary" circumstances, including to_many and has_one inline editing... if possible, it would be nice to match rails, but if what Rails does is too un-helpful, we coudl maybe do better/different.

Easier introspection of jsonb fields

I'm looking to dynamically generate graphql type definitions based on fields in my model classes. I can instrospect ActiveRecord attributes via:

def Foo < ActiveRecord::Base
  include AttrJson::Record
end

# columns
Foo.columns
# associations
Foo.reflect_on_all_associations

I can access Foo.attr_json_registry but currently have no easy way to deduce json attribute names.
Currently hacking around to get the attributes, something around the lines of:

      def attr_json_fields(model)
        return unless model < AttrJson::Record

        registry = model.attr_json_registry
        attrs = model.instance_methods.select { |m| m.to_s.ends_with? '=' }
        attrs.map! { |attr| attr.to_s[0...-1] }
        attrs.select { |attr| registry.has_attribute? attr }
      end

Any objections to a fields method in the AttrJson::AttributeDefinition::Registry class?

Document/support a way to have a free-form json field

This would be great for caching data that may not always have a predetermined form such as data from a third party api, or data that may take different forms based on other "type" context in the record.
A simple implementation would be to allow vanilla ActiveModel::Type::Value entries such as:

  class JsonHash < ActiveModel::Type::Value
    include ActiveModel::Type::Helpers::Mutable

    def deserialize(value)
      if value.is_a?(Hash)
        value.with_indifferent_access
      else
        value
      end
    end
  end

json_attribute macro should raise on unrecognized keyword args

If you pass json_attribute an unrecognized keyword, it should immediately raise, to fail fast on wrong keyword args, whether from bad docs or otherwise. Avoiding the error exhibiting mysteriously like below. --jrochkind


original report:

In my user model, I have two jsonb containers: settings and extended defined as follows:

class User < ApplicationRecord
  include JsonAttribute::Record
  
  self.default_json_container_attribute = :extended
  json_attribute :timezone, Jsonb::TzFields.to_type
  json_attribute :nickname, type: :string

  json_attribute :setting_1, :boolean, default: true, json_container_attribute: 'settings'
  json_attribute :setting_2, :boolean, default: false, json_container_attribute: 'settings'
  json_attribute :setting_3, :boolean, default: true, json_container_attribute: 'settings'

I assign a new settings hash directly to the user to toggle the settings values, similar to so:

user = User.first
new_attrs = { 
   email: "[email protected]", 
   first_name: "new first name", 
   settings: {
      setting_1: false,
      setting_2: true,
      setting_3: false
   } 
}
user.update_attributes!

On accessing the settings attributes, I still get the default values not the new assigned values, so user.setting_1 returns true instead of the newly assigned false value.
I then inspected the attributes on the user.attributes, and the settings are applied correctly to the settings jsonb field, but also duplicated in the extended jsonb field which was defined as the default jsonb container. So the attributes look like this:

user.attributes = {
  # settings assigned as expected
   settings: {
      setting_1: false,
      setting_2: true,
      setting_3: false,
   },
   # extended has settings fields retaining default values which is should not. 
   extended: {
      setting_1: true,
      setting_2: false,
      setting_3: true,
   },
  ...
}

Looks like "mass assignment" of a hash to a jsonb field is supported in the docs. Is it however supported in the case with multiple jsonb containers?

Support mounting an attribute at the top level of a JSON column instead of mounting it to a key _within_ a JSON column container

attr_json provides good support for rich data types as values inside a JSON object (ruby hash). I think it would be useful to also be able to use a rich data type directly on a JSON attribute itself (at the "top level" instead of just for keys inside it).

I tried this:

class Setting < ActiveRecord::Base
  class Settings
    include AttrJson::Model

    attr_json :enabled, :boolean, default: true
    # ...
  end

  attr_json :settings, Settings.to_type, container_attribute: :settings, store_key: nil
end

but not surprisingly, it didn't work (caused stack level too deep) because calling settings tries to look inside its "container", settings. But really, there is actually no container; we just want that JSON attribute itself to have a given type.

(

  attr_json :settings, Settings.to_type, container_attribute: nil, store_key: nil

would probably be more accurate)

You'll probably tell me, that's not what attr_json is for—just use Rails's built-in attribute API! :)

And then I'll say, I tried that, but it doesn't work out of the box currently.

I tried this:

  attribute :settings, Setting.to_type

... which got me pretty close, but it ran into this error when it tried to cast:

main > s.settings
AttrJson::Type::Model::BadCast: Can not cast from "{\"enabled\": true}" to specific_notification_setting
from /home/tyler/.gem/ruby/2.4.5/gems/attr_json-0.5.0/lib/attr_json/type/model.rb:43:in `cast'

When I don't add an explicit attribute :settings line, it automatically casts between a Ruby Hash and JSON string automatically, so it must implicitly set the attribute type of JSONB columns somehow.

I don't know how to introspect into the type of attributes, but I figured out how to get the type of the underlying column:

main > s.class.columns_hash['settings'].instance_eval { @cast_type }
=> #<ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb:0x000055961f437000 @limit=nil, @precision=nil, @scale=nil>

main > ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb.superclass.superclass
=> ActiveRecord::Type::Internal::AbstractJson

main > $ ActiveRecord::Type::Internal::AbstractJson#deserialize
def deserialize(value)
  if value.is_a?(::String)
    ::ActiveSupport::JSON.decode(value) rescue nil
  else
    value
  end
end

So I tried adding this to Model#cast in attr_json/type/model.rb:

        elsif v.kind_of? String
          # TODO Maybe we ought not to do this on #to_h?
          model.new_from_serializable(::ActiveSupport::JSON.decode(v))

And it works!

main > s.settings_before_type_cast
=> "{\"enabled\": true}"

main > s.settings
=> #<SpecificNotificationSetting:0x000055961fb90a40 @attributes={"enabled"=>true, "ActionMailer"=>true}>

... except it still doesn't work for saving...

main > s.settings = NotificationSettings::Setting::Settings.new
=> #<NotificationSettings::Setting::Settings:0x0000560d3957cac8 @attributes={"enabled"=>true, "ActionMailer"=>true}>

main > s.save
TypeError: can't quote Hash
from ~/.gem/ruby/2.4.5/gems/activerecord-5.1.6/lib/active_record/connection_adapters/abstract/quoting.rb:196:in `_quote'

(Same error if you set it directly to a hash:

s.settings = {enabled: false}

)

When I dug into it a bit to see how it was able to save regular attr_json fields that use custom type without getting that error, I discovered that even though the custom type serializes to a Hash, the container attribute that it's contained within takes care of serializing those Hashes and everything inside of it to JSON:

lib/attr_json/type/container_attribute.rb:

   36:         super(v.collect do |key, value|
   37:           attr_def = model.attr_json_registry.store_key_lookup(container_attribute, key)
=> 38:           [key, attr_def ? attr_def.serialize(value) : value]
   39:         end.to_h)

(AttrJson::Type::ContainerAttribute's superclass is ActiveRecord::Type::Internal::AbstractJson (I'm on Rails 5.1).)

How could we get it to work?

It seems like we just need the AttrJson::Model to serialize to a Hash when in a container and to a JSON string when used directly on a JSON attribute. What's the best way to do that?

I don't suppose there's an easy way to detect if it's in a container not within the serialize method...

What about adding another conversion, like to_json_serialized_type or something, that you can call instead of to_json if you want it to serialize directly to/from JSON instead of to/from a Hash?

Why would you want this?

Mostly so you can reuse the same types both directly on an attribute and for attributes stored inside a container attribute without having to duplicate the logic in your own custom type.

A random example...

attribute :preferred_language, Language.to_type
attr_json :languages_spoken, Language, array: true

notifications-rails

The particular use case that led me to this idea was was trying to make a nicer API for accessing settings in a NotificationSettings::Setting::Setting model object.

Since hashes are such a pain to work with, I was trying to change it to use jsonb, so we could do something like:

s.settings.enabled = false
s.category_settings.some_category.enabled = false

instead of

s.settings[:enabled] = false
s.category_settings[:some_category][:enabled] = false

should `array:true` values always default to empty array?

If you define an attribute:

attr_json :str_array, array: true

And you create a brand new object SomeModel.create!.

  • It has no key stored in the json hash for str_array at all
  • if you look at it (now or after later fetch), model_intance.str_array is nil, not [].

This adds some annoyance when writing view or other code that wants to iterate over it. model_instance.str_array.each will of course be a undefined method each' for nil:NilClass`.

You could define it to have a default of the empty array yourself:

attr_json :str_array, array: true, default: -> { [] }

It's a bit annoying to do that for every attr_json you define.

This also means that SomeModel.create! will, all by itself, store a key in the serialized json hash for str_array with the empty (json) array as a value.

What do we want attr_json to do by default? Or as an option?

If any other users of attr_json are reading, feedback welcome!

Allowing arbitrary hash types

Hello,

I would like to be able to store arbitrary-depth model information, but it is not clear if attr_json handles this gracefully (if at all??)

say:

class User
include AttrJson::Record

attr_json :full_name, :string
attr_json :data_dump, :????
end

I want instantiated_user.data_dump to accept an arbitrary hash.

Is this at all possible? or should I just use a separate json field (I just realized this as I was posting).

Thanks for the nice gem, and in advance,

Nick

Update to latest release breaks ActiveModel type registration...

I've been using the code @ f221ae0 for over a year now, and decided to give the latest changes a shot. My custom type registered as:

ActiveModel::Type.register(:db_hash, Db::JsonHash)

Is no longer resolving due to the change to lookup ActiveRecord::Type as opposed to ActiveModel::Type. The comment here looks like the switch was made to support multi-param assignment of date params:
https://github.com/jrochkind/attr_json/blob/master/lib/attr_json/attribute_definition.rb#L43

Can this be changed to look up both? i.e. Check ActiveRecord::Type first then ActiveModel::Type? i.e to something like this:

if type.is_a? Symbol
        # ActiveModel::Type.lookup may make more sense, but ActiveModel::Type::Date
        # seems to have a bug with multi-param assignment. Mostly they return
        # the same types, but ActiveRecord::Type::Date works with multi-param assignment.
       begin
              type = ActiveRecord::Type.lookup(type)
       rescue ArgumentError
              type = ActiveModel::Type.lookup(type)
       end
elsif ! type.is_a? ActiveModel::Type::Value
        raise ArgumentError, "Second argument (#{type}) must be a symbol or instance of an ActiveModel::Type::Value subclass"
      end

Custom Nested AttrJson::Models Do Not Get Cast/Serialized Correctly when Reading from .attributes/.as_json/.serializable_hash

The Following Test Case Shows that there is an issue when reading the attributes when AttrJson:Model is nested inside other AttrJson::Model objects.

Note I used the faker gem to generate the test data for this case

test_case.rb

require 'faker'
class Product
  include AttrJson::Model
  attr_json :name, :string, default: Faker::Commerce.unique.product_name
  attr_json :cost, :string, default: Faker::Commerce.price(range: 25..100, as_string: true)
  attr_json :material, :string, default: Faker::Commerce.material
  attr_json :color, :string, default: Faker::Commerce.color
end

class Brand
  include AttrJson::Model
  def self.build_test
    new(products: Array.new(5) { Product.new } )
  end
  attr_json :name, :string, default: Faker::Appliance.unique.brand
  attr_json :products, Product.to_type, array: true, default: []
end


class Department
  include AttrJson::Model

  def self.build_test
    new(brands: Array.new(5) { Brand.build_test } )
  end

  attr_json :name, :string, default: Faker::Commerce.unique.department
  attr_json :brands, Brand.to_type, array: true, default: []
end

dep = Department.build_test

dep.attributes
#=>
# {
#     "brands" => [
#         [0] #<Brand:0x00007fdc5a55d190 @attributes={"products"=>[#<Product:0x00007fdc5a55edd8 @attributes={"name"=>"G
# orgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007fdc5a55e6f8 @attri
# butes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007f
# dc5a55e1a8 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #
# <Product:0x00007fdc5a55dc58 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color
# "=>"magenta"}>, #<Product:0x00007fdc5a55d708 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material
# "=>"Wool", "color"=>"magenta"}>], "name"=>"KitchenAid"}>,
#         [1] #<Brand:0x00007fdc5ae0e280 @attributes={"products"=>[#<Product:0x00007fdc5a55cd30 @attributes={"name"=>"G
#
# orgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007fdc5a55c7e0 @attri
# butes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007f
# dc5a55c290 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #
# <Product:0x00007fdc5ae0f978 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color
# "=>"magenta"}>, #<Product:0x00007fdc5ae0ee10 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material
# "=>"Wool", "color"=>"magenta"}>], "name"=>"KitchenAid"}>,
#         [2] #<Brand:0x00007fdc5a563540 @attributes={"products"=>[#<Product:0x00007fdc5ae0dd58 @attributes={"name"=>"G
# orgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007fdc5ae0d330 @attri
# butes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007f
# dc5ae0c958 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #
# <Product:0x00007fdc5ae0c0e8 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color
# "=>"magenta"}>, #<Product:0x00007fdc5a563ab8 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material
# "=>"Wool", "color"=>"magenta"}>], "name"=>"KitchenAid"}>,
#         [3] #<Brand:0x00007fdc5a561768 @attributes={"products"=>[#<Product:0x00007fdc5a563220 @attributes={"name"=>"G
# orgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007fdc5a562cd0 @attri
# butes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007f
# dc5a562780 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #
# <Product:0x00007fdc5a562230 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color
# "=>"magenta"}>, #<Product:0x00007fdc5a561ce0 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material
# "=>"Wool", "color"=>"magenta"}>], "name"=>"KitchenAid"}>,
#         [4] #<Brand:0x00007fdc5ae03218 @attributes={"products"=>[#<Product:0x00007fdc5a561448 @attributes={"name"=>"G
# orgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007fdc5a560ef8 @attri
# butes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #<Product:0x00007f
# dc5a5609a8 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color"=>"magenta"}>, #
# <Product:0x00007fdc5a560458 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material"=>"Wool", "color
# "=>"magenta"}>, #<Product:0x00007fdc5ae03e98 @attributes={"name"=>"Gorgeous Granite Coat", "cost"=>"61.00", "material
# "=>"Wool", "color"=>"magenta"}>], "name"=>"KitchenAid"}>
#     ],
#       "name" => "Shoes, Games & Toys"
# }

By Itself this doesn't seem like an issue(as_json and serializable_hash work perfectly fine on this case). However when adding these models to a json_column(Like the products table in the test app) with a container attribute like so

class Store < ActiveRecord::Base
  include AttrJson::Record

  self.table_name = "products"

  attr_json :departments, Department.to_type, container_attribute: :other_attributes, array: true, default: []
end

store = Store.new(departments: Array.new(3) { Department.build_test })

Then running either

store.as_json or store.serializable_hash

The output gets further messed up

#=>
# .... "other_attributes" => {
#          "departments" => [
              # [0] #<Department:0x00007f3221c22518 @attributes={"brands"=>[#<Brand:0x00007f3221c1fe30 @attributes={"products"=>[#<Produc
# t:0x00007f32229232d0 @attributes={"name"=>"Synergistic Linen Pants", "cost"=>"64.00", "material"=>"Cotton", "color"=>
# "blue"}>, #<Product:0x00007f3222922678 @attributes={"name"=>"Synergistic Linen Pants", "cost"=>"64.00", "material"=>"
# Cotton", "color"=>"blue"}>, #<Product:0x00007f3222921c78 @attributes={"name"=>"Synergistic Linen Pants", "cost"=>"64.
#]
#
# }```

Also doing this without `array: true` produces similar results. 
 





Rails 7 support

It looks like attr_json is working great on Rails 7, but the gemspec still requires activerecord < 7.0. Would it be possible to drop the upper limit entirely?

Override of attr_json's

Hey, thanks for a useful gem and support of it!

I have a question regarding overriding attributes: it there some particular reason we try to prevent it here and here? Would it make sense to allow overrides when passing an option override: true?

Asking cause in our case we have modules that include batch of json_attr's though we want override defaults for some of the models. Will be glad to work on the addition of a feature.

Thanks in advance!

rake assets:precompile or db:setup will try to connect to the database, but, there is no database.

when database is not setup before, or build a docker image or deploy to a new server fist time

ActiveRecord::NoDatabaseError and PG::ConnectionBad: will got

the error stack trace to

type = ActiveRecord::Type.lookup(type)

when bundle exe rake assets:precompile, i got

rake aborted!
PG::ConnectionBad: could not connect to server: No such file or directory
	Is the server running locally and accepting
	connections on Unix domain socket "/tmp/.s.PGSQL.5432"?
/usr/local/bundle/gems/pg-1.1.3/lib/pg.rb:56:in `initialize'
/usr/local/bundle/gems/pg-1.1.3/lib/pg.rb:56:in `new'
/usr/local/bundle/gems/pg-1.1.3/lib/pg.rb:56:in `connect'
...

when bundle exec rake db:setup , i got

Caused by:
app_1            | PG::ConnectionBad: FATAL:  database "xxxxxx" does not exist
app_1            | /usr/local/bundle/gems/pg-1.1.3/lib/pg.rb:56:in `initialize'
app_1            | /usr/local/bundle/gems/pg-1.1.3/lib/pg.rb:56:in `new'
app_1            | /usr/local/bundle/gems/pg-1.1.3/lib/pg.rb:56:in `connect'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/postgresql_adapter.rb:692:in `connect'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/postgresql_adapter.rb:223:in `initialize'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/postgresql_adapter.rb:48:in `new'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/postgresql_adapter.rb:48:in `postgresql_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:811:in `new_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:855:in `checkout_new_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:834:in `try_to_checkout_new_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:795:in `acquire_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:523:in `checkout'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:382:in `connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_adapters/abstract/connection_pool.rb:1010:in `retrieve_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_handling.rb:118:in `retrieve_connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/connection_handling.rb:90:in `connection'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/type.rb:52:in `current_adapter_name'
app_1            | /usr/local/bundle/gems/activerecord-5.2.2/lib/active_record/type.rb:41:in `lookup'
app_1            | /usr/local/bundle/gems/attr_json-0.4.0/lib/attr_json/attribute_definition.rb:43:in `initialize'
app_1            | /usr/local/bundle/gems/attr_json-0.4.0/lib/attr_json/model.rb:112:in `new'
app_1            | /usr/local/bundle/gems/attr_json-0.4.0/lib/attr_json/model.rb:112:in `attr_json'

better config system?

Increasingly adding multiple class attributes for different config, unify this in a single json_attribute_config object?

Make the container column to use more explicit

I'm only just beginning to explore this library—and it looks amazing!—but I wanted to share an initial impression I had while it's still fresh (you only get one chance at first impressions, right?)...

When I first read the examples in the Readme, I was confused because even though I saw you created a jsonb column:

t.jsonb :json_attributes

, I didn't see that column mentioned in these declarations:

attr_json :my_string, :string
...

so I was puzzled how it knew which underlying JSONB column to get that data from. I was worried for a second that it might only support getting data from a single JSONB column ... or worse, that the column had to be named :json_attributes for thing to work.

Reading on, of course, I found the answer:

While the default is to assume you want to serialize in a column called json_attributes, no worries, of course you can pick whatever named jsonb column you like, class-wide or per-attribute.

I'm glad there's at least a way to configure which column it uses, esp. on a per-attribute basis, but it bothers me a little that it makes any assumptions about where I want to store that data.

Arguments for changing API to list container attribute as first arg...

Verbose if using multiple JSON columns

It definitely makes the API nicer if you are only using this with one JSONB column... which I guess is what you're assuming is the most common scenario that you're optimizing for... But it makes it less nice if you're using it with multiple columns. For example,

  attr_json :lang,      :string, default: 'fr',              container_attribute: 'locale_preferences'
  attr_json :time_zone, :string, default: 'America/Toronto', container_attribute: 'locale_preferences'
  attr_json :currency,  :string, default: 'CAD',             container_attribute: 'locale_preferences'

  attr_json :send_via_email, :boolean, default: true,  container_attribute: 'notification_preferences'
  attr_json :send_via_sms,   :boolean, default: false, container_attribute: 'notification_preferences'

is not as nice or as readable as this would be:

  attr_json :locale_preferences, :lang,      :string, default: 'fr'
  attr_json :locale_preferences, :time_zone, :string, default: 'America/Toronto'
  attr_json :locale_preferences, :currency,  :string, default: 'CAD'

  attr_json :notification_preferences, :send_via_email, :boolean, default: true
  attr_json :notification_preferences, :send_via_sms,   :boolean, default: false

Current API more different than necessary from store_accessor, etc.

It also makes it different from the store_accessor API, which is what folks might have looked into using for their JSONB-backed attributes prior to discovering attr_json...

Obviously it can't match the *keys API of store_accessor(store_attribute, *keys) since it uses argument 21 to specify the type. But it could at least put the container attribute as the first argument.

These other APIs all list the container/store attribute as the first argument (which doesn't make it better, just makes it more familiar and expected, I suppose)...

https://api.rubyonrails.org/classes/ActiveRecord/Store.html

store_accessor :settings, :privileges, :servants

https://github.com/joel/json_attributes:

json_attributes :office, :address

https://github.com/devmynd/jsonb_accessor

  jsonb_accessor :data,
    title: :string,
    external_id: :integer,
    reviewed_at: :datetime

Current convention too magical / Explicit would be better

By not even requiring the container/store column name to be specified in the model definition, and just assuming it will be named json_attributes, it may be a little too magical for some folks, who might prefer to see the relationship with the underlying column name stated explicitly (though I guess those folks can already make it explicit by specifying container_attribute: for every attribute) ...

Being explicit about the container/host/store attribute seems like it would be more in line with the philosophy mentioned in this comment:

... but the functionality here is only present in a model if you explicitly include JsonAttribute::Record::QueryScopes

Default too presumptuous

Convention over configuration is great and all, but in this case I would worry it could lead people to just automatically assume that only having a single JSON column in their table is better, is the preferred way to do it, better supported, etc., without actually considering what column name would make sense for the data they are storing. In other words, people wouldn't want to break from the convention, the "blessed path", so they turn their brains off and just go with the flow?

The current default presumes that most users would/should prefer storing everything in the same column... but is that really the case?

I actually think it would be better in most cases to define as many columns as logically makes sense, in order to better define and organize the data to the extent you know its shape ahead of time, and only use JSONB for the part of the schema that is more variable and would benefit from the flexibility a JSON data type gives you. Like in the example on https://github.com/joel/json_attributes, they name the column office because it contains data about its office, such as an address. Or in my example, keeping locale data in a separate column from notification preferences seems preferable to having them all in a single column.

I guess it depends where on the RDBMS-NoSQL spectrum you fall. Taken to the extreme, why don't we just make the default column name be attributes and store all the model's attributes in that single column? Then you could define your schema entirely with attr_json! ;-)

But if you're going to have a default column name, how about something more generic like data or something named after what it's for instead of how it's implemented (json_attributes)... like:

  • schemaless_attributes
  • embedded_attributes
  • miscellaneous_attribute_dumping_ground ;-)

Arguments for keeping current container_attribute: API

More declarative / Hides implementation details

I do like how declarative the current API is. It's a little bit like attr_accessor :attr_name, which is maybe what you were going for (hoping to evoke).

For the same reason that the main name used is the attribute name and not the store_name, having the first "main" argument to attr_json be the attribute name keeps the focus on how the end-user of this model will see the attribute.

In other words, it hides the implementation details of where (container_attribute, store_name) and how (in a JSONB-type column) it will store this attribute in a more out-of-the-way place (at the end of the line)

That seems to be in line with the overall philosophy, as alluded to in this comment:

I think jsonb_contains is the right name -- and I don't want callers to have to know what host jsonb columns include the json_attribute when making the call ...

Other option: cleaner DSL for specifying container_attribute

Just thought of another alternative that might be a good compromise between explicit and concise (the main thing having a default column seems to give us)... How about a DSL something like:

class OtherModel < ActiveRecord::Base
  include AttrJson::Record

  contained_in_json_attribute :locale_preferences do
    attr_json :lang,      :string, default: 'fr'
    attr_json :time_zone, :string, default: 'America/Toronto'
    attr_json :currency,  :string, default: 'CAD'
  end

  contained_in_json_attribute :notification_preferences do
    attr_json :send_via_email, :boolean, default: true
    attr_json :send_via_sms,   :boolean, default: false
  end
end

Downsides:

  • either have to use instance_eval and mess with self or yield something to the block
  • what to name it? contained_in_json_attribute? json_attributes_on? json_attributes_in? unnest_json_attribute (like unnest from http://trailblazer.to/gems/disposable/api.html)? container_attribute?

Upsides:

  • groups related things together in the same block and in the same container column, at the same time
  • it could be an optional DSL; you wouldn't have to use it

Conclusion

I hope this was somewhat helpful and I didn't misunderstand something completely. I'm still pretty new to JSONB columns so certainly don't claim to be an expert. I'll try to give the current API a fair chance and see if I still feel the same after I've used it more...

Compound data dynamic

Based on an attribute the model changes, is there a way to do this? example:

class Model < ApplicationRecord
  include AttrJson::Record

  attr_json :theme, :string, default: 'default'
  attr_json :theme_settings, Object.const_get("Themes::#{theme.capitalize}").to_type
end

or maybe inside the AttrJson::Model to have dynamic attributes, depending on the chosen theme.

Assignment to container column ignores values from database

Here is an odd issue I'm encountering:

let(:klass) do
  TestRecord = Class.new(ActiveRecord::Base) do
    include AttrJson::Record
    self.table_name = "products"

    attr_json :foo, :integer, default: 1
    attr_json :bar, :string
  end
end
let(:instance) { klass.new }

it 'works for the simplest case' do
  instance.bar = 2
  instance.save
  expect(instance.foo).to eq 1
  expect(instance.bar).to eq '2'

  instance.foo = 9
  instance.save
  expect(instance.foo).to eq 9
  expect(instance.json_attributes).to eq({ 'foo' => 9, 'bar' => '2'})

  instance.json_attributes = { a: 1, b: 2 }
  instance.save

  # Expected this:
  # expect(instance.json_attributes).to eq({ 'a' => 1, 'b' => '2', 'foo'=>9, 'bar'=>'2' })
  
  # But it's actually this....
  expect(instance.json_attributes).to eq({ 'a' => 1, 'b' => '2', 'foo' => 1})
end

Feature request: add option to skip default value storage for sparse data types

Hi! Thanks again for the awesome gem! It is very useful, specially combined with STI - since each subclass has different attributes.

Anyway, in the project I'm working on I have the following use case:

class MyModel < ApplicationRecord
  include AttrJson::Record

  attr_json :my_nested_object, MyNestedObject, array: true, default: ->{ [] }
end

class MyNestedObject
  include AttrJson::Model

  attr_json :a, :integer, default: 0
  attr_json :b, :integer, default: 0
  attr_json :c, :integer, default: 0

  attr_json :d, :integer, default: 0
  attr_json :e, :integer, default: 0
  attr_json :f, :integer, default: 0

  validates :a, :b, :c, presence: true
  validates :a, :b, :c, :d, :e, :f, numericality: { greater_than_or_equal_to: 0 }
end

In this example we have 2 x 3 = 6 fields. And of these, only a, b and c are usually filled out. On the other hand, fields d, e and f are present only in some rare circunstances.

I was wondering if it would be possible to instruct attr_json to skip the fields d, e and f from storage when they were not given and have the default value (in this case, zero). It could be specified like this:

class MyNestedObject
  include AttrJson::Model

  attr_json :a, :integer, default: 0
  attr_json :b, :integer, default: 0
  attr_json :c, :integer, default: 0

  attr_json :d, :integer, default: 0, store_default: false
  attr_json :e, :integer, default: 0, store_default: false
  attr_json :f, :integer, default: 0, store_default: false

  validates :a, :b, :c, presence: true
  validates :a, :b, :c, :d, :e, :f, numericality: { greater_than_or_equal_to: 0 }
end

That way, if a user saved a record with value {"a": 1, "b": 2, "c": 3}, only these fields would be present in the database.

The motivation for that is that I'm working with a sparse data set, where each object has about 25 fields, of which only 13 will be filled in over 90% of the cases. Do you think it is worth it to avoid storing them? If so, do you have a suggestion on how that could be implemented? I can try making a PR for that if you point me the right direction

Duplicated scope, incompatibility

I'm experimenting a bit with this great gem. When I query:

MyModel.jsonb_contains (payload: {string_id: 'id'})

The following error message is raised:
ArgumentError: You tried to define a scope named "jsonb_contains" on the model "MyModel", but ActiveRecord already defined a class method with the same name.

I've also defined 'jsonb_accessor' in gemfile. If I remove that, the query works fine.

Empty nested resource on edit form

Hi and many thanks for your gem.
It's really solved (almost) my problem.
But for know i have a strange behavior (.
I've use your example, described here https://github.com/jrochkind/attr_json/tree/master/spec/internal.

What i get , - form rendered perfect, all data passed to controller perfect, typing Document.last in console and see that all data looks perfect. But, when i try to render edit action - fields_for block is empty (like i'm creating new Document) and when i type Document.last.person_roles - it returns nil. If i use Document.last[:person_roles] - i can see perfect data...
What can be wrong?

rails (5.2.5)
ruby 2.6.3
pg (0.21.0)
attr_json (1.3.0)
cocoon (1.2.15)

thanks.

support ruby 3.0

Running into problems due to something changed in ruby 3.0, it's possible it's actually a bug in ruby 3, looking into it.

Get parent Model inside Attr_json nested model.

I'm trying to implement an relation to parent model, like belongs_to behavior inside an 'AttrJson::Model', there's an "gem Way" to do that?

My current solution is to pass the parent model to the method I'm calling, but this leads to others questions and more complexity. Like: AttrJsonModel.new().called_method(parent, *other_args)

But it's in some way possible to do inside method:

class Model
  include AttrJson::Model
  
  def called_method args
    ...  
    self.parent_model
    ...
  end

Could you share some thoughts about this need?

Thanks for all.

make attr_json_accepts_nested_attributes_for default on

at least if you included AttrJson::NestedAttributes.

Working with it, it's a pain to have to do this manually, and I can't think of any reason you wouldn't want it (if you are including AttrJson::NestedAttributes anyway).

Can still turn it off with an arg maybe. Or customize args.

Empty strings in AttrJson::Model fields submitted by form are should be turned into nil?

There are already some methods in the *attributes= methods created for handling form-submitted values that ignore empty strings.

If using a repeatable AttrJson::Model, submitting a hash with entirely blank values is ignored, as if it wasn't submitted. This is more or less consistent with how ActiveRecord handles nested "to-many" submissions.

If using a repeatable primitive string, there is an option on the "accepts nested" API to also ignore empty strings, and just treat them as if they weren't there.

Both of these are meant to be more-or-less consistent with ActiveRecord behavior -- and handle the case of repeatable fields, where you had an extra blank one on the end, not filled out, it should be ignored not add a blank value on end in database. Or in some cases you wind up with an extra value/model added on every 'save', for the extra empty form input at end.

However, if you have an AttrJson::Model with several fields, and have a form on screen with all of them, but only some of them are filled out -- the ones left blank, on form submission, will wind up with empty string values in the database. There isn't any built-in way to handle these. But I sort of assumed there should be in sciencehistory/scihist_digicoll#491, and was surprised there wasn't.

Should there be? Not sure. Worth investigating how ActiveRecord handles this with embedded nested to-many inputs. If AR doesn't handle it any special way, we shouldn't either probably.

ActiveRecord time_zone_aware_attributes not operative in attr_json date/time types

ActiveRecord.time_zone_aware_attributes is true by default, and what it does is turn all your AR date/time attributes into magical ActiveSupport::TimeWithZone objects that (basically) have the current Time.zone zone attached to them. The time might be stored in the db in UTC but it comes back as a TimeWithZone in Time.zone. (you don't even need to save for this to happen, it happens as soon as you set an attribute).

Our json_attribute :date and :datetime typed fields do not have this property. They remain plain old ruby stdlib Time or Date objects, they are not magically turned into ActiveSupport::TimeWithZone.

They are stored in the db always in UTC. They are correct, they don't get the time wrong. If you set a local time, it will be stored in the db in UTC, representing the same moment in time. And it will come back in UTC. You can convert it to whatever time zone you want for display purposes using ActiveSupport API -- but it won't automatically be that.

I'm not sure if this matters, it may be okay. On the other hand, it may lead to hard to deal with behavior when letting users edit times in a form (what zone are those times in exactly?).

We need more info on how important/useful it is before spending time on figuring out how to get our own date/time types to respect ActiveRecord::Base.time_zone_aware_attributes.

Feedback welcome.

There are some tests marked "pending" that would pass if our attr's were properly respecting/implementing ActiveRecord::Base.time_zone_aware_attributes == true behavior.

Querying with multiple jsob_containers

Does the query interface currently support querying with multiple jsonb_containers.

An example would I've got 2 jsonb columns in my table. One named data the other named something_data.

How can I query something_data and not data?

Thanks

Custom types inside Model types don't deserialize correctly

I have an ActiveRecord::Type that encrypts the value in the database. It works correctly when I use it on an ActiveRecord with attribute :password, :encrypted and even with attr_json :password, :encrypted but it doesn't work when the attr_json is inside an AttrJson::Model. I think the issue is because AttrJson::Type::Model just calls cast from deserialize here. The way I see it, cast is supposed to take in a user value while deserialize is supposed to take in a value from the database, so it doesn't make sense to me to call case from deserialize.

class EncryptedType < ActiveRecord::Type::Value
  def deserialize(value)
    decrypt(value)
  end

  def serialize(value)
    encrypt(value)
  end

  def cast(value)
    value
  end
end

ActiveRecord::Type.register(:encrypted, EncryptedType)

def UserType
  include AttrJson::Model
  attr_json :password, :encrypted
end

def Account < ActiveRecord::Base
  include AttrJson::Record
  attr_json :user, UserType.to_type
end

Nested Form attributes with array types

First off, I love your gem as it helps me building a project for my university - thanks!

My problem is the following: We're building a healthcare application where we need to construct FHIR datasets / json objects from Rails forms. The normal attributes are working fine, but some attributes are stored in an new array inside the hash and therefor need to have a name like "resource[name][ ][prefix][ ]" - notice the empty array sign to tell Rails to save this as an array.

The structure looks something like this:

{
  "id": "7632922c-8977-4126-91ec-4db42bbc95ed",
  "name": [
    {
      "text": "Dr Maximilian Rohleder-Kirsch",
      "given": [
        "Maximilian"
      ],
      "family": "Rohleder-Kirsch",
      "prefix": [
        "Dr"
      ]
    }
  ],
  "active": true,
  "gender": "male",
  "address": [
    {
      "use": "home",
      "line": [
        "Wilhelmstraße"
      ],
      "type": "physical",
      "postalCode": "35614"
    }
  ],
  "birthDate": "2020-12-14",
  "resourceType": "Patient",
  "deceasedBoolean": false,
  "deceasedDateTime": null
}

What I did then was reading your form guide and put this the accepted nested attributes into my patient model:
attr_json_accepts_nested_attributes_for :name

attr_json :name, HumanName.to_type, array: true
The name itself is a class with the AttrJson::Model included and looks like this:

class HumanName
    include AttrJson::Model

    attr_json :family, :string
    attr_json :text, :string
    attr_json :given, :string, array: true
    attr_json :prefix, :string, array: true
    attr_json :suffix, :string, array: true
end

Now my form looks like this:

<%= form_with(model: @patient, class: "col s12", url: patients_path, local: true) do |f| %>
    <%= f.fields_for :resource do |ff| %>
        <%= ff.hidden_field :local_id, value: SecureRandom.uuid %>
        <%= ff.fields_for :name do |name_field| %>
            <div class="input-field col l6 m12 s12">
                <%= name_field.text_field :prefix %>
                <%= name_field.label :prefix, "Titel" %>
            </div>
        <% end %>
    <% end %>
    <%= button_tag "Patient anlegen", type: "submit", class: "btn waves-effect btn-block light-green" %>
<% end %>

It's working but the name should be passed as an array to the params (notice the array: true in my model) but it's passed as an regular hash and the name of the form field is:

patient[resource][name][prefix] but should be like this: patient[resource][name][ ][prefix][ ].

Do I have to manually specify the names on the form fields to get this to work?

Sorry for the long text and thanks for any help!

Nested model validations

Hey again, sorry for bothering you one last time

I read the docs about validations and got one last question.
The validation is working, but I also want to show the validation messages translated by I18n.

Now, if I validate my "associated" address model like this:

class Address
    include AttrJson::Model

    attr_json :id, :integer 

    attr_json :use, :string
    attr_json :type, :string
    attr_json :text, :string
    attr_json :line, :string, array: true
    attr_json :city, :string
    attr_json :district, :string
    attr_json :state, :string
    attr_json :postalCode, :string
    attr_json :country, :string

    validates :postalCode, numericality: true
end

And then getting the errors the regular Rails way like tha (object is the form object here, patient in this case)t:

<% object.errors.full_messages.each do |msg| %
    <li class="white-text"><strong><%= msg %></strong></li>
<% end %>

Then I get the message "Address is not valid", which is fine. The next message "is not a number" is nested deeper in the according array/hash structure and therefore not shown when I just use this method.

Is there any way I could grab these messages in the standard way aswell or do you think that won't work because of the missin g has_one or has_many dependency?

[Question] How would I migrate from store_model?

Hello,

I have implemented a new feature on one of my companies apps using store_model, but realized that this gem has some nice features like dirty tracking. How would I go about migrating from the store_model gem? One of the things I like about store_model is the ability to write classes that encapsulate the behavior of the json fields. I assume that I can do the same using AttrJson::Model, but might be missing something as my records are showing up as empty.

Here is what I have so far:

class Shop::Messaging
  include AttrJson::Model

  attr_json :reminder, :string, default: I18n.t("sms.reminder_notice")
  attr_json :replenish, :string, default: I18n.t("sms.replenish_notice")
  attr_json :welcome, :string, default: I18n.t("sms.welcome_no_name")
end
class Shop < ApplicationRecord
  include AttrJson::Record
  include AttrJson::Record::QueryScopes

  attr_json :messaging, Shop::Messaging.to_type, default: Shop::Messaging.new

Do I need to drop the default? Specify that messaging is the column in the database?

Thank you!

Error assigning polymorphic nested model via json_attributes=

I have a low level ActiveAdmin dashboard that only devs can use and it'd be nice in some cases to just edit the raw json_attributes jsonb field with a large text area input, but I can't figure out the proper way to do that without breaking something else (and I couldn't find documentation / prior issues regarding how to do that). Is it possible to do such a thing?

Dup'ing models doesn't copy attributes

class TestModel
  include AttrJson::Model
  attr_json :test, :string
end

foo = TestModel.new(test: 'foo')
bar = foo.dup
bar.test = 'bar'
foo.test #=> bar

Same behavior with clone and deep_dup.

Question: Easy way to update dynamic generated nested paths?

Hi,
Im using attr_json to store documents that have deeply nested fields, is there a way to update specific fields having an path of keys in an array or a specific hash?

For example, lets say we have a "path" array ["summary", "products", "prices"] that we want to set to [1,2,3], if it were a static action it would be easy:

@document.summary.products.prices = [1,2,3]
@document.save

I tried to generate a nested hash and using a plain old .update like this:

d = {"summary" => {"products" => {"prices" => [1,2,3]}}}
@document.update(d)

But (as expected) it sets the specific path and resets the rest of the @document to defaults.

Right now Im just getting the whole document content as_json, changing the specified value using some dig magic and doing a @document.update with the whole JSON. It works but I would like to know is there a way to do this "properly"?

Have a changelog

Hey there! Looks like an awesome gem!

But I didn't see any kind of changelog file here. And it already has 7 releases. I believe it would be nice to start logging what's new in new releases. If you need some template, here's one:
https://keepachangelog.com/en/1.0.0/

an array type of models does weird things on nil

 attr_json :models, some_model_class_type, array: true

And then

 instance.models = [nil]
 instance.save!

Does weird and unexpected things -- in some cases raising unexpectedly with a convoluted stack trace.

Not sure what it should do. Either allow the nil through, or compact and remove it? Or should this be an option on array type?

For arrays of primitive types, trying to put a nil in there may not raise, looks like it usually passes through and allows it. Is that ok/expected?

get out of the way of rails 5.2 ActiveModel::Attributes

Rails 5.2 has a real attributes API for ActiveModel.

We're doing okay without it, and supporting Rails 5.0-5.2, but let's rename some of our methods and ivars in JsonAttribute::Model to step out of it's way and not conflict, to avoid any weirdness, and in case someone wants to use both.

(Jan 18 2024: We really should move to the models having ActiveModel attributes, and maybe include ActiveModel::API by defualt. See some things possible at https://betacraft.com/blog/08/06/2023/active-model-jsonb-coulmn.html, although it's not totally clear how this gets integrated with an AR container, as they suggest. https://www.reddit.com/r/ruby/comments/199yvhl/progressively_adopt_activemodelapi_for_your_poros/).

Should JsonAttribute::Model allow undefined attributes?

I would expect JsonAttribute::Model to throw an UnknownAttributeError if you pass it a property not defined using json_attribute.

class Foo
  include JsonAttribute::Record
  json_attribute :bars, Bar.to_type, array: true, default: []
end

class Bar
  include JsonAttribute::Model
  json_attribute :bar, :string
end

Foo.new(bars: [{ baz: 'hello' }])

NoMethodError: private method `read_attribute_for_serialization + attributes property in AttrJson:Model should be HashWithIndifferentAccess

Using
ruby 2.5.7
'rails', '5.2.3'
'attr_json', '0.7.0'
'active_model_serializers', '0.10.0'
'rails', ' 5.2.3'

Give the following context I have two attr_json models that inherit from a FieldSet Class

   class FieldSet
      include AttrJson::Model
    end

   class TitleSet < FieldSet
      attr_json :primary, Title.to_type, store_key: 'primary'
      attr_json :other, Title.to_type, store_key: 'other', array: true, default: []

      validates :primary, presence: true
    end

    class Title < FieldSet
      attr_json :label, :string
      attr_json :subtitle, :string
      attr_json :display, :string
      attr_json :display_label, :string
      attr_json :usage, :string
      attr_json :supplied, :boolean
      attr_json :language, :string
      attr_json :type, :string
      attr_json :authority_code, :string
      attr_json :id_from_auth, :string
      attr_json :part_number, :string
      attr_json :part_name, :string
    end

The TitlelSet model is a jsonb column on my Descriptive model

When using the following two serializers

class TitleSerializer < ActiveModel::Serializer
       attributes :label, :usage, :display, :subtitle, :language
  end


    class TitleSetSerializer < ActiveModel::Serializer
      attribute :primary do
        TitleSerializer.new(object.primary).as_json
      end

      attribute :other do
        ActiveModelSerializers::SerializableResource.new(object.other, each_serializer: TitleSerializer adapter: :attributes).as_json
      end
    end

In the Descriptive Serializer like so

  attribute :title do
      TitleSetSerializer.new(object.title).as_json
    end

I get

NoMethodError: private method read_attribute_for_serialization called for #<:TitleSet:0x00007f
5d13abe0d8>
Did you mean?  read_attribute_for_validation
from ~/.rbenv/versions/2.5.7/lib/ruby/gems/2.5.0/gems/active_model_serializers-0.10.10/lib/active_model/se
rializer.rb:397:in read_attribute_for_serialization

The reason for this is due to this method in AMS https://github.com/rails-api/active_model_serializers/blob/5ce94fda60b2e1389114ea0a91cd811a8203104e/lib/active_model/serializer.rb#L393-L399

The method in attr_json is here which is declared under private

def read_attribute_for_serialization(key)
attributes[key]
end

Adding the following to my FieldSet Class prevents it from breaking

public :read_attribute_for_serialization

However the title attributes all come back as null from the title serializer

{
    :primary => {
           :label => nil,
        :subtitle => nil,
        :language => nil
    },
      :other => [
            [0] {
                   :label => nil,
                :subtitle => nil,
                :language => nil
            }
     ]
}

This is because the attributes hash in AttrJson::Model is not a hash with indifferent access

I discovered this after plating around with it in the rails console like so

[6] pry(main)> descriptive.title.primary.attributes[:label]
nil
[7] pry(main)> descriptive.title.primary.attributes['label']
"The wintermind"

So the solution for the latter issue is just make attributes a HashWithIndifferentAccess

Getting error when setting a default value to a nested object

Problem

I am getting an error when I set a default value to a nested object (e.g. attr_json :nested_type, NestedType.to_type, default: NestedType.new).

The error:

NoMethodError: undefined method `attributes' for #<Object:0x00005652820c1810>
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/model.rb:248:in `=='
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/attribute_definition.rb:78:in `!='
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/attribute_definition.rb:78:in `has_default?'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/type/container_attribute.rb:52:in `block in deserialize'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/type/container_attribute.rb:47:in `each'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/type/container_attribute.rb:47:in `deserialize'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.2.2/lib/active_model/type/helpers/mutable.rb:8:in `cast'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/type/container_attribute.rb:19:in `cast'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.2.2/lib/active_model/attribute.rb:174:in `type_cast'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.2.2/lib/active_model/attribute.rb:42:in `value'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.2.2/lib/active_model/attribute_set.rb:41:in `fetch_value'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.2.2/lib/active_record/attribute_methods/read.rb:40:in `_read_attribute'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.2.2/lib/active_record/attribute_methods/read.rb:18:in `json_attributes'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/record.rb:182:in `public_send'
    /home/caiofilipemr/.rvm/gems/ruby-2.6.3/gems/attr_json-1.0.0/lib/attr_json/record.rb:182:in `block (2 levels) in attr_json'

Following the stack, the exception is thrown in other_object.attributes == self.attributes, which is called by AttributeDefinition#has_default? passing @default and NO_DEFAULT_PROVIDED. This is weird, because, if I understood correctly, NO_DEFAULT_PROVIDED will always be an empty Object and, therefore will never have an #attributes method.

Script to reproduce

Here is a script to reproduce the error. Just save into .rb file and execute ruby filename.rb:

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "activerecord", '6.0.2.2'
  gem 'sqlite3'
  gem 'attr_json'
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :examples, force: true do |t|
    t.json :json
  end
end

class DateSpan
  include AttrJson::Model

  attr_json :years,  :integer
  attr_json :months, :integer
  attr_json :days,   :integer
end

class Example < ActiveRecord::Base
  include AttrJson::Record

  attr_json :date_span, DateSpan.to_type, default: DateSpan.new
end

class BugTest < Minitest::Test
  def test_default_value_on_a_new_example
    Example.new.date_span
  end

  def test_default_value_on_a_existing_example
    Example.create!.date_span
  end
end

PS: we are using postgresql in our code, the sqlite3 here is just for the sake of simplicity.

Creating a CollectionProxy using `.to_list_type`

Hey @jrochkind,

I have this usecase that I wanted to run by you. I often use array attributes to store a collection of lightweight AttrJson models with an id field. This is much more ergonomic than creating a new table, especially if the collection will always be queries through the parent record. But querying over these arrays is pretty tedious, as it involves typical find/select workflow, and I have to write the finders on the parent. Creating new instances of these models is even more tedius. I end up building a lot of repetitious CRUD methods on the parent class.

A simpler alternative I've come up with is to create a Collection type for the model, and write custom serialize/deserialize methods to handle the conversion. This allows me to write custom finder methods like this:

module Product
  class Collection
    include Enumerable

    def find_by(**kwargs)
      # ...
    end

    def where(**kwargs)
      # ...
    end
  end

  class Type < ActiveRecord::Type::Value
    def type
      :jsonb
    end

    def cast(value)
      Collection.new(value)
    end

    def deserialize(value)
      return Collection.new(value) if value.kind_of?(Array)
      decoded = ::ActiveSupport::JSON.decode(value) rescue []
      Collection.new(decoded)
    end

    def serialize(value)
      case value
      when Array, Collection
        ::ActiveSupport::JSON.encode(value)
      when Hash
        ::ActiveSupport::JSON.encode(value.values)
      else
        super
      end
    end
  end
end

This is pretty much what Rails' CollectionProxy module does. But creating a Collection type for every model is pretty tedious, too! So that got me thinking what if there was a feature that rolled out a Collection type for an AttrJson model? The interface would be something like this:

attr_json :products, Product.to_list_type, default: Product.collection.new

Thus, anyone would be able to query over array attributes as seamlessly as they can query over Hash attributes.

What are your thoughts?

Using the merge operator to update the jsonb column

Hey @jrochkind,

I saw your comment about adding support for the merge operator. I've been thinking about this problem myself. I started using your library recently and I've quickly realized the problem you stated in the readme. Using optimistic locks everywhere I use attribute json columns isn't practical. We need a different solution.

I've scoped out how Rails compiles the update SQL, and I think my solution is 90% there. Here is my rough plan for getting there:

  • Monkey patch Arel::UpdateManager
module Arel
  class UpdateManager < Arel::TreeManager
    def set values
      if String === values
        @ast.values = [values]
      else
        @ast.values = values.map { |column, value|
          klass = if column.is_jsonb?
                    Nodes::JsonMerge
                  else
                    Nodes::Assignment
                  end

          klass.new(
            Nodes::UnqualifiedColumn.new(column),
            value
          )
        }
      end
      self
    end
  end
end
  • add #is_jsonb? method to Jsonb column type.
  • create an Arel::Nodes::JsonMerge node
module Arel
  module Nodes
    %w{
      JsonMerge
    }.each do |name|
      const_set name, Class.new(Binary)
    end
  end
end
  • monkeypatch Arel::Visitors::BindVisitor to add a visitor
module Arel
  module Visitors
    module BindVisitor
      def visit_Arel_Nodes_JsonMerge o, collector
        if o.right.is_a? Arel::Nodes::BindParam
          collector = visit o.left, collector
          collector << " || "
          # TODO: wrap the right node with `jsonb_set(column_name, '{}', value)`
          visit o.right, collector
        else
          super
        end
      end
    end
  end
end

or

module Arel
  module Visitors
    module PgJson
      private

      def visit_Arel_Nodes_JsonbMerge o, a
        json_infix o, a, '||'
      end

      def json_infix(o, a, opr)
        visit(Nodes::InfixOperation.new(opr, o.left, o.right), a)
      end
    end

    PostgreSQL.send :include, PgJson
  end
end
  • Monkeypatch ActiveRecord::Persistence::ClassMethods#_update_record to replace the value of the attr_json column with the changes hash.
module ActiveRecord::Persistence::ClassMethods
  def _update_record(values, constraints) # :nodoc:
    constraints = _substitute_values(constraints).map { |attr, bind| attr.eq(bind) }

    um = arel_table.where(
      constraints.reduce(&:and)
    ).compile_update(_substitute_values(_replace_jsonb_column(values)), primary_key)

    connection.update(um, "#{self} Update")
  end

  def _replace_jsonb_column(values)
    return values unless respond_to?(:attr_json_config)
    # Look up the name of the attr_json column
    container_name = attr_json_config.default_container_attribute
    # Determine if the key is in values hash
    return values unless values.key?(container_name)
    # Get the changes to this column
    change_arr = a.changes[container_name]
    changes = change_arr.last.reject {|k,v| change_arr.first[k] == v }
    # Replace the changes with the actual value in the `values` hash
    values[container_name] = changes
    values
  end
end

What do you think of this plan? It's not very clean, but it might work. Let me know if you'd be interested in a pull request or if it should be in its own repo.

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.