associate_jsonb 0.0.4 → 0.0.5

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: 5cb22feba951a6241ccada849b6d17ae06a4a84c797a49ae139844917d50474c
4
- data.tar.gz: 910b39ddc0f89d8525439d9d4e74244a62bbf3f4d07cb333306879312588dfda
3
+ metadata.gz: 2488f59e2d11f0055e7bf0f619397eb949517e61110d8e5b5a81409231f6f6fe
4
+ data.tar.gz: dbebbe5383f20bdb95e359210df22161eecf1819d0148bede5e587b4a62b3f7b
5
5
  SHA512:
6
- metadata.gz: 5382c87315fbbbc8bc52ebb5efb605e60373c7f24e9d681a008f5d5c53dc26649df89f7233ca1b3b6962fdd16a891c9072e55577a3c40e655faa54f3ec0dc375
7
- data.tar.gz: 3570c685cde53a273f70f5d434d1daacbc32b260b0b0500a3e6ef801b18a50fdd007a1970ede0d692a5eee89a87eba46b73ced15019514cfafece65b01c588a8
6
+ metadata.gz: db5297d3d3557fef9ca21a7aceaebc13788006e3e1597156cfc108308ce902af258435b9c820bad6a9df88735f8f778774ad4fe198e6189067ae223dd0cf4ab4
7
+ data.tar.gz: 8bf8a3a056de12214579f8ca8a8cf0a471bde370f531abd86da7d239802ccdb673e058484f4998d6fc0aec2082932b07431013cb222361e4af3c696f1bb8dc81
data/README.md CHANGED
@@ -2,7 +2,9 @@
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 `jsonb_set`
6
8
 
7
9
  <!-- 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
10
 
@@ -12,10 +12,16 @@ require "mutex_m"
12
12
 
13
13
  require "zeitwerk"
14
14
  loader = Zeitwerk::Loader.for_gem
15
- loader.inflector.inflect "supported_rails_version" => "SUPPORTED_RAILS_VERSION"
15
+ loader.inflector.inflect(
16
+ "postgresql" => "PostgreSQL",
17
+ "supported_rails_version" => "SUPPORTED_RAILS_VERSION"
18
+ )
16
19
  loader.setup # ready!
17
20
 
18
21
  module AssociateJsonb
22
+ mattr_accessor :safe_hash_classes, default: [
23
+ ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb
24
+ ]
19
25
  end
20
26
 
21
27
 
@@ -25,6 +31,8 @@ ActiveSupport.on_load :active_record do
25
31
 
26
32
  ActiveRecord::Base.include AssociateJsonb::WithStoreAttribute
27
33
  ActiveRecord::Base.include AssociateJsonb::Associations
34
+ ActiveRecord::Base.include AssociateJsonb::AttributeMethods
35
+ ActiveRecord::Base.include AssociateJsonb::Persistence
28
36
 
29
37
  Arel::Nodes.include AssociateJsonb::ArelNodes
30
38
 
@@ -40,6 +48,10 @@ ActiveSupport.on_load :active_record do
40
48
  AssociateJsonb::ArelExtensions::Table
41
49
  )
42
50
 
51
+ Arel::Visitors::PostgreSQL.prepend(
52
+ AssociateJsonb::ArelExtensions::Visitors::PostgreSQL
53
+ )
54
+
43
55
  Arel::Visitors::Visitor.singleton_class.prepend(
44
56
  AssociateJsonb::ArelExtensions::Visitors::Visitor
45
57
  )
@@ -86,6 +98,7 @@ ActiveSupport.on_load :active_record do
86
98
  # )
87
99
 
88
100
  ActiveRecord::Reflection::AbstractReflection.prepend AssociateJsonb::Reflection
101
+ ActiveRecord::PredicateBuilder.prepend AssociateJsonb::PredicateBuilder
89
102
  ActiveRecord::Relation::WhereClause.prepend AssociateJsonb::Relation::WhereClause
90
103
 
91
104
  ActiveRecord::ConnectionAdapters::ReferenceDefinition.prepend(
@@ -23,7 +23,7 @@ module AssociateJsonb
23
23
  def [](name)
24
24
  return super unless store_col = store_tracker&.get(name)
25
25
 
26
- attr = ::Arel::Nodes::Jsonb::DashArrow.
26
+ attr = ::Arel::Nodes::Jsonb::DashDoubleArrow.
27
27
  new(self, self[store_col[:store]], store_col[:key])
28
28
 
29
29
  if cast_as = (store_col[:cast] && store_col[:cast][:sql_type])
@@ -0,0 +1,73 @@
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 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)
18
+ added |= a
19
+ deleted |= d
20
+ else
21
+ if 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
+ end
29
+ [ finished, added, deleted ]
30
+ end
31
+
32
+ def is_hash_update?(o, collector)
33
+ 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) }
36
+ rescue
37
+ false
38
+ end
39
+
40
+ def visit_Arel_Nodes_BindParam(o, collector)
41
+ if is_hash_update?(o, collector)
42
+ value = o.value
43
+
44
+ changes, additions, deletions =
45
+ collect_hash_changes(
46
+ value.original_value.presence || {},
47
+ value.value.presence || {}
48
+ )
49
+
50
+ json = +"COALESCE(#{quote_column_name(o.value.name)}, '{}'::jsonb)"
51
+
52
+ deletions.each do |del|
53
+ json = +"(#{json} #- '#{del}')"
54
+ end
55
+
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
61
+ end
62
+
63
+ collector << json
64
+
65
+ collector
66
+ else
67
+ collector.add_bind(o.value) { |i| "$#{i}" }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -44,23 +44,21 @@ module AssociateJsonb
44
44
  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
45
45
 
46
46
  def apply_jsonb_equality(scope, table, jsonb_column, store_key, foreign_key, value, foreign_klass)
47
- sql_type = type = node_klass = nil
47
+ sql_type = type = nil
48
48
  begin
49
49
  type = foreign_klass.attribute_types[foreign_key.to_s]
50
50
  raise "type not found" unless type.present?
51
51
  sql_type = foreign_klass.columns_hash[foreign_key.to_s]
52
52
  raise "not a column" unless sql_type.present?
53
53
  sql_type = sql_type.sql_type
54
- node_klass = Arel::Nodes::Jsonb::DashArrow
55
54
  rescue
56
55
  type = ActiveModel::Type::String.new
57
56
  sql_type = "text"
58
- node_klass = Arel::Nodes::Jsonb::DashDoubleArrow
59
57
  end
60
58
 
61
59
  scope.where!(
62
60
  Arel::Nodes::SqlCastedEquality.new(
63
- node_klass.new(table, table[jsonb_column], store_key),
61
+ Arel::Nodes::Jsonb::DashDoubleArrow.new(table, table[jsonb_column], store_key),
64
62
  sql_type,
65
63
  Arel::Nodes::BindParam.new(
66
64
  ActiveRecord::Relation::QueryAttribute.new(
@@ -4,14 +4,14 @@
4
4
  module AssociateJsonb
5
5
  module Associations
6
6
  module BelongsToAssociation #:nodoc:
7
- def replace_keys(record)
8
- return super unless reflection.options.key?(:store)
9
-
10
- owner[reflection.foreign_key] =
11
- record._read_attribute(
12
- reflection.association_primary_key(record.class)
13
- )
14
- end
7
+ # def replace_keys(record)
8
+ # return super unless reflection.options.key?(:store)
9
+ #
10
+ # owner[reflection.foreign_key] =
11
+ # record._read_attribute(
12
+ # reflection.association_primary_key(record.class)
13
+ # )
14
+ # end
15
15
  end
16
16
  end
17
17
  end
@@ -0,0 +1,19 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module AttributeMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include Read
10
+ end
11
+
12
+ private
13
+ def attributes_with_info(attribute_names)
14
+ attribute_names.each_with_object({}) do |name, attrs|
15
+ attrs[name] = _fetch_attribute(name)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module AttributeMethods
6
+ module Read
7
+ extend ActiveSupport::Concern
8
+
9
+ def _fetch_attribute(attr_name, &block) # :nodoc
10
+ sync_with_transaction_state if @transaction_state&.finalized?
11
+ @attributes.fetch(attr_name.to_s, &block)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -3,18 +3,25 @@
3
3
  module AssociateJsonb
4
4
  module ConnectionAdapters
5
5
  module ReferenceDefinition #:nodoc:
6
+ ForeignKeyDefinition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition
6
7
  # rubocop:disable Metrics/ParameterLists
7
8
  def initialize(
8
9
  name,
9
10
  store: false,
11
+ store_key: false,
10
12
  **options
11
13
  )
12
14
  @store = store && store.to_sym
15
+ @store_key = store_key && store_key.to_s unless options[:polymorphic]
13
16
 
14
17
  super(name, **options)
15
18
  end
16
19
  # rubocop:enable Metrics/ParameterLists
17
20
 
21
+ def column_name
22
+ store_key || super
23
+ end
24
+
18
25
  def add_to(table)
19
26
  return super unless store
20
27
 
@@ -25,40 +32,51 @@ module AssociateJsonb
25
32
  should_add_col = table.columns.none? {|col| col.name.to_sym == store}
26
33
  end
27
34
 
28
- table.column(store, :jsonb, null: false, default: {}) if should_add_col
35
+ if should_add_col
36
+ opts = { null: false, default: {} }
37
+ table.column(store, :jsonb, **opts)
38
+ end
39
+
40
+ if foreign_key && column_names.length == 1
41
+ fk = ForeignKeyDefinition.new(table.name, foreign_table_name, foreign_key_options)
42
+ columns.each do |col_name, type, _|
43
+ value = <<-SQL.squish
44
+ jsonb_foreign_key(
45
+ '#{fk.to_table}',
46
+ '#{fk.active_record_primary_key}',
47
+ #{store},
48
+ '#{col_name}',
49
+ '#{type}'
50
+ )
51
+ SQL
52
+ table.constraint({
53
+ name: "#{table.name}_#{col_name}_foreign_key",
54
+ value: value
55
+ })
56
+ end
57
+ end
29
58
 
30
59
  return unless index
31
60
 
32
- # should_add_idx = false
33
- # if table.respond_to? :index_exists?
34
- # should_add_idx = !table.index_exists?([ store ], using: :gin)
35
- # elsif table.respond_to? :indexes
36
- # should_add_idx = table.indexes.none? do |idx, opts|
37
- # (idx == [ store ]) \
38
- # && (opts == { using: :gin })
39
- # end
40
- # end
41
- #
42
- # table.index([ store ], using: :gin) if should_add_idx
43
-
44
- column_names.each do |column_name|
61
+ columns.each do |col_name, type, opts|
62
+ type = :text if type == :string
45
63
  table.index(
46
- "CAST (\"#{store}\"->'#{column_name}' AS #{@type || :bigint})",
64
+ "CAST (\"#{store}\"->>'#{col_name}' AS #{type || :bigint})",
47
65
  using: :btree,
48
- name: "index_#{table.name}_on_#{store}_#{column_name}"
66
+ name: "index_#{table.name}_on_#{store}_#{col_name}"
49
67
  )
50
68
 
51
69
  table.index(
52
- "(#{store}->>'#{column_name}')",
70
+ "(\"#{store}\"->>'#{col_name}')",
53
71
  using: :btree,
54
- name: "index_#{table.name}_on_#{store}_#{column_name}_text"
72
+ name: "index_#{table.name}_on_#{store}_#{col_name}_text"
55
73
  )
56
74
  end
57
75
  end
58
76
 
59
77
  protected
60
78
 
61
- attr_reader :store
79
+ attr_reader :store, :store_key
62
80
  end
63
81
  end
64
82
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssociateJsonb
4
+ module ConnectionAdapters
5
+ module SchemaCreation
6
+ private
7
+ def visit_AlterTable(o)
8
+ sql = super
9
+ sql << o.constraint_adds.map {|ct| visit_AddConstraint ct }.join(" ")
10
+ sql << o.constraint_drops.map {|ct| visit_DropConstraint ct }.join(" ")
11
+ add_jsonb_function(o, sql)
12
+ end
13
+
14
+ def visit_TableDefinition(...)
15
+ add_jsonb_function(o, super(...))
16
+ end
17
+
18
+ def visit_ColumnDefinition(o)
19
+ column_sql = super
20
+ add_column_constraint!(o, column_sql, column_options(o))
21
+ column_sql
22
+ end
23
+
24
+ def visit_ConstraintDefinition(o)
25
+ +<<-SQL.squish
26
+ CONSTRAINT #{o.name} CHECK (#{o.value})
27
+ SQL
28
+ end
29
+
30
+ def visit_AddConstraint(o)
31
+ "ADD #{accept(o)}"
32
+ end
33
+
34
+ def visit_DropConstraint(o)
35
+ "DROP CONSTRAINT #{o.name}"
36
+ end
37
+
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
50
+
51
+ sql
52
+ end
53
+
54
+ def add_jsonb_function(o, sql)
55
+ if sql =~ /jsonb_foreign_key/
56
+ visit_AddJsonForeignKeyFunction(o) + sql
57
+ else
58
+ sql
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
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
+ module AssociateJsonb
9
+ module ConnectionAdapters
10
+ module SchemaStatements
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssociateJsonb
4
+ module ConnectionAdapters
5
+ module TableDefinition #:nodoc:
6
+
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Persistence
6
+ private
7
+ def _update_row(attribute_names, attempted_action = "update")
8
+ self.class._update_record(
9
+ attributes_with_info(attribute_names),
10
+ @primary_key => id_in_database
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module PredicateBuilder # :nodoc:
6
+ def build_bind_attribute(column_name, value)
7
+ if value.respond_to?(:value_before_type_cast)
8
+ attr = ActiveRecord::Relation::QueryAttribute.new(column_name.to_s, value.value_before_type_cast, table.type(column_name), value)
9
+ else
10
+ attr = ActiveRecord::Relation::QueryAttribute.new(column_name.to_s, value, table.type(column_name))
11
+ end
12
+ Arel::Nodes::BindParam.new(attr)
13
+ end
14
+ end
15
+ end
@@ -60,7 +60,7 @@ module AssociateJsonb
60
60
  Arel::Nodes::NamedFunction.new(
61
61
  "CAST",
62
62
  [
63
- Arel::Nodes::Jsonb::DashArrow.
63
+ Arel::Nodes::Jsonb::DashDoubleArrow.
64
64
  new(table, table[foreign_store_attr], foreign_store_key || key).
65
65
  as(foreign_klass.columns_hash[foreign_key.to_s].sql_type)
66
66
  ]
@@ -83,7 +83,7 @@ module AssociateJsonb
83
83
  Arel::Nodes::NamedFunction.new(
84
84
  "CAST",
85
85
  [
86
- Arel::Nodes::Jsonb::DashArrow.
86
+ Arel::Nodes::Jsonb::DashDoubleArrow.
87
87
  new(foreign_table, foreign_table[jsonb_store_attr], jsonb_store_key || foreign_key).
88
88
  as(klass.columns_hash[key.to_s].sql_type)
89
89
  ]
@@ -1,3 +1,6 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
1
4
  module AssociateJsonb
2
5
  module Relation
3
6
  module WhereClause
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module AssociateJsonb
5
- VERSION = "0.0.4"
5
+ VERSION = "0.0.5"
6
6
  end
@@ -140,20 +140,23 @@ module AssociateJsonb
140
140
 
141
141
  mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
142
142
  def #{store}=(given)
143
- super(given || {})
144
- write_attribute(:#{attribute}, #{on_store_change.call %Q(#{store}["#{key}"])})
145
- if #{attribute}.blank?
146
- #{store}.delete("#{key}")
147
- else
148
- #{store}["#{key}"] = #{attribute}
143
+ if !given
144
+ given = {}
145
+ #{store}.keys.each do |k|
146
+ given[k] = nil
147
+ end
148
+ 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
149
153
  end
150
154
  #{store}
151
155
  end
152
156
 
153
157
  def #{attribute}=(given)
154
158
  #{on_attr_change}
155
- value = #{store}["#{key}"] = #{attribute}
156
- #{store}.delete("#{key}") if value.nil?
159
+ value = #{store}["#{key}"] = #{attribute}.presence
157
160
  _write_attribute(:#{store}, #{store})
158
161
  value
159
162
  end
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.4
4
+ version: 0.0.5
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-16 00:00:00.000000000 Z
11
+ date: 2020-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -125,6 +125,7 @@ files:
125
125
  - lib/associate_jsonb/arel_extensions/nodes/binary.rb
126
126
  - lib/associate_jsonb/arel_extensions/nodes/table_alias.rb
127
127
  - lib/associate_jsonb/arel_extensions/table.rb
128
+ - lib/associate_jsonb/arel_extensions/visitors/postgresql.rb
128
129
  - lib/associate_jsonb/arel_extensions/visitors/visitor.rb
129
130
  - lib/associate_jsonb/arel_nodes/jsonb/at_arrow.rb
130
131
  - lib/associate_jsonb/arel_nodes/jsonb/attribute.rb
@@ -147,8 +148,16 @@ files:
147
148
  - lib/associate_jsonb/associations/has_many_association.rb
148
149
  - lib/associate_jsonb/associations/join_dependency.rb
149
150
  - lib/associate_jsonb/associations/preloader/association.rb
151
+ - lib/associate_jsonb/attribute_methods.rb
152
+ - lib/associate_jsonb/attribute_methods/read.rb
150
153
  - lib/associate_jsonb/connection_adapters.rb
151
154
  - lib/associate_jsonb/connection_adapters/reference_definition.rb
155
+ - lib/associate_jsonb/connection_adapters/schema_creation.rb
156
+ - lib/associate_jsonb/connection_adapters/schema_creation/alter_table.rb
157
+ - lib/associate_jsonb/connection_adapters/schema_statements.rb
158
+ - lib/associate_jsonb/connection_adapters/table_definition.rb
159
+ - lib/associate_jsonb/persistence.rb
160
+ - lib/associate_jsonb/predicate_builder.rb
152
161
  - lib/associate_jsonb/reflection.rb
153
162
  - lib/associate_jsonb/relation/where_clause.rb
154
163
  - lib/associate_jsonb/supported_rails_version.rb