associate_jsonb 0.0.5 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
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