associate_jsonb 0.0.5 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2488f59e2d11f0055e7bf0f619397eb949517e61110d8e5b5a81409231f6f6fe
4
- data.tar.gz: dbebbe5383f20bdb95e359210df22161eecf1819d0148bede5e587b4a62b3f7b
3
+ metadata.gz: f0832e64c43e22ba206a98d0603c0e231c1606b17315f715a4050e19a1fdb21b
4
+ data.tar.gz: ff37b2777adf131f20ad9a95076d8797ecf30dc2ddeaa86913de5a457cf9fed4
5
5
  SHA512:
6
- metadata.gz: db5297d3d3557fef9ca21a7aceaebc13788006e3e1597156cfc108308ce902af258435b9c820bad6a9df88735f8f778774ad4fe198e6189067ae223dd0cf4ab4
7
- data.tar.gz: 8bf8a3a056de12214579f8ca8a8cf0a471bde370f531abd86da7d239802ccdb673e058484f4998d6fc0aec2082932b07431013cb222361e4af3c696f1bb8dc81
6
+ metadata.gz: 00fbd32bc9e66fa8bd85fcf9cab51850bc65443a99f66dbc3bcff5ca5d05e855624c62ac9a0aa7bcbed330a2650dbe149806c49bc3bb40ecd3aa6cdf002fde19
7
+ data.tar.gz: b134266f93576482f3558be28840081b803982ce369bd75497af2d9d2d2db30e3c27364247485ef2a4712705fb01183d80b812f6fa28f18d8f7db37db555bb88
data/README.md CHANGED
@@ -4,7 +4,85 @@
4
4
 
5
5
  #### PostgreSQL JSONB extensions including:
6
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 `jsonb_set`
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)`
8
86
 
9
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).
10
88
 
@@ -16,18 +16,70 @@ loader.inflector.inflect(
16
16
  "postgresql" => "PostgreSQL",
17
17
  "supported_rails_version" => "SUPPORTED_RAILS_VERSION"
18
18
  )
19
+ loader.collapse("#{__dir__}/associate_jsonb/connection_adapters/schema_definitions")
19
20
  loader.setup # ready!
20
21
 
21
22
  module AssociateJsonb
22
- mattr_accessor :safe_hash_classes, default: [
23
- ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb
24
- ]
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
25
56
  end
26
57
 
27
58
 
28
59
  # rubocop:disable Metrics/BlockLength
29
60
  ActiveSupport.on_load :active_record do
30
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
+
31
83
 
32
84
  ActiveRecord::Base.include AssociateJsonb::WithStoreAttribute
33
85
  ActiveRecord::Base.include AssociateJsonb::Associations
@@ -96,6 +148,30 @@ ActiveSupport.on_load :active_record do
96
148
  # ActiveRecord::Associations::Preloader::HasMany.prepend(
97
149
  # AssociateJsonb::Associations::Preloader::HasMany
98
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
+
99
175
 
100
176
  ActiveRecord::Reflection::AbstractReflection.prepend AssociateJsonb::Reflection
101
177
  ActiveRecord::PredicateBuilder.prepend AssociateJsonb::PredicateBuilder
@@ -13,58 +13,98 @@ module AssociateJsonb
13
13
  deleted = []
14
14
  finished = {}
15
15
  keys.each do |k|
16
- if original[k].is_a?(Hash) && updated[k].is_a?(Hash)
17
- finished[k], a, d = collect_hash_changes(original[k], updated[k], nesting ? "#{nesting},#{k}" : 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?
18
19
  added |= a
19
20
  deleted |= d
20
- else
21
- if updated[k].nil?
21
+ elsif updated[k].nil?
22
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
23
+ elsif original[k] != updated[k]
24
+ finished[k] = updated[k]
25
+ added << [(nesting ? "{#{nesting},#{k}}" : "{#{k}}"), updated[k]]
27
26
  end
28
27
  end
29
28
  [ finished, added, deleted ]
30
29
  end
31
30
 
32
- def is_hash_update?(o, collector)
31
+ def is_hash?(type)
32
+ AssociateJsonb.is_hash? type
33
+ end
34
+
35
+ def is_update?(collector)
33
36
  collector &&
34
- Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /UPDATE/) } &&
35
- AssociateJsonb.safe_hash_classes.any? {|t| o.value.type.is_a?(t) }
37
+ Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /UPDATE/) }
36
38
  rescue
37
39
  false
38
40
  end
39
41
 
40
- def visit_Arel_Nodes_BindParam(o, collector)
41
- if is_hash_update?(o, collector)
42
- value = o.value
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
43
48
 
44
- changes, additions, deletions =
45
- collect_hash_changes(
46
- value.original_value.presence || {},
47
- value.value.presence || {}
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
+ )
49
55
 
50
- json = +"COALESCE(#{quote_column_name(o.value.name)}, '{}'::jsonb)"
56
+ base_json = +"COALESCE(#{quote_column_name(t.name)}, '{}'::jsonb)"
57
+ json = base_json
51
58
 
52
- deletions.each do |del|
53
- json = +"(#{json} #- '#{del}')"
54
- end
59
+ deletions.each do |del|
60
+ json = +"(#{json} #- '#{del}')"
61
+ end
55
62
 
56
- additions.each do |add, value|
57
- collector.add_bind(o.value.with_value_from_user(value)) do |i|
58
- json = +"jsonb_set(#{json},'#{add}', $#{i}, true)"
59
- ''
60
- end
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
+ ''
61
68
  end
69
+ end
62
70
 
63
- collector << json
71
+ collector << json
72
+ end
64
73
 
65
- collector
66
- else
67
- collector.add_bind(o.value) { |i| "$#{i}" }
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}" }
68
108
  end
69
109
  end
70
110
  end
@@ -22,7 +22,7 @@ module AssociateJsonb
22
22
  key = (reflection.jsonb_store_key || foreign_key).to_s
23
23
  store = reflection.jsonb_store_attr
24
24
 
25
- mixin.instance_eval <<-CODE, __FILE__, __LINE__ + 1
25
+ mixin.instance_eval <<~CODE, __FILE__, __LINE__ + 1
26
26
  if attribute_names.include?(foreign_key)
27
27
  raise AssociateJsonb::Associations::
28
28
  ConflictingAssociation,
@@ -51,7 +51,7 @@ module AssociateJsonb
51
51
  foreign_type = :integer
52
52
  end
53
53
 
54
- mixin.instance_eval <<-CODE, __FILE__, __LINE__ + 1
54
+ mixin.instance_eval <<~CODE, __FILE__, __LINE__ + 1
55
55
  store_column_attribute(:#{store}, :#{foreign_key}, foreign_type, sql_type: sql_type, key: "#{key}", **opts)
56
56
  CODE
57
57
  end
@@ -1,3 +1,4 @@
1
+ # encoding: utf-8
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module AssociateJsonb
@@ -8,55 +9,153 @@ module AssociateJsonb
8
9
  sql = super
9
10
  sql << o.constraint_adds.map {|ct| visit_AddConstraint ct }.join(" ")
10
11
  sql << o.constraint_drops.map {|ct| visit_DropConstraint ct }.join(" ")
11
- add_jsonb_function(o, sql)
12
+ sql
12
13
  end
13
14
 
14
- def visit_TableDefinition(...)
15
- add_jsonb_function(o, super(...))
15
+ def visit_TableDefinition(o)
16
+ create_sql = +"CREATE#{table_modifier_in_create(o)} TABLE "
17
+ create_sql << "IF NOT EXISTS " if o.if_not_exists
18
+ create_sql << "#{quote_table_name(o.name)} "
19
+
20
+ statements = o.columns.map { |c| accept c }
21
+ statements << accept(o.primary_keys) if o.primary_keys
22
+
23
+ if supports_indexes_in_create?
24
+ statements.concat(o.indexes.map { |column_name, options| index_in_create(o.name, column_name, options) })
25
+ end
26
+
27
+ if supports_foreign_keys?
28
+ statements.concat(o.foreign_keys.map { |to_table, options| foreign_key_in_create(o.name, to_table, options) })
29
+ # statements.concat(o.constraints.map { |ct| visit_ConstraintDefinition(ct) })
30
+ end
31
+
32
+ create_sql << "(#{statements.join(', ')})" if statements.present?
33
+ add_table_options!(create_sql, table_options(o))
34
+ create_sql << " AS #{to_sql(o.as)}" if o.as
35
+ create_sql
16
36
  end
17
37
 
18
- def visit_ColumnDefinition(o)
19
- column_sql = super
20
- add_column_constraint!(o, column_sql, column_options(o))
21
- column_sql
38
+ def visit_ConstraintDeferral(o)
39
+ return "" unless o.deferrable_default?
40
+ return "NOT DEFERRABLE" unless o.deferrable?
41
+ initial =
42
+ case o.deferrable
43
+ when :immediate
44
+ "IMMEDIATE"
45
+ else
46
+ "DEFERRED"
47
+ end
48
+ "DEFERRABLE INITIALLY #{initial}"
22
49
  end
23
50
 
24
51
  def visit_ConstraintDefinition(o)
25
52
  +<<-SQL.squish
26
- CONSTRAINT #{o.name} CHECK (#{o.value})
53
+ CONSTRAINT #{quote_column_name(o.name)}
54
+ CHECK (#{o.value})
55
+ #{visit_ConstraintDeferral(o)}
56
+ #{o.not_valid? ? "NOT VALID" : ''}
27
57
  SQL
28
58
  end
29
59
 
30
60
  def visit_AddConstraint(o)
31
- "ADD #{accept(o)}"
61
+ sql = +""
62
+ if o.force?
63
+ sql << visit_DropConstraint(o)
64
+ sql << " "
65
+ end
66
+ sql << "ADD #{accept(o)}"
32
67
  end
33
68
 
34
- def visit_DropConstraint(o)
35
- "DROP CONSTRAINT #{o.name}"
69
+ def visit_DropConstraint(o, if_exists: false)
70
+ +<<-SQL.squish
71
+ DROP CONSTRAINT #{quote_column_name(o.name)}
72
+ #{o.force? ? "IF EXISTS" : ""}
73
+ SQL
36
74
  end
37
75
 
38
- def add_column_constraint!(o, sql, options)
39
- if options[:constraint]
40
- name = value = nil
41
- if options[:constraint].is_a?(Hash)
42
- name = quote_column_name(options[:constraint][:name]).presence
43
- value = options[:constraint][:value]
44
- else
45
- value = options[:constraint]
46
- end
47
- name ||= quote_column_name("#{o.name}_constraint_#{value.hash}")
48
- sql << " CONSTRAINT #{name} CHECK (#{value})"
49
- end
76
+ def visit_AddJsonbForeignKeyFunction(*)
77
+ <<~SQL
78
+ CREATE OR REPLACE FUNCTION jsonb_foreign_key
79
+ (
80
+ table_name text,
81
+ foreign_key text,
82
+ store jsonb,
83
+ key text,
84
+ type text default 'numeric',
85
+ nullable boolean default TRUE
86
+ )
87
+ RETURNS BOOLEAN AS
88
+ $BODY$
89
+ DECLARE
90
+ does_exist BOOLEAN;
91
+ BEGIN
92
+ IF store->key IS NULL
93
+ THEN
94
+ return nullable;
95
+ END IF;
50
96
 
51
- sql
97
+ EXECUTE FORMAT('SELECT EXISTS (SELECT 1 FROM %1$I WHERE %1$I.%2$I = CAST($1 AS ' || type || '))', table_name, foreign_key)
98
+ INTO does_exist
99
+ USING store->>key;
100
+
101
+ RETURN does_exist;
102
+ END;
103
+ $BODY$
104
+ LANGUAGE plpgsql;
105
+
106
+ SQL
52
107
  end
53
108
 
54
- def add_jsonb_function(o, sql)
55
- if sql =~ /jsonb_foreign_key/
56
- visit_AddJsonForeignKeyFunction(o) + sql
57
- else
58
- sql
109
+ def visit_AddJsonbNestedSetFunction(*)
110
+ <<~SQL
111
+ CREATE OR REPLACE FUNCTION jsonb_nested_set
112
+ (
113
+ target jsonb,
114
+ path text[],
115
+ new_value jsonb
116
+ )
117
+ RETURNS jsonb AS
118
+ $BODY$
119
+ DECLARE
120
+ new_json jsonb := '{}'::jsonb;
121
+ does_exist BOOLEAN;
122
+ current_path text[];
123
+ key text;
124
+ BEGIN
125
+ IF target #> path IS NOT NULL
126
+ THEN
127
+ return jsonb_set(target, path, new_value);
128
+ ELSE
129
+ new_json := target;
130
+
131
+ IF array_length(path, 1) > 1
132
+ THEN
133
+ FOREACH key IN ARRAY path[:(array_length(path, 1) - 1)]
134
+ LOOP
135
+ current_path := array_append(current_path, key);
136
+ IF new_json #> current_path IS NULL
137
+ THEN
138
+ new_json := jsonb_set(new_json, current_path, '{}'::jsonb, TRUE);
139
+ END IF;
140
+ END LOOP;
141
+ END IF;
142
+
143
+ return jsonb_set(new_json, path, new_value, TRUE);
144
+ END IF;
145
+ END;
146
+ $BODY$
147
+ LANGUAGE plpgsql;
148
+ SQL
149
+ end
150
+
151
+ def add_column_options!(sql, opts)
152
+ super
153
+
154
+ if opts[:constraint]
155
+ sql << " #{accept(ConstraintDefinition.new(**opts[:constraint]))}"
59
156
  end
157
+
158
+ sql
60
159
  end
61
160
  end
62
161
  end
@@ -0,0 +1,9 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ConnectionAdapters
6
+ class AddJsonbForeignKeyFunction
7
+ end
8
+ end
9
+ end
@@ -1,9 +1,9 @@
1
+ # encoding: utf-8
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module AssociateJsonb
4
5
  module ConnectionAdapters
5
- module TableDefinition #:nodoc:
6
-
6
+ class AddJsonbNestedSetFunction
7
7
  end
8
8
  end
9
9
  end
@@ -0,0 +1,40 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ConnectionAdapters
6
+ module AlterTable # :nodoc:
7
+ attr_reader :constraint_adds, :constraint_drops
8
+ def initialize(td)
9
+ super
10
+ @constraint_adds = []
11
+ @constraint_drops = []
12
+ end
13
+
14
+ def add_constraint(name = nil, **opts)
15
+ unless opts[:value].present?
16
+ raise ArgumentError.new("Invalid Add Constraint Options")
17
+ end
18
+
19
+ @constraint_adds << ConstraintDefinition.new(
20
+ **opts.reverse_merge(name: name)
21
+ )
22
+ end
23
+
24
+ def alter_constraint(name = nil, **opts)
25
+ opts[:force] = true
26
+ add_constraint(name, **opts)
27
+ end
28
+
29
+ def drop_constraint(name = nil, **opts)
30
+ opts = opts.reverse_merge(force: true, name: name, value: nil)
31
+
32
+ unless opts[:name].present? || opts[:value].present?
33
+ raise ArgumentError.new("Invalid Drop Constraint Options")
34
+ end
35
+
36
+ @constraint_drops << ConstraintDefinition.new(**opts)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,60 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ConnectionAdapters
6
+ class ConstraintDefinition
7
+ # rubocop:disable Metrics/ParameterLists
8
+ attr_reader :name, :value, :not_valid, :deferrable, :force
9
+ def initialize(value:, name: nil, not_valid: false, force: false, deferrable: true, **)
10
+ @name = name.presence
11
+ @value = value
12
+ @not_valid = not_valid
13
+ @deferrable = deferrable
14
+ @force = force
15
+
16
+ @name ||=
17
+ "rails_constraint_" \
18
+ "#{@value.hash}" \
19
+ "_#{not_valid ? "nv" : "v"}" \
20
+ "_#{deferrable ? "d" : "nd"}"
21
+ end
22
+
23
+ def deferrable_default?
24
+ deferrable.nil?
25
+ end
26
+
27
+
28
+ def name?
29
+ !!name
30
+ end
31
+
32
+ def value?
33
+ !!value
34
+ end
35
+
36
+ def not_valid?
37
+ !!not_valid
38
+ end
39
+
40
+ def deferrable?
41
+ !!deferrable
42
+ end
43
+
44
+ def force?
45
+ !!force
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ name: name,
51
+ value: value,
52
+ not_valid: not_valid,
53
+ deferrable: deferrable,
54
+ force: force
55
+ }
56
+ end
57
+ alias :to_hash :to_h
58
+ end
59
+ end
60
+ end
@@ -1,8 +1,9 @@
1
+ # encoding: utf-8
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module AssociateJsonb
4
5
  module ConnectionAdapters
5
- module ReferenceDefinition #:nodoc:
6
+ module ReferenceDefinition
6
7
  ForeignKeyDefinition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition
7
8
  # rubocop:disable Metrics/ParameterLists
8
9
  def initialize(
@@ -13,6 +14,7 @@ module AssociateJsonb
13
14
  )
14
15
  @store = store && store.to_sym
15
16
  @store_key = store_key && store_key.to_s unless options[:polymorphic]
17
+ @nullable = options[:null] != false
16
18
 
17
19
  super(name, **options)
18
20
  end
@@ -39,20 +41,24 @@ module AssociateJsonb
39
41
 
40
42
  if foreign_key && column_names.length == 1
41
43
  fk = ForeignKeyDefinition.new(table.name, foreign_table_name, foreign_key_options)
42
- columns.each do |col_name, type, _|
44
+ columns.each do |col_name, type, options|
45
+ options ||= {}
43
46
  value = <<-SQL.squish
44
47
  jsonb_foreign_key(
45
- '#{fk.to_table}',
46
- '#{fk.active_record_primary_key}',
47
- #{store},
48
- '#{col_name}',
49
- '#{type}'
48
+ '#{fk.to_table}'::text,
49
+ '#{fk.primary_key}'::text,
50
+ #{store}::jsonb,
51
+ '#{col_name}'::text,
52
+ '#{type}'::text,
53
+ #{nullable}
50
54
  )
51
55
  SQL
52
- table.constraint({
56
+ table.constraint(
53
57
  name: "#{table.name}_#{col_name}_foreign_key",
54
- value: value
55
- })
58
+ value: value,
59
+ not_valid: true,
60
+ deferrable: true
61
+ )
56
62
  end
57
63
  end
58
64
 
@@ -76,7 +82,7 @@ module AssociateJsonb
76
82
 
77
83
  protected
78
84
 
79
- attr_reader :store, :store_key
85
+ attr_reader :store, :store_key, :nullable
80
86
  end
81
87
  end
82
88
  end
@@ -0,0 +1,12 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ConnectionAdapters
6
+ module Table
7
+ def constraint(**opts)
8
+ @base.add_constraint(name, **opts)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ConnectionAdapters
6
+ module TableDefinition
7
+ attr_reader :constraints
8
+
9
+ def initialize(*,**)
10
+ super
11
+ @constraints = []
12
+ end
13
+
14
+ def constraint(name = nil, **opts)
15
+ unless opts[:value].present?
16
+ raise ArgumentError.new("Invalid Drop Constraint Options")
17
+ end
18
+
19
+ @constraints << ConstraintDefinition.new(
20
+ **opts.reverse_merge(name: name)
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,13 +1,116 @@
1
+ # encoding: utf-8
1
2
  # frozen_string_literal: true
2
3
 
3
- require "active_record/migration/join_table"
4
- require "active_support/core_ext/string/access"
5
- require "active_support/deprecation"
6
- require "digest/sha2"
7
-
8
4
  module AssociateJsonb
9
5
  module ConnectionAdapters
10
6
  module SchemaStatements
7
+ def add_jsonb_nested_set_function
8
+ execute schema_creation.accept(AddJsonbNestedSetFunction.new)
9
+ end
10
+
11
+ def add_jsonb_foreign_key_function
12
+ execute schema_creation.accept(AddJsonbForeignKeyFunction.new)
13
+ end
14
+
15
+ def create_table(table_name, **options)
16
+ td = create_table_definition(table_name, **options)
17
+
18
+ if options[:id] != false && !options[:as]
19
+ pk = options.fetch(:primary_key) do
20
+ ActiveRecord::Base.get_primary_key table_name.to_s.singularize
21
+ end
22
+
23
+ if pk.is_a?(Array)
24
+ td.primary_keys pk
25
+ else
26
+ td.primary_key pk, options.fetch(:id, :primary_key), **options.except(:comment)
27
+ end
28
+ end
29
+
30
+ yield td if block_given?
31
+
32
+ if options[:force]
33
+ drop_table(table_name, **options, if_exists: true)
34
+ end
35
+
36
+ result = execute schema_creation.accept td
37
+
38
+ td.indexes.each do |column_name, index_options|
39
+ add_index(table_name, column_name, index_options)
40
+ end
41
+
42
+ td.constraints.each do |ct|
43
+ add_constraint(table_name, **ct)
44
+ end
45
+
46
+ if table_comment = options[:comment].presence
47
+ change_table_comment(table_name, table_comment)
48
+ end
49
+
50
+ td.columns.each do |column|
51
+ change_column_comment(table_name, column.name, column.comment) if column.comment.present?
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ def add_constraint(table_name, **options)
58
+ at = create_alter_table table_name
59
+ at.add_constraint(**options)
60
+ execute schema_creation.accept at
61
+ end
62
+
63
+ def constraints(table_name) # :nodoc:
64
+ scope = quoted_scope(table_name)
65
+
66
+ result = query(<<~SQL, "SCHEMA")
67
+ SELECT
68
+ con.oid,
69
+ con.conname,
70
+ con.connamespace,
71
+ con.contype,
72
+ con.condeferrable,
73
+ con.condeferred,
74
+ con.convalidated,
75
+ pg_get_constraintdef(con.oid) as consrc
76
+ FROM pg_catalog.pg_constraint con
77
+ INNER JOIN pg_catalog.pg_class rel
78
+ ON rel.oid = con.conrelid
79
+ INNER JOIN pg_catalog.pg_namespace nsp
80
+ ON nsp.oid = connamespace
81
+ WHERE nsp.nspname = #{scope[:schema]}
82
+ AND rel.relname = #{scope[:name]}
83
+ ORDER BY rel.relname
84
+ SQL
85
+
86
+ result.map do |row|
87
+ {
88
+ oid: row[0],
89
+ name: row[1],
90
+ deferrable: row[4],
91
+ deferred: row[5],
92
+ validated: row[6],
93
+ definition: row[7],
94
+ type:
95
+ case row[3].to_s.downcase
96
+ when "c"
97
+ "CHECK"
98
+ when "f"
99
+ "FOREIGN KEY"
100
+ when "p"
101
+ "PRIMARY KEY"
102
+ when "u"
103
+ "UNIQUE"
104
+ when "t"
105
+ "TRIGGER"
106
+ when "x"
107
+ "EXCLUDE"
108
+ else
109
+ "UNKNOWN"
110
+ end
111
+ }
112
+ end
113
+ end
11
114
  end
12
115
  end
13
116
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module AssociateJsonb
5
- VERSION = "0.0.5"
5
+ VERSION = "0.0.7"
6
6
  end
@@ -34,7 +34,7 @@ module AssociateJsonb
34
34
  end
35
35
 
36
36
  included do
37
- instance_eval <<-CODE, __FILE__, __LINE__ + 1
37
+ instance_eval <<~CODE, __FILE__, __LINE__ + 1
38
38
  initialize_store_column_attribute_tracker
39
39
 
40
40
  after_initialize &set_store_column_attribute_values_on_init
@@ -106,7 +106,7 @@ module AssociateJsonb
106
106
  array = attribute_opts[:array]
107
107
  attribute attr, cast_type, **attribute_opts
108
108
 
109
- instance_eval <<-CODE, __FILE__, __LINE__ + 1
109
+ instance_eval <<~CODE, __FILE__, __LINE__ + 1
110
110
  add_store_column_attribute_name("#{attr}", :#{store}, "#{key}", { sql_type: sql_type, type: cast_type, opts: attribute_opts })
111
111
  CODE
112
112
 
@@ -117,47 +117,52 @@ module AssociateJsonb
117
117
  class InstanceMethodsOnActivation < Module
118
118
  def initialize(mixin, store, attribute, key, is_array)
119
119
  is_array = !!(is_array && attribute.to_s =~ /_ids$/)
120
- on_attr_change =
121
- is_array \
122
- ? "write_attribute(:#{attribute}, Array(given))" \
123
- : "super(given)"
124
- on_store_change = ->(var) {
125
- "write_attribute(:#{attribute}, #{
126
- is_array \
127
- ? "Array(#{var})" \
128
- : var
129
- })"
130
- }
131
120
 
121
+ array_or_attr = ->(value) {
122
+ is_array \
123
+ ? %Q(Array(#{value})) \
124
+ : %Q(#{value})
125
+ }
126
+
127
+ on_store_change = "_write_attribute(:#{attribute}, #{array_or_attr.call %Q(#{store}["#{key}"])})"
128
+ on_attr_change = "super(#{array_or_attr.call %Q(given)})"
132
129
 
133
130
  if is_array
134
- mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
131
+ mixin.class_eval <<~CODE, __FILE__, __LINE__ + 1
135
132
  def #{attribute}
136
133
  _read_attribute(:#{attribute}) || []
137
134
  end
138
135
  CODE
139
136
  end
140
137
 
141
- mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
138
+ mixin.class_eval <<~CODE, __FILE__, __LINE__ + 1
142
139
  def #{store}=(given)
143
- if !given
144
- given = {}
145
- #{store}.keys.each do |k|
146
- given[k] = nil
147
- end
140
+ if given.is_a?(::String)
141
+ given = ActiveSupport::JSON.decode(given) rescue nil
148
142
  end
149
- super(#{store}.deep_merge(given.deep_stringify_keys))
150
- if #{store}.key?("#{key}")
151
- write_attribute(:#{attribute}, #{on_store_change.call %Q(#{store}["#{key}"])})
152
- #{store}["#{key}"] = #{attribute}.presence
143
+
144
+ if AssociateJsonb.merge_hash?(self.class.attribute_types["#{store}"])
145
+ if !given
146
+ given = {}
147
+ #{store}.keys.each do |k|
148
+ given[k] = nil
149
+ end
150
+ end
151
+ super(#{store}.deep_merge(given.deep_stringify_keys))
152
+
153
+ self.#{attribute}= #{store}["#{key}"] if #{store}.key?("#{key}")
154
+ else
155
+ super given || {}
156
+ self.#{attribute}= #{store}["#{key}"]
153
157
  end
158
+
154
159
  #{store}
155
160
  end
156
161
 
157
162
  def #{attribute}=(given)
158
163
  #{on_attr_change}
159
164
  value = #{store}["#{key}"] = #{attribute}.presence
160
- _write_attribute(:#{store}, #{store})
165
+ #{store}.delete("#{key}") unless !value.nil? || AssociateJsonb.merge_hash?(self.class.attribute_types["#{store}"])
161
166
  value
162
167
  end
163
168
  CODE
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: associate_jsonb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sampson Crowley
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-07-21 00:00:00.000000000 Z
11
+ date: 2020-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -104,14 +104,18 @@ dependencies:
104
104
  - - "~>"
105
105
  - !ruby/object:Gem::Version
106
106
  version: 3.7.0
107
- description: |2
108
- This gem extends ActiveRecord to let you use PostgreSQL JSONB data for associations
107
+ description: |
108
+ This gem extends ActiveRecord to add additional functionality to JSONB
109
109
 
110
- Inspired by activerecord-jsonb-associations, but for use in Rails 6+ and
111
- ruby 2.7+ and with some unnecessary options and features (HABTM) removed
110
+ - use PostgreSQL JSONB data for associations
111
+ - thread-safe single-key updates to JSONB columns using `jsonb_set`
112
+ - extended `table#references` for easy migrations and indexes
113
+ - virtual JSONB foreign keys using check constraints
114
+ (NOTE: real foreign key constraints are not possible with PostgreSQL JSONB)
112
115
 
113
- BONUS: extended `table#references` for easy migrations and indexes
114
- (NOTE: real foreign key constraints are not possible with PostgreSQL JSONB)
116
+ Inspired by activerecord-jsonb-associations, but for use in Rails 6+ and
117
+ ruby 2.7+ and with some unnecessary options and features (HABTM) removed
118
+ and some additional features added
115
119
  email:
116
120
  - sampsonsprojects@gmail.com
117
121
  executables: []
@@ -151,11 +155,15 @@ files:
151
155
  - lib/associate_jsonb/attribute_methods.rb
152
156
  - lib/associate_jsonb/attribute_methods/read.rb
153
157
  - lib/associate_jsonb/connection_adapters.rb
154
- - lib/associate_jsonb/connection_adapters/reference_definition.rb
155
158
  - lib/associate_jsonb/connection_adapters/schema_creation.rb
156
- - lib/associate_jsonb/connection_adapters/schema_creation/alter_table.rb
159
+ - lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_foreign_key_function.rb
160
+ - lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_nested_set_function.rb
161
+ - lib/associate_jsonb/connection_adapters/schema_definitions/alter_table.rb
162
+ - lib/associate_jsonb/connection_adapters/schema_definitions/constraint_definition.rb
163
+ - lib/associate_jsonb/connection_adapters/schema_definitions/reference_definition.rb
164
+ - lib/associate_jsonb/connection_adapters/schema_definitions/table.rb
165
+ - lib/associate_jsonb/connection_adapters/schema_definitions/table_definition.rb
157
166
  - lib/associate_jsonb/connection_adapters/schema_statements.rb
158
- - lib/associate_jsonb/connection_adapters/table_definition.rb
159
167
  - lib/associate_jsonb/persistence.rb
160
168
  - lib/associate_jsonb/predicate_builder.rb
161
169
  - lib/associate_jsonb/reflection.rb
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AssociateJsonb
4
- module ConnectionAdapters
5
- module SchemaCreation
6
- class AlterTable # :nodoc:
7
- attr_reader :constraint_adds
8
- attr_reader :constraint_drops
9
-
10
- def initialize(td)
11
- super
12
- @constraint_adds = []
13
- @constraint_drops = []
14
- end
15
-
16
-
17
- def add_foreign_key(to_table, options)
18
- @foreign_key_adds << ForeignKeyDefinition.new(name, to_table, options)
19
- end
20
-
21
- def drop_foreign_key(name)
22
- @foreign_key_drops << name
23
- end
24
-
25
- def add_constraint(options)
26
- @foreign_key_adds << ConstraintDefinition.new(name, options)
27
- end
28
-
29
- def drop_constraint(options)
30
- @constraint_drops << ConstraintDefinition.new(name, options)
31
- end
32
- end
33
- end
34
- end
35
- end