associate_jsonb 0.0.1 → 0.0.7

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 +81 -1
  3. data/lib/associate_jsonb.rb +111 -1
  4. data/lib/associate_jsonb/arel_extensions/nodes/binary.rb +14 -0
  5. data/lib/associate_jsonb/arel_extensions/nodes/table_alias.rb +38 -0
  6. data/lib/associate_jsonb/arel_extensions/table.rb +40 -0
  7. data/lib/associate_jsonb/arel_extensions/visitors/postgresql.rb +113 -0
  8. data/lib/associate_jsonb/arel_extensions/visitors/visitor.rb +19 -0
  9. data/lib/associate_jsonb/arel_nodes/jsonb/attribute.rb +38 -0
  10. data/lib/associate_jsonb/arel_nodes/sql_casted_binary.rb +20 -0
  11. data/lib/associate_jsonb/arel_nodes/sql_casted_equality.rb +26 -12
  12. data/lib/associate_jsonb/associations/alias_tracker.rb +13 -0
  13. data/lib/associate_jsonb/associations/association_scope.rb +18 -45
  14. data/lib/associate_jsonb/associations/belongs_to_association.rb +8 -8
  15. data/lib/associate_jsonb/associations/builder/belongs_to.rb +5 -3
  16. data/lib/associate_jsonb/associations/join_dependency.rb +21 -0
  17. data/lib/associate_jsonb/attribute_methods.rb +19 -0
  18. data/lib/associate_jsonb/attribute_methods/read.rb +15 -0
  19. data/lib/associate_jsonb/connection_adapters/schema_creation.rb +162 -0
  20. data/lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_foreign_key_function.rb +9 -0
  21. data/lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_nested_set_function.rb +9 -0
  22. data/lib/associate_jsonb/connection_adapters/schema_definitions/alter_table.rb +40 -0
  23. data/lib/associate_jsonb/connection_adapters/schema_definitions/constraint_definition.rb +60 -0
  24. data/lib/associate_jsonb/connection_adapters/schema_definitions/reference_definition.rb +88 -0
  25. data/lib/associate_jsonb/connection_adapters/schema_definitions/table.rb +12 -0
  26. data/lib/associate_jsonb/connection_adapters/schema_definitions/table_definition.rb +25 -0
  27. data/lib/associate_jsonb/connection_adapters/schema_statements.rb +116 -0
  28. data/lib/associate_jsonb/persistence.rb +14 -0
  29. data/lib/associate_jsonb/predicate_builder.rb +15 -0
  30. data/lib/associate_jsonb/reflection.rb +2 -2
  31. data/lib/associate_jsonb/relation/where_clause.rb +19 -0
  32. data/lib/associate_jsonb/supported_rails_version.rb +6 -0
  33. data/lib/associate_jsonb/version.rb +1 -1
  34. data/lib/associate_jsonb/with_store_attribute.rb +59 -23
  35. metadata +39 -14
  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: ad94cc18f8d5f87d25f2ebe9d0b75309fb43a3f22fc1438266e05ce39c2a3064
4
- data.tar.gz: 016ce6443d9fdca1155d4b63fc18e6f6aaa18ef8887f73fe22312ee876d65f5d
3
+ metadata.gz: f0832e64c43e22ba206a98d0603c0e231c1606b17315f715a4050e19a1fdb21b
4
+ data.tar.gz: ff37b2777adf131f20ad9a95076d8797ecf30dc2ddeaa86913de5a457cf9fed4
5
5
  SHA512:
6
- metadata.gz: 7534ab4739036cff3691d4da6ac3d612cb6149c948088a3b7c24095e10bafce0d98e4bd96b7276c1ce84fd5e1017f039af48d59ed7d29f42827e90379a5e277d
7
- data.tar.gz: 97e169f1942e48131f7de7be8dc99551c525fbdec7914a3de26abe3738614f1ae838bbd3edf2fb8828097c60ae32a47546638053f447510b4ad3792677dd639b
6
+ metadata.gz: 00fbd32bc9e66fa8bd85fcf9cab51850bc65443a99f66dbc3bcff5ca5d05e855624c62ac9a0aa7bcbed330a2650dbe149806c49bc3bb40ecd3aa6cdf002fde19
7
+ data.tar.gz: b134266f93576482f3558be28840081b803982ce369bd75497af2d9d2d2db30e3c27364247485ef2a4712705fb01183d80b812f6fa28f18d8f7db37db555bb88
data/README.md CHANGED
@@ -2,7 +2,87 @@
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
+ #### PostgreSQL JSONB extensions including:
6
+ - Basic ActiveRecord Associations using PostgreSQL JSONB columns, with built-in accessors and column indexes
7
+ - Thread-Safe JSONB updates (well, as safe as they can be) using a custom nested version of `jsonb_set` (`jsonb_nested_set`)
8
+
9
+ **Requirements:**
10
+
11
+ - PostgreSQL (>= 12)
12
+ - Rails 6.0.3.2
13
+
14
+ ## Usage
15
+
16
+ ### Jsonb Associations
17
+
18
+
19
+ ### jsonb_set based hash updates
20
+
21
+ When enabled, *only* keys present in the updated hash and with values changed in memory will be updated.
22
+ To completely delete a key, value pair from an enabled attribute, set the key's value to `nil`.
23
+
24
+ e.g.
25
+ ```ruby
26
+ # given: instance#data == { "key_1"=>1,
27
+ # "key_2"=>2,
28
+ # "key_3"=> { "key_4"=>7,
29
+ # "key_5"=>8,
30
+ # "key_6"=>9 } }
31
+
32
+ instance.update({ key_1: "asdf", a: 1, key_2: nil, key_3: { key_5: nil }})
33
+ # instance#data => { "key_1"=>"asdf",
34
+ # "a"=>"asdf",
35
+ # "key_3"=> { "key_4"=>7,
36
+ # "key_6"=>9 } }
37
+
38
+ ```
39
+
40
+ #### enabling/adding attribute types
41
+ first, create the sql function
42
+ ```bash
43
+ rails g migration add_jsonb_nested_set_function
44
+ ```
45
+ ```ruby
46
+ class CreateState < ActiveRecord::Migration[6.0]
47
+ def up
48
+ add_jsonb_nested_set_function
49
+ end
50
+ end
51
+ ```
52
+
53
+ then in an initializer, enable key based updates:
54
+ ```ruby
55
+ # config/initializers/associate_jsonb.rb
56
+ AssociateJsonb.enable_jsonb_set
57
+ ```
58
+
59
+ 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`
60
+
61
+ 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`
62
+
63
+ 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
64
+
65
+ #### disabling/removing attribute types
66
+ by default `jsonb_nested_set` updates are disabled.
67
+
68
+ if you've enabled them and need to disable, use: `AssociateJsonb.disable_jsonb_set`
69
+
70
+ To remove a class from the allowed list while leaving nested set updates enabled, use `AssociateJsonb.remove_hash_type(*klasses)`.
71
+ Any arguments passed to `AssociateJsonb.disable_jsonb_set` are forwarded to `AssociateJsonb.remove_hash_type`
72
+
73
+ ### Automatically delete nil value hash keys
74
+
75
+ When jsonb_set updates are disabled, jsonb columns are replaced with the current document (i.e. default rails behavior)
76
+
77
+ You are also given the option to automatically clear nil/null values from the hash automatically
78
+
79
+ in an initializer, enable stripping nil values:
80
+ ```ruby
81
+ # config/initializers/associate_jsonb.rb
82
+ AssociateJsonb.jsonb_delete_nil = true
83
+ ```
84
+
85
+ Rules for classes to with this applies are the same as for `jsonb_nested_set`; add and remove classes through `AssociateJsonb.(add|remove)_hash_type(*klasses)`
6
86
 
7
87
  <!-- 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
88
 
@@ -12,21 +12,106 @@ require "mutex_m"
12
12
 
13
13
  require "zeitwerk"
14
14
  loader = Zeitwerk::Loader.for_gem
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")
15
20
  loader.setup # ready!
16
21
 
17
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
18
56
  end
19
57
 
20
58
 
21
59
  # rubocop:disable Metrics/BlockLength
22
60
  ActiveSupport.on_load :active_record do
23
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
+
24
83
 
25
84
  ActiveRecord::Base.include AssociateJsonb::WithStoreAttribute
26
85
  ActiveRecord::Base.include AssociateJsonb::Associations
86
+ ActiveRecord::Base.include AssociateJsonb::AttributeMethods
87
+ ActiveRecord::Base.include AssociateJsonb::Persistence
27
88
 
28
89
  Arel::Nodes.include AssociateJsonb::ArelNodes
29
- 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
+ )
30
115
 
31
116
  ActiveRecord::Associations::Builder::BelongsTo.extend(
32
117
  AssociateJsonb::Associations::Builder::BelongsTo
@@ -63,8 +148,33 @@ ActiveSupport.on_load :active_record do
63
148
  # ActiveRecord::Associations::Preloader::HasMany.prepend(
64
149
  # AssociateJsonb::Associations::Preloader::HasMany
65
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
+
66
175
 
67
176
  ActiveRecord::Reflection::AbstractReflection.prepend AssociateJsonb::Reflection
177
+ ActiveRecord::PredicateBuilder.prepend AssociateJsonb::PredicateBuilder
68
178
  ActiveRecord::Relation::WhereClause.prepend AssociateJsonb::Relation::WhereClause
69
179
 
70
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
@@ -0,0 +1,113 @@
1
+ module AssociateJsonb
2
+ module ArelExtensions
3
+ module Visitors
4
+ module PostgreSQL
5
+ private
6
+ def collect_hash_changes(original, updated, nesting = nil)
7
+ keys = original.keys.map(&:to_s)
8
+ updated_keys = updated.keys.map(&:to_s)
9
+ keys |= updated_keys
10
+ original = original.with_indifferent_access
11
+ updated = updated.with_indifferent_access
12
+ added = []
13
+ deleted = []
14
+ finished = {}
15
+ keys.each do |k|
16
+ if updated[k].is_a?(Hash)
17
+ finished[k], a, d = collect_hash_changes(original[k].is_a?(Hash) ? original[k] : {}, updated[k], nesting ? "#{nesting},#{k}" : k)
18
+ a = [[(nesting ? "{#{nesting},#{k}}" : "{#{k}}"), {}]] if original[k].nil? && a.blank?
19
+ added |= a
20
+ deleted |= d
21
+ elsif updated[k].nil?
22
+ deleted << (nesting ? "{#{nesting},#{k}}" : "{#{k}}") if updated_keys.include?(k)
23
+ elsif original[k] != updated[k]
24
+ finished[k] = updated[k]
25
+ added << [(nesting ? "{#{nesting},#{k}}" : "{#{k}}"), updated[k]]
26
+ end
27
+ end
28
+ [ finished, added, deleted ]
29
+ end
30
+
31
+ def is_hash?(type)
32
+ AssociateJsonb.is_hash? type
33
+ end
34
+
35
+ def is_update?(collector)
36
+ collector &&
37
+ Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /UPDATE/) }
38
+ rescue
39
+ false
40
+ end
41
+
42
+ def is_insert?(collector)
43
+ collector &&
44
+ Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /INSERT INTO/) }
45
+ rescue
46
+ false
47
+ end
48
+
49
+ def visit_BindHashChanges(t, collector)
50
+ changes, additions, deletions =
51
+ collect_hash_changes(
52
+ t.original_value.presence || {},
53
+ t.value.presence || {}
54
+ )
55
+
56
+ base_json = +"COALESCE(#{quote_column_name(t.name)}, '{}'::jsonb)"
57
+ json = base_json
58
+
59
+ deletions.each do |del|
60
+ json = +"(#{json} #- '#{del}')"
61
+ end
62
+
63
+ coalesced_paths = []
64
+ additions.sort.each do |add, value|
65
+ collector.add_bind(t.with_value_from_user(value)) do |i|
66
+ json = +"jsonb_nested_set(#{json},'#{add}', COALESCE($#{i}, '{}'::jsonb))"
67
+ ''
68
+ end
69
+ end
70
+
71
+ collector << json
72
+ end
73
+
74
+ def visit_Arel_Nodes_BindParam(o, collector)
75
+ catch(:nodes_bound) do
76
+ if AssociateJsonb.jsonb_set_enabled
77
+ catch(:not_hashable) do
78
+ if is_hash?(o.value.type)
79
+ if is_update?(collector)
80
+ visit_BindHashChanges(o.value, collector)
81
+
82
+ throw :nodes_bound, collector
83
+ elsif is_insert?(collector)
84
+ value = o.value
85
+
86
+ value, _, _ =
87
+ collect_hash_changes(
88
+ {},
89
+ value.value.presence || {}
90
+ )
91
+ throw :nodes_bound, collector.add_bind(o.value.with_cast_value(value)) { |i| "$#{i}"}
92
+ else
93
+ throw :not_hashable
94
+ end
95
+ else
96
+ throw :not_hashable
97
+ end
98
+ end
99
+ elsif AssociateJsonb.jsonb_delete_nil && is_hash?(o.value.type)
100
+ value, _, _ =
101
+ collect_hash_changes(
102
+ {},
103
+ o.value.value.presence || {}
104
+ )
105
+ throw :nodes_bound, collector.add_bind(o.value.with_cast_value(value)) { |i| "$#{i}" }
106
+ end
107
+ throw :nodes_bound, collector.add_bind(o.value) { |i| "$#{i}" }
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end