atomic_json 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'atomic_json/json_query_helpers'
4
+
5
+ module AtomicJson
6
+ class QueryBuilder
7
+
8
+ include JsonQueryHelpers
9
+
10
+ attr_reader :record, :connection
11
+
12
+ delegate :quote_column_name, :quote_table_name, :quote, to: :connection
13
+
14
+ def initialize(record, connection)
15
+ @record = record
16
+ @connection = connection
17
+ end
18
+
19
+ def build(attributes, touch)
20
+ <<~SQL
21
+ UPDATE #{quote_table_name(record.class.table_name)}
22
+ SET #{build_set_subquery(attributes, touch)}
23
+ WHERE id = #{quote(record.id)};
24
+ SQL
25
+ end
26
+
27
+ private
28
+
29
+ def build_set_subquery(attributes, touch)
30
+ updates = json_updates_agg(attributes)
31
+ updates << timestamp_update_string if touch && record.has_attribute?(:updated_at)
32
+ updates.join(',')
33
+ end
34
+
35
+ def json_updates_agg(attributes)
36
+ attributes.map do |column, payload|
37
+ "#{quote_column_name(column)} = #{json_deep_merge(column, payload)}"
38
+ end
39
+ end
40
+
41
+ def timestamp_update_string
42
+ "#{quote_column_name(:updated_at)} = #{quote(Time.now)}"
43
+ end
44
+
45
+ def json_deep_merge(target, payload)
46
+ loop do
47
+ keys, value = traverse_payload(Hash[*payload.shift])
48
+ target = jsonb_set_query_string(target, keys, value)
49
+ break target if payload.empty?
50
+ end
51
+ end
52
+
53
+ ##
54
+ # Traverse the Hash payload, incrementally
55
+ # aggregating all hash keys into an array
56
+ # and use the last child as value
57
+ def traverse_payload(key_value_pair, keys = [])
58
+ loop do
59
+ key, val = key_value_pair.flatten
60
+ keys << key.to_s
61
+ break [keys, val] unless single_value_hash?(val)
62
+ key_value_pair = val
63
+ end
64
+ end
65
+
66
+ def jsonb_set_query_string(target, keys, value)
67
+ <<~EOF
68
+ jsonb_set(
69
+ #{target}::jsonb,
70
+ #{jsonb_quote_keys(keys)},
71
+ #{multi_value_hash?(value) ? concatenation(target, keys, value) : jsonb_quote_value(value)}
72
+ )::jsonb
73
+ EOF
74
+ end
75
+
76
+ def multi_value_hash?(value)
77
+ value.is_a?(Hash) && value.keys.count > 1
78
+ end
79
+
80
+ def single_value_hash?(value)
81
+ value.is_a?(Hash) && value.keys.count == 1
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'atomic_json/query'
4
+
5
+ module AtomicJson
6
+ module QueryMethods
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ def json_update(input)
11
+ run_callbacks(:save) do
12
+ Query.new(self)
13
+ .build(input, touch: true)
14
+ .execute!
15
+ reload.validate
16
+ end
17
+ end
18
+
19
+ def json_update!(input)
20
+ run_callbacks(:save) do
21
+ Query.new(self)
22
+ .build(input, touch: true)
23
+ .execute!
24
+ reload.validate!
25
+ end
26
+ end
27
+
28
+ def json_update_columns(input)
29
+ Query.new(self)
30
+ .build(input)
31
+ .execute!
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'atomic_json/errors'
4
+
5
+ module AtomicJson
6
+ module Validations
7
+
8
+ def validate_record!(record)
9
+ raise ActiveRecordError, 'cannot update a new record' if record.new_record?
10
+ raise ActiveRecordError, 'cannot update a destroyed record' if record.destroyed?
11
+ end
12
+
13
+ def validate_attributes!(record, attributes)
14
+ raise TypeError, 'Payload to update must be a hash' unless attributes.is_a?(Hash)
15
+ attributes.each_key do |key|
16
+ raise ReadOnlyAttributeError, "#{key} is marked as readonly" if read_only_attribute?(record, key)
17
+ raise InvalidColumnTypeError, 'ActiveRecord column needs to be of type JSON or JSONB' unless valid_column_type?(record, key)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def read_only_attribute?(record, key)
24
+ record.class.readonly_attributes.include?(key.to_s)
25
+ end
26
+
27
+ def valid_column_type?(record, key)
28
+ %i[json jsonb].include?(record.type_for_attribute(key.to_s).type)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicJson
2
+
3
+ VERSION = '0.1.0'.freeze
4
+
5
+ end
metadata ADDED
@@ -0,0 +1,225 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: atomic_json
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Antoine Macia
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.18'
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 0.18.1
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - "~>"
56
+ - !ruby/object:Gem::Version
57
+ version: '0.18'
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.18.1
61
+ - !ruby/object:Gem::Dependency
62
+ name: bundler
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.16'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.16'
75
+ - !ruby/object:Gem::Dependency
76
+ name: byebug
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '10.0'
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 10.0.2
85
+ type: :development
86
+ prerelease: false
87
+ version_requirements: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - "~>"
90
+ - !ruby/object:Gem::Version
91
+ version: '10.0'
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 10.0.2
95
+ - !ruby/object:Gem::Dependency
96
+ name: factory_bot
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '4.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '4.0'
109
+ - !ruby/object:Gem::Dependency
110
+ name: minitest
111
+ requirement: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '5.0'
116
+ type: :development
117
+ prerelease: false
118
+ version_requirements: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '5.0'
123
+ - !ruby/object:Gem::Dependency
124
+ name: rake
125
+ requirement: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '10.0'
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '10.0'
137
+ - !ruby/object:Gem::Dependency
138
+ name: rubocop
139
+ requirement: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: 0.58.1
144
+ type: :development
145
+ prerelease: false
146
+ version_requirements: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: 0.58.1
151
+ - !ruby/object:Gem::Dependency
152
+ name: standalone_migrations
153
+ requirement: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '5.2'
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: 5.2.5
161
+ type: :development
162
+ prerelease: false
163
+ version_requirements: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - "~>"
166
+ - !ruby/object:Gem::Version
167
+ version: '5.2'
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: 5.2.5
171
+ description: ''
172
+ email:
173
+ - antoine@discolabs.com
174
+ executables: []
175
+ extensions: []
176
+ extra_rdoc_files: []
177
+ files:
178
+ - ".gitignore"
179
+ - ".rubocop.yml"
180
+ - ".travis.yml"
181
+ - Gemfile
182
+ - Gemfile.lock
183
+ - LICENSE.txt
184
+ - README.md
185
+ - Rakefile
186
+ - VERSION
187
+ - atomic_json.gemspec
188
+ - bin/console
189
+ - bin/setup
190
+ - db/config.yml
191
+ - db/migrate/20180715062407_create_mock_test_table.rb
192
+ - db/schema.rb
193
+ - lib/atomic_json.rb
194
+ - lib/atomic_json/errors.rb
195
+ - lib/atomic_json/json_query_helpers.rb
196
+ - lib/atomic_json/query.rb
197
+ - lib/atomic_json/query_builder.rb
198
+ - lib/atomic_json/query_methods.rb
199
+ - lib/atomic_json/validations.rb
200
+ - lib/atomic_json/version.rb
201
+ homepage: ''
202
+ licenses:
203
+ - MIT
204
+ metadata: {}
205
+ post_install_message:
206
+ rdoc_options: []
207
+ require_paths:
208
+ - lib
209
+ required_ruby_version: !ruby/object:Gem::Requirement
210
+ requirements:
211
+ - - ">="
212
+ - !ruby/object:Gem::Version
213
+ version: '0'
214
+ required_rubygems_version: !ruby/object:Gem::Requirement
215
+ requirements:
216
+ - - ">="
217
+ - !ruby/object:Gem::Version
218
+ version: '0'
219
+ requirements: []
220
+ rubyforge_project:
221
+ rubygems_version: 2.5.2
222
+ signing_key:
223
+ specification_version: 4
224
+ summary: Atomic update of JSON/JSONB fields for ActiveRecord models
225
+ test_files: []