associate_jsonb 0.0.1 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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