attr_json 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "rubygems"
2
+ require "bundler/setup"
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new
7
+
8
+ task(default: [:spec])
data/bin/console ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # combustion defaults to RAILS_ENV=test, we don't want to here.
4
+ ENV['RAILS_ENV'] ||= "development"
5
+
6
+ require "bundler/setup"
7
+ require "attr_json"
8
+ require 'yaml'
9
+
10
+ # You can add fixtures and/or initialization code here to make experimenting
11
+ # with your gem easier. You can also use a different console, if you like.
12
+
13
+ # (If you use this, don't forget to add pry to your Gemfile!)
14
+ # require "pry"
15
+ # Pry.start
16
+
17
+ require "rspec"
18
+ require File.expand_path("../../spec/spec_helper.rb", __FILE__)
19
+
20
+ require_relative '../playground_models.rb'
21
+
22
+ require "irb"
23
+ IRB.start(__FILE__)
data/bin/rake ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rake' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rake", "rake")
data/bin/rspec ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rspec-core", "rspec")
data/bin/setup ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
9
+
10
+ rake db:create
11
+ rake db:migrate
data/config.ru ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+ require "bundler"
5
+
6
+ Bundler.require :default, :test
7
+
8
+ Combustion.initialize! :all
9
+ run Combustion::Application
@@ -0,0 +1,155 @@
1
+ # Dirty Tracking Support in AttrJson
2
+
3
+ In ordinary ActiveRecord, there is dirty/change-tracking support for attributes,
4
+ that lets you see what changes currently exist in the model compared to what
5
+ was fetched from the db, as well as what changed on the most recent save operation.
6
+
7
+ ```ruby
8
+ model = SomeModel.new
9
+ model.str_value = "some value"
10
+ model.changes_to_save
11
+ # => { 'str_value' => 'some_value'}
12
+ model.will_save_change_to_str_value?
13
+ # => true
14
+ model.save
15
+ model.saved_changes
16
+ # => { 'str_value' => 'some_value'}
17
+ model.str_value_before_last_save
18
+ # => nil
19
+ # and more
20
+ ```
21
+
22
+ You may be used to an older style of AR change-tracking methods,
23
+ involving `changes` and `previous_changes`. These older-style methods were
24
+ deprecated in Rails 5.1 and removed in Rails 5.2. It's a bit confusing and not
25
+ fully documented in AR, see more at
26
+ [these](https://www.levups.com/en/blog/2017/undocumented-dirty-attributes-activerecord-changes-rails51.html)
27
+ blog [posts](https://www.ombulabs.com/blog/rails/upgrades/active-record-5-1-api-changes.html),
28
+ and the initial [AR pull request](https://github.com/rails/rails/pull/25337).
29
+
30
+ AttrJson supports all of these new-style dirty-tracking methods, only
31
+ in Rails 5.1+. (*Sorry, our dirty tracking support does not work with Rails 5.0,
32
+ or old-style dirty API in Rails 5.1. Only new-style API in Rails 5.1+*). I wasn't
33
+ able to find a good way to get changes in the default Rails dirty tracking methods,
34
+ so instead **they are available off a separate `attr_json_changes` method**,
35
+ which also allows customization of if host record changes are also included.
36
+
37
+ To include the AttrJson dirty-tracking features, include the
38
+ `AttrJson::Record::Dirty` module in your active record model already including
39
+ `AttrJson::Record`:
40
+
41
+ ```ruby
42
+ class MyEmbeddedModel
43
+ include AttrJson::Model
44
+
45
+ attr_json :str, :string
46
+ end
47
+
48
+ class MyModel < ActiveRecord::Base
49
+ include AttrJson::Record
50
+ include AttrJson::Record::Dirty
51
+
52
+ attr_json :str, :string
53
+ attr_json :str_array, :string, array: true
54
+ attr_json :array_of_models, MyEmbeddedModel.to_type, array: true
55
+ end
56
+ ```
57
+
58
+ Now dirty changes are available off a `attr_json_changes` method.
59
+ The full suite of (new, Rails 5.1+) ActiveRecord dirty methods are supported,
60
+ both ones that take the attribute-name as an argument, and synthetic attribute-specific
61
+ methods. All top-level `attr_json`s are supported, including those that
62
+ include arrays and/or complex/nested/compound models.
63
+
64
+ ```ruby
65
+ model = MyModel.new
66
+ model.str = "some value"
67
+ model.attr_json_changes.will_save_change_to_str? #=> true
68
+ model.str_array = ["original1", "original2"]
69
+ model.array_of_models = [MyEmbeddedModel.new(str: "value")]
70
+ model.save
71
+
72
+ model.attr_json_changes.saved_changes
73
+ # => {"str"=>[nil, "some value"], "str_array"=>[nil, ["original1", "original2"]], "array_of_models"=>[nil, [#<MyEmbeddedModel:0x00007fb285d12330 @attributes={"str"=>"value"}, @validation_context=nil, @errors=#<ActiveModel::Errors:0x00007fb285d00400 @base=#<MyEmbeddedModel:0x00007fb285d12330 ...>, @messages={}, @details={}>>]]
74
+
75
+ model.str_array << "new1"
76
+
77
+ model.attr_json_changes.will_save_change_to_str_array? # => true
78
+ model.attr_json_changes.str_array_change_to_be_saved
79
+ # => [["original1", "original2"], ["original1", "original2", "new1"]]
80
+ ```
81
+
82
+ ## Cast representation vs Json representation
83
+
84
+ If you ask to see changes, you are going to see the changes reported as _cast_ values,
85
+ not _json_ values. For instance, you'll see your actual `AttrJson::Model`
86
+ objects instead of the hashes they serialize to, and ruby DateTime objects instead
87
+ of the ISO 8601 strings they serialize to.
88
+
89
+ If you'd like to see the the JSON-compat data structures instead, just tag
90
+ on the `as_json` modifier. For simple strings and ints and similar primitives,
91
+ it won't make a difference, for some types it will:
92
+
93
+ ```ruby
94
+ model.attr_json_changes.changes_to_save
95
+ #=> {
96
+ # json_str: [nil, "some value"]
97
+ # embedded_model: [nil, #<TestModel:0x00007fee25a04bf8 @attributes={"str"=>"foo"}>]
98
+ # json_date: [nil, {{ruby Date object}}]
99
+ # }
100
+
101
+ model.attr_json_changes.as_json.changes_to_save
102
+ #=> {
103
+ # json_str: [nil, "some_value"]
104
+ # embedded_model: [nil, {'str' => 'foo'}]
105
+ # json_date: [nil, "2018-03-23"]
106
+ # }
107
+
108
+ ```
109
+
110
+ All existing values are serialized every time you call this, since they are stored
111
+ in cast form internally. So there _could_ be perf implications, but generally it is looking fine.
112
+
113
+ ## Merge in ordinary AR attribute dirty tracking
114
+
115
+ Now you have one place to track 'ordinary' AR attribute "dirtyness"
116
+ (`model.some_attribute_will_change?`), and another place to track attr_json
117
+ dirty-ness (`my_model.attr_json_changes.some_json_attr_will_change?`).
118
+
119
+ You may wish you could have one place that tracked both, so your calling code
120
+ doesn't need to care if a given attribute is jsonb-backed or ordinary-column, and
121
+ is resilient if an attribute switches from one to another.
122
+
123
+ While we couldn't get this on the built-in dirty attributes, you *can* optionally
124
+ tell the `attr_json_changes` to include 'ordinary' changes from model too,
125
+ all in one place, by adding on the method `merged`.
126
+
127
+ ```ruby
128
+ model.attr_json_changes.merged.ordinary_attribute_will_change?
129
+ model.attr_json_changes.merged.attr_json_will_change?
130
+ model.attr_json_changes.merged.attr_json_will_change?
131
+ model.attr_json_changes.merged.changes_to_save
132
+ # => includes a hash with keys that are both ordinary AR attributes
133
+ # and attr_jsons, as applicable for changes.
134
+ ```
135
+
136
+ This will ordinarily include your json container attributes (eg `attr_jsons`)
137
+ too, as they will show up in ordinary AR dirty tracking since they are just AR
138
+ columns.
139
+
140
+ If you'd like to exclude these from the merged dirty tracking, pretend the json
141
+ container attributes don't exist and just focus on the individual `attr_json`s,
142
+ we got you covered:
143
+
144
+ ```ruby
145
+ model.attr_json_changes.merged(containers: false).attr_jsons_will_change?
146
+ # => always returns `nil`, the 'real' `attr_jsons` attribute is dead to us.
147
+ ```
148
+
149
+ ## Combine both of these modifiers at once no problem
150
+
151
+ ```ruby
152
+ model.attr_json_changes.as_json.merged.saved_changes
153
+ model.attr_json_changes.as_json.merged(containers: false).saved_changes
154
+ model.attr_json_changes.merged(containers: true).as_json.saved_changes
155
+ ```
data/doc_src/forms.md ADDED
@@ -0,0 +1,124 @@
1
+ # Use with Forms and Form builders
2
+
3
+ We've tried to make your attr_jsons just as easy to work with Rails form builders as ordinary attributes, including treating nested/compound models as if they were Rails associations in forms.
4
+
5
+ It's worked out pretty well. This is one of the more complex parts of our attr_json code, making it work with all the Rails weirdness on nested params, multi-param attributes (generally used for dates), etc. So if a bug is gonna happen somewhere, it's probably here. But at the moment it looks pretty solid and stable.
6
+
7
+ We even integration test with [simple_form](https://github.com/plataformatec/simple_form) and [cocoon](https://github.com/nathanvda/cocoon) (see below, some custom config may be required).
8
+ You can look at our [stub app used for integration tests](../spec/internal) as an example if you like.
9
+
10
+ ## Standard Rails form builder
11
+
12
+ ### Simple attributes
13
+
14
+ attr_json :some_string, :string
15
+ attr_json :some_datetime, :datetime
16
+
17
+ Use with form builder just as you would anything else.
18
+
19
+ f.text_field :some_string
20
+ f.datetimme_field :some_datetime
21
+
22
+ It _will_ work with the weird rails multi-param setting used for date fields.
23
+
24
+ Don't forget you gotta handle strong params same as you would for any ordinary attribute.
25
+
26
+ ### Arrays of simple attributes
27
+
28
+ attr_json :string_array, :string, array: true
29
+
30
+ The ActionView+ActiveRecord architecture isn't really setup for an array of "primitives", but you can make it work:
31
+
32
+ <% f.object.string_array.each do |str| %>
33
+ <%= f.text_field(:string_array, value: str, multiple: true) %>
34
+ <% end %>
35
+
36
+ That will display, submit and update fine, although when you try to handle reporting validation errors, you'll probably only be able to report on the array, not the specific element.
37
+
38
+ You may want to [use SimpleForm and create a custom input](https://github.com/plataformatec/simple_form#custom-inputs) to handle arrays of primitives in the way you want. Or you may want to consider an array of AttrJson::Model value types instead -- you can have a model with only one attribute! It can be handled more conventionally, see below.
39
+
40
+ ### Embedded/Nested AttrJson::Model attributes
41
+
42
+ With ordinary rails associations handled in the ordinary Rails way, you use [accepts_nested_attributes_for](http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html) for associations (to-one or to-many).
43
+
44
+ You can handle a single or array AttrJson::Model attr_json similarly, but you have to:
45
+
46
+ * include AttrJson::NestedAttributes in your model, and then
47
+ * use our own similar `attr_json_accepts_nested_attributes_for` instead. It _always_ has `allow_destroy`, and some of the other `accepts_nested_attributes_for` options also don't apply, see method for full options.
48
+
49
+ ```ruby
50
+ class Event
51
+ include AttrJson::Model
52
+
53
+ attr_json :name
54
+ attr_json :datetime
55
+ end
56
+ class MyRecord < ActiveRecord::Base
57
+ include AttrJson::Record
58
+ include AttrJson::NestedAttributes
59
+
60
+ attr_json :one_event, Event.to_type
61
+ attr_json :many_events, Event.to_type, array: true
62
+
63
+ attr_json_accepts_nested_attributes_for :one_event, :many_events
64
+ end
65
+
66
+ # In a form template...
67
+ <%= form_for(record) do |f| %>
68
+ <%= f.fields_for :one_event do |one_event_f| %>
69
+ <%= one_event_f.text_field :name %>
70
+ <%= one_event_f.datetime_field :datetime %>
71
+ <% end %>
72
+
73
+ <%= f.fields_for :many_events do |one_event_f| %>
74
+ <%= one_event_f.text_field :name %>
75
+ <%= one_event_f.datetime_field :datetime %>
76
+ <% end %>
77
+ <% end %>
78
+ ```
79
+
80
+ It should just work as you are expecting! You have to handle strong params as normal for when dealing with Rails associations, which can be tricky, but it's just the same here.
81
+
82
+ Note that the `AttrJsons::NestedAttributes` module also adds convenient rails-style `build_` methods for you. In the case above, you get a `build_one_event` and `build_many_event` (note singularization, cause that's how Rails does) method, which you can use much like Rails' `build_to_one_association` or `to_many_assocication.build` methods. You can turn off creation of the build methods by passing `define_build_method: false` to `attr_json_accepts_nested_attributes_for`.
83
+
84
+ ### Nested multi-level/compound embedded models
85
+
86
+ A model inside a model inside a model? Some single and some array? No problem, should just work.
87
+
88
+ Remember to add `include AttrJson::NestedAttributes` to all your AttrJson::Models (or at least non-terminal ones). Remember that Rails strong params have to be dealt with and are confusing here, but you deal with them the same way you would multi-nested associations on a rails form.
89
+
90
+ ## Simple Form
91
+
92
+ One of the nice parts about [simple_form](https://github.com/plataformatec/simple_form) is how you can just give it `f.input`, and it figures out the right input for you.
93
+
94
+ AttrJson by default, on an ActiveRecord::Base, doesn't register it's `attr_jsons` in the right way for simple_form to reflect and figure out their types. However, you can ask it to with `rails_attribute: true`.
95
+
96
+ ```ruby
97
+ class SomeRecord < ActiveRecord::Base
98
+ include AttrJson::Record
99
+
100
+ attr_json :my_date, :date, rails_attribute: true
101
+ end
102
+ ```
103
+
104
+ This will use the [ActiveRecord::Base.attribute](http://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html) method to register the attribute and type, and SimpleForm will now be able to automatically look up attribute type just as you expect. (Q: Should we make this default on?)
105
+
106
+ You don't need to do this in your nested AttrJson::Model classes, SimpleForm will already be able to reflect on their attribute types just fine as is.
107
+
108
+ ## Cocoon
109
+
110
+ [Cocoon](https://github.com/nathanvda/cocoon) is one easy way to implement js-powered add- and remove-field functionality for to-many associations nested on a form with Rails.
111
+
112
+ It _almost_ "just works" with nested/compound AttrJson::Model attributes, used with rails `fields_for` form builder as above. But Cocoon is looking for some ActiveRecord-specific methods that don't exist in our AttrJson::Models -- although it doesn't actually matter what these methods return, Cocoon works with our architecture either way.
113
+
114
+ Include the `AttrJson::Model::CocoonCompat` module in your **AttrJson::Model** classes to get these methods so Cocoon will work.
115
+
116
+ We have an integration test running a real rails app ensuring both simple_form and cocoon continue to work.
117
+
118
+ ## Reform?
119
+
120
+ If you would rather use [Reform](https://github.com/trailblazer/reform) than the standard Rails architecture(s) (which are somewhat tangled and weird for nested associations), I _believe_ it should Just Work (tm). Use it how you would with AR associations, and it should work for our nested AttrJson::Models too.
121
+
122
+ You shouldn't have to use the `AttrJson::NestedAttributes` module anywhere. You will have to do a lot more work yourself, as the nature of reform.
123
+
124
+ I have not tested or experimented extensively with reform+attr_json myself, feedback welcome.
@@ -0,0 +1,50 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'attr_json/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "attr_json"
8
+ spec.version = AttrJson::VERSION
9
+ spec.authors = ["Jonathan Rochkind"]
10
+ spec.email = ["jonathan@dnil.net"]
11
+
12
+ spec.summary = %q{ActiveRecord attributes stored serialized in a json column, super smooth.}
13
+ spec.description = %q{ActiveRecord attributes stored serialized in a json column, super smooth.
14
+ For Rails 5.0, 5.1, or 5.2. Typed and cast like Active Record. Supporting nested models,
15
+ dirty tracking, some querying (with postgres jsonb contains), and working smoothy with form builders.
16
+
17
+ Use your database as a typed object store via ActiveRecord, in the same models right next to
18
+ ordinary ActiveRecord column-backed attributes and associations. Your json-serialized attr_json
19
+ attributes use as much of the existing ActiveRecord architecture as we can.}
20
+ spec.homepage = "https://github.com/jrochkind/attr_json"
21
+ spec.license = "MIT"
22
+ spec.metadata = {
23
+ "homepage_uri" => "https://github.com/jrochkind/attr_json",
24
+ "source_code_uri" => "https://github.com/jrochkind/attr_json"
25
+ }
26
+
27
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
28
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
29
+ # if spec.respond_to?(:metadata)
30
+ # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
31
+ # else
32
+ # raise "RubyGems 2.0 or newer is required to protect against " \
33
+ # "public gem pushes."
34
+ # end
35
+
36
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
37
+ f.match(%r{^(test|spec|features)/})
38
+ end
39
+ spec.bindir = "exe"
40
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
41
+ spec.require_paths = ["lib"]
42
+
43
+ spec.add_runtime_dependency "activerecord", ">= 5.0.0", "< 5.3"
44
+
45
+ spec.add_development_dependency "bundler", "~> 1.14"
46
+ spec.add_development_dependency "rake", ">= 10.0"
47
+ spec.add_development_dependency "rspec", "~> 3.5"
48
+ spec.add_development_dependency "database_cleaner", "~> 1.5"
49
+ spec.add_development_dependency "yard-activesupport-concern"
50
+ end