associate_jsonb 0.0.3 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -31
  3. data/Rakefile +1 -3
  4. data/lib/associate_jsonb.rb +111 -2
  5. data/lib/associate_jsonb/arel_extensions/nodes/binary.rb +14 -0
  6. data/lib/associate_jsonb/arel_extensions/nodes/table_alias.rb +38 -0
  7. data/lib/associate_jsonb/arel_extensions/table.rb +40 -0
  8. data/lib/associate_jsonb/arel_extensions/visitors/postgresql.rb +113 -0
  9. data/lib/associate_jsonb/arel_extensions/visitors/visitor.rb +19 -0
  10. data/lib/associate_jsonb/arel_nodes/jsonb/attribute.rb +38 -0
  11. data/lib/associate_jsonb/arel_nodes/sql_casted_binary.rb +20 -0
  12. data/lib/associate_jsonb/arel_nodes/sql_casted_equality.rb +26 -12
  13. data/lib/associate_jsonb/associations/alias_tracker.rb +13 -0
  14. data/lib/associate_jsonb/associations/association_scope.rb +18 -45
  15. data/lib/associate_jsonb/associations/belongs_to_association.rb +8 -8
  16. data/lib/associate_jsonb/associations/builder/belongs_to.rb +5 -3
  17. data/lib/associate_jsonb/associations/join_dependency.rb +21 -0
  18. data/lib/associate_jsonb/attribute_methods.rb +19 -0
  19. data/lib/associate_jsonb/attribute_methods/read.rb +15 -0
  20. data/lib/associate_jsonb/connection_adapters/schema_creation.rb +168 -0
  21. data/lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_foreign_key_function.rb +9 -0
  22. data/lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_nested_set_function.rb +9 -0
  23. data/lib/associate_jsonb/connection_adapters/schema_definitions/alter_table.rb +40 -0
  24. data/lib/associate_jsonb/connection_adapters/schema_definitions/constraint_definition.rb +60 -0
  25. data/lib/associate_jsonb/connection_adapters/schema_definitions/reference_definition.rb +102 -0
  26. data/lib/associate_jsonb/connection_adapters/schema_definitions/table.rb +12 -0
  27. data/lib/associate_jsonb/connection_adapters/schema_definitions/table_definition.rb +25 -0
  28. data/lib/associate_jsonb/connection_adapters/schema_statements.rb +116 -0
  29. data/lib/associate_jsonb/persistence.rb +14 -0
  30. data/lib/associate_jsonb/predicate_builder.rb +15 -0
  31. data/lib/associate_jsonb/reflection.rb +2 -2
  32. data/lib/associate_jsonb/relation/where_clause.rb +19 -0
  33. data/lib/associate_jsonb/version.rb +1 -1
  34. data/lib/associate_jsonb/with_store_attribute.rb +54 -31
  35. metadata +34 -10
  36. data/lib/associate_jsonb/arel_node_extensions/binary.rb +0 -12
  37. data/lib/associate_jsonb/connection_adapters/reference_definition.rb +0 -64
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfb874f973a7bd4611949cd39c064ce2de1a06bf2a7cd68fc526ef7c41f0c3e4
4
- data.tar.gz: d7c7f3b56c0c247c5f73610e537f6a39a435cade4e67ce93cf29f6a856301968
3
+ metadata.gz: 861ea54462a02137ede260085fdb80e49d0ea3d4286d09d2367cfaa9d140dcc3
4
+ data.tar.gz: 1d6fd78832d2ddc7a3d503760b401ae97f453cd79f50dc1cd14314176d9d5fdb
5
5
  SHA512:
6
- metadata.gz: f70971458fa4e3833058cf3904a070374ced50507a033b3bc619bdbb0f8a0623f9fd61da4ae967c11d71853874296ddf5801f152cf0ec9f18be614cc27d1c961
7
- data.tar.gz: 2f737173b83cc355772c6d66f760b5bc56faead1fbdc63d4d0cb2d63d37a8a1c24eed79e8a69b5dd7e1f3088b2bd3374b8b30316688e06a72b38f76621b208aa
6
+ metadata.gz: dd8af89295273155cff22563212369930c15ff8bdcfece98e18a8f4c990d25037da443e2c345a0da99bb12d0b9ef447f7435a2c75ae1d7c62bc9e9a09bb3bac4
7
+ data.tar.gz: 5e267f48ff719e521c7f04e7a702d807a43dacadff43223499fc3d9c638020730db6b3b3105f36b1e90d466689bf92ce3fcbdd5c78f5498ef95ce987164082bf
data/README.md CHANGED
@@ -2,17 +2,98 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/associate_jsonb.svg)](https://badge.fury.io/rb/associate_jsonb)
4
4
 
5
- Basic ActiveRecord Associations using PostgreSQL JSONB columns, with built-in accessors and column indexes
5
+ #### Easy PostgreSQL JSONB extensions
6
+ **including:**
6
7
 
7
- <!-- This gem was created as a solution to this [task](http://cultofmartians.com/tasks/active-record-jsonb-associations.html) from [EvilMartians](http://evilmartians.com).
8
+ - Basic ActiveRecord Associations using PostgreSQL JSONB columns, with built-in accessors and column indexes
9
+ - Thread-Safe JSONB updates (well, as safe as they can be) using a custom nested version of `jsonb_set` (`jsonb_nested_set`)
8
10
 
9
11
  **Requirements:**
10
12
 
11
- - PostgreSQL (>= 9.6)
13
+ - PostgreSQL (>= 12)
14
+ - Rails 6.0.3.2
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'associate_jsonb'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ $ bundle install
28
+ ```
12
29
 
13
30
  ## Usage
14
31
 
15
- ### One-to-one and One-to-many associations
32
+ ### Jsonb Associations
33
+
34
+ #### One-to-One and One-to-Many associations
35
+
36
+ To set up your jsonb column, you can use the built in `add_reference`/`table.references` function. This will only add a new store column if it doesn't already exist
37
+
38
+ ```bash
39
+ rails g migration add_foreign_key_store_to_my_table
40
+ ```
41
+ ```ruby
42
+ class AddForeignKeyStoreToMyTable < ActiveRecord::Migration[6.0]
43
+ def change
44
+ add_reference :my_table, :user, store: :extra # => store created
45
+ add_reference :my_table, :label, store: :extra, null: false # => store already exists, NOT NULL check constraint added to `store->'label_id'`
46
+ # NOTE: you can also use a `change_table(:my_table) block`
47
+ end
48
+ end
49
+ ```
50
+
51
+ and
52
+
53
+ ```ruby
54
+ class CreateMyTable < ActiveRecord::Migration[6.0]
55
+ def change
56
+ create_table(:my_table) do |t|
57
+ t.references :user, store: :extra
58
+ t.references :label, store: :extra, null: false
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ If you add the `jsonb_foreign_key` function to your database, you can also create a foreign_key **check** constraint by using the same built-in `:foreign_key` option used in normal reference definitions.
65
+
66
+ **NOTE**: true foreign key references are not possible with jsonb attributes. This will instead create a CHECK constraint that looks for the referenced column using an `EXISTS` statement
67
+
68
+ ```bash
69
+ rails g migration add_jsonb_foreign_key_function
70
+ ```
71
+ ```ruby
72
+ class AddJsonbForeignKeyFunction < ActiveRecord::Migration[6.0]
73
+ def up
74
+ add_jsonb_foreign_key_function
75
+ end
76
+ end
77
+ ```
78
+ ```ruby
79
+ class CreateMyTable < ActiveRecord::Migration[6.0]
80
+ def change
81
+ create_table(:my_table) do |t|
82
+ t.references :user, store: :extra, foreign_key: true, null: false
83
+ end
84
+ end
85
+ end
86
+ ```
87
+ ```ruby
88
+ class CreateMyTable < ActiveRecord::Migration[6.0]
89
+ def change
90
+ create_table(:my_table) do |t|
91
+ t.references :person, store: :extra, foreign_key: { to_table: :users }, null: false
92
+ end
93
+ end
94
+ end
95
+ ```
96
+
16
97
 
17
98
  You can store all foreign keys of your model in one JSONB column, without having to create multiple columns:
18
99
 
@@ -35,49 +116,87 @@ class User < ActiveRecord::Base
35
116
  end
36
117
  ```
37
118
 
38
- Foreign keys for association on one model have to be unique, even if they use different store column.
119
+ ### Many-to-Many associations
120
+
121
+ Due to the ease of getting out-of-sync, and the complexity needed to build it, HABTM relation functionality has not been implemented through JSONB
122
+
123
+ ### jsonb_set based hash updates
39
124
 
40
- You can also use `add_references` in your migration to add JSONB column and index for it (if `index: true` option is set):
125
+ When enabled, *only* keys present in the updated hash and with values changed in memory will be updated.
126
+ To completely delete a `key/value` pair from an enabled attribute, set the key's value to `nil`.
127
+
128
+ e.g.
41
129
 
42
130
  ```ruby
43
- add_reference :profiles, :users, store: :extra, index: true
131
+ # given: instance#data == { "key_1"=>1,
132
+ # "key_2"=>2,
133
+ # "key_3"=> { "key_4"=>7,
134
+ # "key_5"=>8,
135
+ # "key_6"=>9 } }
136
+
137
+ instance.update({ key_1: "asdf", a: 1, key_2: nil, key_3: { key_5: nil }})
138
+
139
+ # instance#data => { "key_1"=>"asdf",
140
+ # "a"=>"asdf",
141
+ # "key_3"=> { "key_4"=>7,
142
+ # "key_6"=>9 } }
44
143
  ```
45
144
 
46
- ### Many-to-many associations
145
+ #### enabling/adding attribute types
47
146
 
48
- Due to the ease of getting out-of-sync, and the complexity needed to build it, HABTM relation functionality has not been implemented through JSONB
147
+ first, create the sql function
49
148
 
50
- #### Performance
149
+ ```bash
150
+ rails g migration add_jsonb_nested_set_function
151
+ ```
152
+ ```ruby
153
+ class AddJsonbNestedSetFunction < ActiveRecord::Migration[6.0]
154
+ def up
155
+ add_jsonb_nested_set_function
156
+ end
157
+ end
158
+ ```
51
159
 
52
- Compared to regular associations, fetching models associated via JSONB column has no drops in performance.
160
+ then in an initializer, enable key based updates:
53
161
 
54
- Getting the count of connected records is ~35% faster with associations via JSONB (tested on associations with up to 10 000 connections).
162
+ ```ruby
163
+ # config/initializers/associate_jsonb.rb
164
+ AssociateJsonb.enable_jsonb_set
165
+ ```
55
166
 
56
- Adding new connections is slightly faster with JSONB, for scopes up to 500 records connected to another record (total count of records in the table does not matter that much. If you have more then ~500 records connected to one record on average, and you want to add new records to the scope, JSONB associations will be slower then traditional:
167
+ - Key based updates rely on inheritance for allowed attribute types. Any attributes that respond true to `attr_type.is_a?(GivenClass)` for any enabled type classes will use `jsonb_nested_set`
57
168
 
58
- <img src="https://github.com/lebedev-yury/associate_jsonb/blob/master/doc/images/adding-associations.png?raw=true | width=500" alt="JSONB HAMTB is slower on adding associations" width="600">
169
+ - To add classes to the enabled list, pass them as arguments to `AssociateJsonb.add_hash_type(*klasses)`. Any arguments passed to `AssociateJsonb.enable_jsonb_set` are forwarded to `AssociateJsonb.add_hash_type`
59
170
 
60
- On the other hand, unassociating models from a big amount of associated models if faster with JSONB HABTM as the associations count grows:
171
+ - By default, calling `AssociateJsonb.enable_jsonb_set(*klasses)` without arguments, and no classes previously added, adds `ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb` to the allowed classes list
61
172
 
62
- <img src="https://github.com/lebedev-yury/associate_jsonb/blob/master/doc/images/deleting-associations.png?raw=true | width=500" alt="JSONB HAMTB is faster on removing associations" width="600">
173
+ #### disabling/removing attribute types
63
174
 
64
- ## Installation
175
+ - by default `jsonb_nested_set` updates are disabled.
65
176
 
66
- Add this line to your application's Gemfile:
177
+ - if you've enabled them and need to disable, use: `AssociateJsonb.disable_jsonb_set`
67
178
 
68
- ```ruby
69
- gem 'associate_jsonb'
70
- ```
179
+ - To remove a class from the allowed list while leaving nested set updates enabled, use `AssociateJsonb.remove_hash_type(*klasses)`.
180
+ Any arguments passed to `AssociateJsonb.disable_jsonb_set` are forwarded to `AssociateJsonb.remove_hash_type`
71
181
 
72
- And then execute:
182
+ ### Automatically delete nil value hash keys
73
183
 
74
- ```bash
75
- $ bundle install
184
+ When jsonb_set updates are disabled, jsonb columns are replaced with the current document (i.e. default rails behavior)
185
+
186
+ You are also given the option to automatically clear nil/null values from the hash automatically when jsonb_set is disabled
187
+
188
+ in an initializer:
189
+
190
+ ```ruby
191
+ # config/initializers/associate_jsonb.rb
192
+ AssociateJsonb.jsonb_delete_nil = true
76
193
  ```
77
194
 
195
+ Rules for classes to which this applies are the same as for `jsonb_nested_set`; add and remove classes through `AssociateJsonb.(add|remove)_hash_type(*klasses)`
196
+
78
197
  ## Developing
79
198
 
80
- To setup development environment, just run:
199
+ To setup development environment, run:
81
200
 
82
201
  ```bash
83
202
  $ bin/setup
@@ -89,11 +208,6 @@ To run specs:
89
208
  $ bundle exec rspec
90
209
  ```
91
210
 
92
- To run benchmarks (that will take a while):
93
-
94
- ```bash
95
- $ bundle exec rake benchmarks:habtm
96
- ``` -->
97
-
98
211
  ## License
212
+
99
213
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -4,13 +4,11 @@ rescue LoadError
4
4
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
5
  end
6
6
 
7
- Rake.add_rakelib 'benchmarks'
8
-
9
7
  require 'rdoc/task'
10
8
 
11
9
  RDoc::Task.new(:rdoc) do |rdoc|
12
10
  rdoc.rdoc_dir = 'rdoc'
13
- rdoc.title = 'AssociateJsonb::Associations'
11
+ rdoc.title = 'AssociateJsonb'
14
12
  rdoc.options << '--line-numbers'
15
13
  rdoc.rdoc_files.include('README.md')
16
14
  rdoc.rdoc_files.include('lib/**/*.rb')
@@ -12,22 +12,106 @@ require "mutex_m"
12
12
 
13
13
  require "zeitwerk"
14
14
  loader = Zeitwerk::Loader.for_gem
15
- loader.inflector.inflect "supported_rails_version" => "SUPPORTED_RAILS_VERSION"
15
+ loader.inflector.inflect(
16
+ "postgresql" => "PostgreSQL",
17
+ "supported_rails_version" => "SUPPORTED_RAILS_VERSION"
18
+ )
19
+ loader.collapse("#{__dir__}/associate_jsonb/connection_adapters/schema_definitions")
16
20
  loader.setup # ready!
17
21
 
18
22
  module AssociateJsonb
23
+ mattr_accessor :jsonb_hash_types, default: []
24
+ mattr_accessor :jsonb_set_removed, default: []
25
+ mattr_accessor :jsonb_set_enabled, default: false
26
+ mattr_accessor :jsonb_delete_nil, default: false
27
+ private_class_method :jsonb_hash_types=
28
+ private_class_method :jsonb_set_enabled=
29
+
30
+ def self.enable_jsonb_set(klass = nil, *classes)
31
+ add_hash_type(*Array(klass), *classes) unless klass.nil?
32
+ self.jsonb_set_enabled = true
33
+ end
34
+
35
+ def self.disable_jsonb_set(klass = nil, *classes)
36
+ remove_hash_type(*Array(klass), *classes) unless klass.nil?
37
+ self.jsonb_set_enabled = false
38
+ end
39
+
40
+ def self.add_hash_type(*classes)
41
+ self.jsonb_hash_types |= classes.flatten
42
+ end
43
+
44
+ def self.remove_hash_type(*classes)
45
+ self.jsonb_set_removed |= classes.flatten
46
+ end
47
+
48
+ def self.merge_hash?(v)
49
+ return false unless jsonb_set_enabled && v
50
+ self.jsonb_hash_types.any? { |type| v.is_a?(type) }
51
+ end
52
+
53
+ def self.is_hash?(v)
54
+ self.jsonb_hash_types.any? { |type| v.is_a?(type) }
55
+ end
19
56
  end
20
57
 
21
58
 
22
59
  # rubocop:disable Metrics/BlockLength
23
60
  ActiveSupport.on_load :active_record do
24
61
  loader.eager_load
62
+ AssociateJsonb.class_eval do
63
+ def self.enable_jsonb_set(klass = nil, *classes)
64
+ if klass.nil?
65
+ add_hash_type ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb if jsonb_hash_types.empty?
66
+ else
67
+ add_hash_type |= [*Array(klass), *classes].flatten
68
+ end
69
+ self.jsonb_set_enabled = true
70
+ end
71
+
72
+ self.enable_jsonb_set if jsonb_set_enabled
73
+
74
+ def self.remove_hash_type(*classes)
75
+ self.jsonb_hash_types -= classes.flatten
76
+ end
77
+ removed = jsonb_set_removed
78
+ self.remove_hash_type removed
79
+ self.send :remove_method, :jsonb_set_removed
80
+ self.send :remove_method, :jsonb_set_removed=
81
+ end
82
+
25
83
 
26
84
  ActiveRecord::Base.include AssociateJsonb::WithStoreAttribute
27
85
  ActiveRecord::Base.include AssociateJsonb::Associations
86
+ ActiveRecord::Base.include AssociateJsonb::AttributeMethods
87
+ ActiveRecord::Base.include AssociateJsonb::Persistence
28
88
 
29
89
  Arel::Nodes.include AssociateJsonb::ArelNodes
30
- Arel::Nodes::Binary.include AssociateJsonb::ArelNodeExtensions::Binary
90
+
91
+ Arel::Nodes::Binary.prepend(
92
+ AssociateJsonb::ArelExtensions::Nodes::Binary
93
+ )
94
+
95
+ Arel::Nodes::TableAlias.prepend(
96
+ AssociateJsonb::ArelExtensions::Nodes::TableAlias
97
+ )
98
+
99
+ Arel::Table.prepend(
100
+ AssociateJsonb::ArelExtensions::Table
101
+ )
102
+
103
+ Arel::Visitors::PostgreSQL.prepend(
104
+ AssociateJsonb::ArelExtensions::Visitors::PostgreSQL
105
+ )
106
+
107
+ Arel::Visitors::Visitor.singleton_class.prepend(
108
+ AssociateJsonb::ArelExtensions::Visitors::Visitor
109
+ )
110
+
111
+
112
+ ActiveRecord::Associations::AliasTracker.prepend(
113
+ AssociateJsonb::Associations::AliasTracker
114
+ )
31
115
 
32
116
  ActiveRecord::Associations::Builder::BelongsTo.extend(
33
117
  AssociateJsonb::Associations::Builder::BelongsTo
@@ -64,8 +148,33 @@ ActiveSupport.on_load :active_record do
64
148
  # ActiveRecord::Associations::Preloader::HasMany.prepend(
65
149
  # AssociateJsonb::Associations::Preloader::HasMany
66
150
  # )
151
+ %i[
152
+ AlterTable
153
+ ConstraintDefinition
154
+ ReferenceDefinition
155
+ SchemaCreation
156
+ Table
157
+ TableDefinition
158
+ ].each do |m|
159
+ includable = AssociateJsonb::ConnectionAdapters.const_get(m)
160
+ including =
161
+ begin
162
+ ActiveRecord::ConnectionAdapters::PostgreSQL.const_get(m)
163
+ rescue NameError
164
+ ActiveRecord::ConnectionAdapters.const_get(m)
165
+ end
166
+ including.prepend includable
167
+ rescue NameError
168
+ ActiveRecord::ConnectionAdapters::PostgreSQL.const_set(m, includable)
169
+ end
170
+
171
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(
172
+ AssociateJsonb::ConnectionAdapters::SchemaStatements
173
+ )
174
+
67
175
 
68
176
  ActiveRecord::Reflection::AbstractReflection.prepend AssociateJsonb::Reflection
177
+ ActiveRecord::PredicateBuilder.prepend AssociateJsonb::PredicateBuilder
69
178
  ActiveRecord::Relation::WhereClause.prepend AssociateJsonb::Relation::WhereClause
70
179
 
71
180
  ActiveRecord::ConnectionAdapters::ReferenceDefinition.prepend(
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelExtensions
6
+ module Nodes
7
+ module Binary
8
+ def original_left
9
+ left
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelExtensions
6
+ module Nodes
7
+ module TableAlias
8
+ attr_reader :store_tracker
9
+
10
+ def initialize(*args, store_columns: nil)
11
+ @store_columns = store_columns
12
+ super(*args)
13
+ end
14
+
15
+ def with_store_tracker(tracker)
16
+ @store_tracker = tracker
17
+ self
18
+ end
19
+
20
+ def [](name)
21
+ return super unless store_col = store_tracker&.get(name)
22
+
23
+ attr = ::Arel::Nodes::Jsonb::DashArrow.
24
+ new(self, self[store_col[:store]], store_col[:key])
25
+
26
+ if cast_as = (store_col[:cast] && store_col[:cast][:sql_type])
27
+ attr = ::Arel::Nodes::NamedFunction.new(
28
+ "CAST",
29
+ [ attr.as(cast_as) ]
30
+ )
31
+ end
32
+
33
+ Arel::Nodes::Jsonb::Attribute.new(self, name, attr)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelExtensions
6
+ module Table
7
+ attr_reader :store_tracker
8
+
9
+ def initialize(*args, store_tracker: nil, **opts)
10
+ @store_tracker = store_tracker
11
+ super(*args, **opts)
12
+ end
13
+
14
+ def alias(...)
15
+ super(...).with_store_tracker(store_tracker)
16
+ end
17
+
18
+ def with_store_tracker(tracker)
19
+ @store_tracker = tracker
20
+ self
21
+ end
22
+
23
+ def [](name)
24
+ return super unless store_col = store_tracker&.get(name)
25
+
26
+ attr = ::Arel::Nodes::Jsonb::DashDoubleArrow.
27
+ new(self, self[store_col[:store]], store_col[:key])
28
+
29
+ if cast_as = (store_col[:cast] && store_col[:cast][:sql_type])
30
+ attr = ::Arel::Nodes::NamedFunction.new(
31
+ "CAST",
32
+ [ attr.as(cast_as) ]
33
+ )
34
+ end
35
+
36
+ Arel::Nodes::Jsonb::Attribute.new(self, name, attr)
37
+ end
38
+ end
39
+ end
40
+ end