changebase 0.1 → 0.2

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: dcaf7190bcd66bca83640222cc52f2bae125b65b9008c26c9e8dac4c88c35226
4
- data.tar.gz: '099666dc5717843cf99230a8a22c51fed5568ef93b4aa550928621a29d8a3e72'
3
+ metadata.gz: cb5026dc3675467dc638006f5953c5ba3a1ec4f486e63ecd4066fd2a027356f2
4
+ data.tar.gz: e4d58239417908fec5572dfe5e99bfb6d48db046679c39621d632cdcba8951fc
5
5
  SHA512:
6
- metadata.gz: 0b3dc117e4516d95e353b2e4e4ad076020815e37c88a6c256bb40ca1633ff5a487b692ac49331a203e8c6f519c46523c20e231e1cee768078c007666dea23d35
7
- data.tar.gz: f0915103e19c8966675d21d40b7091d62eb4eb97f686b92fc98087fe8277e3ef94ace4a3524ded170ead1cbd4f6d06395a03b6737fd937ca20edf4365a3225c3
6
+ metadata.gz: 5ed3fa5539602e0417635ff5333ec804a749930db38802a6f56636363b91a9896b1aa7acb18c0665471e9f66863e54e391a644d34e016014dda5090247eed488
7
+ data.tar.gz: 0cae8f40c3bc961c6381c0e4b9773b19d408e3602cbf47340d77479cc34c0bbb7827726f030b5929d3e466c944372b8f16d1792c0390f3f7696508ed88a53dd4
data/README.md CHANGED
@@ -47,27 +47,27 @@ Below are several diffent way of including metadata:
47
47
 
48
48
  ```ruby
49
49
  class ApplicationController < ActionController::Base
50
-
50
+
51
51
  # Just a block returning a hash of metadata.
52
52
  changebase do
53
53
  { my: data }
54
54
  end
55
-
55
+
56
56
  # Sets `release` in the metadata to the string RELEASE_SHA
57
57
  changebase :release, RELEASE_SHA
58
-
58
+
59
59
  # Sets `request_id` in the metadata to the value returned from the `Proc`
60
60
  changebase :request_id, -> { request.uuid }
61
-
61
+
62
62
  # Sets `user.id` in the metadata to the value returned from the
63
63
  # `current_user_id` function
64
64
  changebase :user, :id, :current_user_id
65
-
65
+
66
66
  # Sets `user.name` in the metadata to the value returned from the block
67
67
  changebase :user, :name do
68
68
  current_user.name
69
69
  end
70
-
70
+
71
71
  def current_user_id
72
72
  current_user.id
73
73
  end
@@ -99,8 +99,15 @@ To include metadata when creating or modifying data with ActiveRecord:
99
99
 
100
100
  ### Configuration
101
101
 
102
- To configure the metadata table that Changebase writes to create a initializer
103
- at `config/initializers/changebase.rb` with the following:
102
+ #### Replication Mode
103
+
104
+ The default mode for the `changebase` gem is `replication`. In this mode
105
+ Changebase is setup to replicate your database and record events via the
106
+ replication stream.
107
+
108
+ The default configuration `changebase` will write metadata to the
109
+ `"changebase_metadata"` table. To configure the metadata table create an
110
+ initializer at `config/initializers/changebase.rb` with the following:
104
111
 
105
112
  ```ruby
106
113
  Rails.application.config.tap do |config|
@@ -112,9 +119,67 @@ If you are not using Rails you can configure Changebase directly via:
112
119
 
113
120
  ```ruby
114
121
  Changebase.metadata_table = "my_very_cool_custom_metadata_table"
122
+
123
+ # Or
124
+
125
+ Changebase.configure(metadata_table: "my_very_cool_custom_metadata_table")
126
+ ```
127
+
128
+ #### Inline Mode
129
+
130
+ If you are unable to setup database replication you can use inline mode. Events
131
+ will be sent to through the Changebase API. You will collect roughly the same
132
+ information, but potentionally to miss events and changes in your database
133
+ if you are not careful, or if another application accesses the database directly.
134
+
135
+ ##### Limitations
136
+
137
+ - Any changes made to the database by ActiveRecord that does not first
138
+ instantiate the records will not be caputred. These methods include:
139
+ - [ActiveRecord::ConnectionAdapters::DatabaseStatements#execute](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-execute)
140
+ - [ActiveRecord::ConnectionAdapters::DatabaseStatements#exec_query](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-exec_query)
141
+ - [ActiveRecord::ConnectionAdapters::DatabaseStatements#exec_update](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-exec_update)
142
+ - [ActiveRecord::ConnectionAdapters::DatabaseStatements#exec_insert](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-exec_insert)
143
+ - [ActiveRecord::ConnectionAdapters::DatabaseStatements#exec_delete](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-exec_delete)
144
+ - [ActiveRecord::Persistence#delete](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-delete)
145
+ - [ActiveRecord::Persistence#update_column](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-update_column)
146
+ - [ActiveRecord::Persistence#update_columns](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-update_columns)
147
+ - [ActiveRecord::Relation#touch_all](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-touch_all)
148
+ - [ActiveRecord::Relation#update_all](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-update_all)
149
+ - [ActiveRecord::Relation#delete_all](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-delete_all)
150
+ - [ActiveRecord::Relation#delete_by](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-delete_by)
151
+ - Any changes made to the database outside of the Rails application will not be
152
+ captured.
153
+ - Ordering of events will not be guaranteed. The timestamp will be used as the
154
+ LSN, which may not be in the same order of transactions / events in the database.
155
+
156
+ To configure Changebase in the `"inline"` mode create a initializer at
157
+ `config/initializers/changebase.rb` with the following:
158
+
159
+ ```ruby
160
+ Rails.application.config.tap do |config|
161
+ config.changebase.mode = "inline"
162
+ config.changebase.connection = "https://#{ ENV.fetch('CHANGEBASE_API_KEY') }@changebase.io"
163
+ end
164
+ ```
165
+
166
+ If you are not using Rails you can configure Changebase directly via:
167
+
168
+ ```ruby
169
+ Changebase.configure do |config|
170
+ config.changebase.mode = "inline"
171
+ config.changebase.connection = "https://#{ ENV.fetch('CHANGEBASE_API_KEY') }@changebase.io"
172
+ end
173
+
174
+ # Or
175
+
176
+ Changebase.configure(
177
+ mode: "inline",
178
+ connection: "https://API_KEY@chanbase.io"
179
+ )
115
180
  ```
116
181
 
117
182
  ## Bugs
118
183
 
119
- If you think you found a bug, please file a ticket on the [issue
184
+ If you think you found a bug, please file a ticket on the [issue
120
185
  tracker](https://github.com/changebase-io/ruby-gem/issues).
@@ -4,7 +4,7 @@ module Changebase::ActionController
4
4
  included do
5
5
  prepend_around_action :changebase_metadata_wrapper
6
6
  end
7
-
7
+
8
8
  module ClassMethods
9
9
  def changebase(*keys, &block)
10
10
  method = if block
@@ -14,18 +14,18 @@ module Changebase::ActionController
14
14
  else
15
15
  keys.first
16
16
  end
17
-
17
+
18
18
  @changebase_metadata ||= []
19
19
  @changebase_metadata << [keys, method]
20
20
  end
21
-
21
+
22
22
  def changebase_metadata
23
23
  klass_metadata = if instance_variable_defined?(:@changebase_metadata)
24
24
  @changebase_metadata
25
25
  else
26
26
  []
27
27
  end
28
-
28
+
29
29
  if self.superclass.respond_to?(:changebase_metadata)
30
30
  klass_metadata + self.superclass.changebase_metadata
31
31
  else
@@ -33,21 +33,21 @@ module Changebase::ActionController
33
33
  end
34
34
  end
35
35
  end
36
-
36
+
37
37
  def changebase_metadata
38
38
  self.class.changebase_metadata
39
39
  end
40
-
40
+
41
41
  def changebase_metadata_wrapper(&block)
42
42
  metadata = {}
43
-
43
+
44
44
  changebase_metadata.each do |keys, value|
45
45
  data = metadata
46
46
  keys[0...-1].each do |key|
47
47
  data[key] ||= {}
48
48
  data = data[key]
49
49
  end
50
-
50
+
51
51
  value = case value
52
52
  when Symbol
53
53
  self.send(value)
@@ -56,7 +56,7 @@ module Changebase::ActionController
56
56
  else
57
57
  value
58
58
  end
59
-
59
+
60
60
  if keys.last
61
61
  data[keys.last] ||= value
62
62
  else
@@ -66,7 +66,7 @@ module Changebase::ActionController
66
66
 
67
67
  ActiveRecord::Base.with_metadata(metadata, &block)
68
68
  end
69
-
69
+
70
70
  end
71
71
 
72
- ActionController::Base.include(Changebase::ActionController)
72
+ ActionController::Base.include(Changebase::ActionController)
@@ -1,106 +1,23 @@
1
1
  module Changebase::ActiveRecord
2
2
  extend ActiveSupport::Concern
3
-
4
- module ClassMethods
3
+
4
+ class_methods do
5
5
  def with_metadata(metadata, &block)
6
6
  connection.with_metadata(metadata, &block)
7
7
  end
8
8
  end
9
-
10
- def with_metadata(metadata, &block)
11
- self.class.with_metadata(metadata, &block)
12
- end
13
- end
14
-
15
- module Changebase::ActiveRecord::Connection
16
9
 
17
10
  def with_metadata(metadata, &block)
18
- @changebase_metadata = metadata
19
- yield
20
- ensure
21
- @changebase_metadata = nil
22
- end
23
-
24
- end
25
-
26
- module Changebase::ActiveRecord::PostgreSQLAdapter
27
-
28
- def initialize(*args, **margs)
29
- @without_changebase = false
30
- @changebase_metadata = nil
31
- super
32
- end
33
-
34
- def without_changebase
35
- @without_changebase = true
36
- yield
37
- ensure
38
- @without_changebase = false
39
- end
40
-
41
- def drop_database(name) # :nodoc:
42
- without_changebase { super }
43
- end
44
-
45
- def drop_table(table_name, **options) # :nodoc:
46
- without_changebase { super }
47
- end
48
-
49
- def create_database(name, options = {})
50
- without_changebase { super }
51
- end
52
-
53
- def recreate_database(name, options = {}) # :nodoc:
54
- without_changebase { super }
55
- end
56
-
57
- def execute(sql, name = nil)
58
- if !@without_changebase && !current_transaction.open? && write_query?(sql)
59
- transaction { super }
60
- else
61
- super
62
- end
63
- end
64
-
65
- def exec_query(sql, name = "SQL", binds = [], prepare: false)
66
- if !@without_changebase && !current_transaction.open? && write_query?(sql)
67
- transaction { super }
68
- else
69
- super
70
- end
71
- end
72
-
73
- def exec_delete(sql, name = nil, binds = []) # :nodoc:
74
- if !@without_changebase && !current_transaction.open? && write_query?(sql)
75
- transaction { super }
76
- else
77
- super
78
- end
11
+ self.class.with_metadata(metadata, &block)
79
12
  end
80
13
 
81
- def commit_db_transaction
82
- if !@without_changebase && @changebase_metadata && !@changebase_metadata.empty?
83
- sql = ActiveRecord::Base.send(:replace_named_bind_variables, <<~SQL, {version: 1, metadata: ActiveSupport::JSON.encode(@changebase_metadata)})
84
- INSERT INTO #{quote_table_name(Changebase.metadata_table)} ( version, data )
85
- VALUES ( :version, :metadata )
86
- ON CONFLICT ( version )
87
- DO UPDATE SET version = :version, data = :metadata;
88
- SQL
89
-
90
- log(sql, "CHANGEBASE") do
91
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
92
- @connection.async_exec(sql)
93
- end
94
- end
14
+ module Connection
15
+ def with_metadata(metadata, &block)
16
+ @changebase_metadata = metadata
17
+ yield
18
+ ensure
19
+ @changebase_metadata = nil
95
20
  end
96
- super
97
21
  end
98
22
 
99
- end
100
-
101
- require 'active_record'
102
- ActiveRecord::Base.include(Changebase::ActiveRecord)
103
- ActiveRecord::ConnectionAdapters::AbstractAdapter.include(Changebase::ActiveRecord::Connection)
104
-
105
- require 'active_record/connection_adapters/postgresql_adapter'
106
- ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(Changebase::ActiveRecord::PostgreSQLAdapter)
23
+ end
@@ -0,0 +1,399 @@
1
+ require 'net/https'
2
+
3
+ module Changebase
4
+
5
+ class ServerError < ::RuntimeError
6
+ end
7
+
8
+ # RuntimeErrors don't get translated by Rails into
9
+ # ActiveRecord::StatementInvalid which StandardError do. Would rather
10
+ # use StandardError, but it's usefull with Changebase to know when something
11
+ # raises a Changebase::Exception::NotFound or Forbidden
12
+ class Exception < ::RuntimeError
13
+
14
+ class UnexpectedResponse < Changebase::Exception
15
+ end
16
+
17
+ class BadRequest < Changebase::Exception
18
+ end
19
+
20
+ class Unauthorized < Changebase::Exception
21
+ end
22
+
23
+ class Forbidden < Changebase::Exception
24
+ end
25
+
26
+ class NotFound < Changebase::Exception
27
+ end
28
+
29
+ class Gone < Changebase::Exception
30
+ end
31
+
32
+ class MovedPermanently < Changebase::Exception
33
+ end
34
+
35
+ class BadGateway < Changebase::Exception
36
+ end
37
+
38
+ class ApiVersionUnsupported < Changebase::Exception
39
+ end
40
+
41
+ class ServiceUnavailable < Changebase::Exception
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+
48
+
49
+ # _Changebase::Connection_ is a low-level API. It provides basic HTTP #get,
50
+ # #post, #put, and #delete calls to the an HTTP(S) Server. It can also provides
51
+ # basic error checking of responses.
52
+ class Changebase::Connection
53
+
54
+ attr_reader :api_key, :host, :port, :use_ssl
55
+
56
+ # Initialize a connection Changebase.
57
+ #
58
+ # Options:
59
+ #
60
+ # * <tt>:url</tt> - An optional url used to set the protocol, host, port,
61
+ # and api_key
62
+ # * <tt>:host</tt> - The default is to connect to 127.0.0.1.
63
+ # * <tt>:port</tt> - Defaults to 80.
64
+ # * <tt>:use_ssl</tt> - Defaults to true.
65
+ # * <tt>:api_key</tt> - An optional token to send in the `Api-Key` header
66
+ # * <tt>:user_agent</tt> - An optional string. Will be joined with other
67
+ # User-Agent info.
68
+ def initialize(config)
69
+ if config[:url]
70
+ uri = URI.parse(config.delete(:url))
71
+ config[:api_key] ||= (uri.user ? CGI.unescape(uri.user) : nil)
72
+ config[:host] ||= uri.host
73
+ config[:port] ||= uri.port
74
+ config[:use_ssl] ||= (uri.scheme == 'https')
75
+ end
76
+
77
+ [:api_key, :host, :port, :use_ssl, :user_agent].each do |key|
78
+ self.instance_variable_set(:"@#{key}", config[key])
79
+ end
80
+
81
+ @connection = Net::HTTP.new(host, port)
82
+ @connection.max_retries = 0
83
+ @connection.open_timeout = 5
84
+ @connection.read_timeout = 30
85
+ @connection.write_timeout = 5
86
+ @connection.ssl_timeout = 5
87
+ @connection.keep_alive_timeout = 30
88
+ @connection.use_ssl = use_ssl
89
+ true
90
+ end
91
+
92
+ def connect!
93
+ @connection.start
94
+ end
95
+
96
+ def active?
97
+ @connection.active?
98
+ end
99
+
100
+ def reconnect!
101
+ disconnect!
102
+ connect!
103
+ end
104
+
105
+ def disconnect!
106
+ @connection.finish if @connection.active?
107
+ end
108
+
109
+ # Returns the User-Agent of the client. Defaults to:
110
+ # "Rubygems/changebase@GEM_VERSION Ruby@RUBY_VERSION-pPATCH_LEVEL RUBY_PLATFORM"
111
+ def user_agent
112
+ [
113
+ @user_agent,
114
+ "Rubygems/changebase@#{Changebase::VERSION}",
115
+ "Ruby@#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}",
116
+ RUBY_PLATFORM
117
+ ].compact.join(' ')
118
+ end
119
+
120
+ # Sends a Net::HTTPRequest to the server. The headers returned from
121
+ # Connection#request_headers are automatically added to the request.
122
+ # The appropriate error is raised if the response is not in the 200..299
123
+ # range.
124
+ #
125
+ # Paramaters::
126
+ #
127
+ # * +request+ - A Net::HTTPRequest to send to the server
128
+ # * +body+ - Optional, a String, IO Object, or a Ruby object which is
129
+ # converted into JSON and sent as the body
130
+ # * +block+ - An optional block to call with the +Net::HTTPResponse+ object.
131
+ #
132
+ # Return Value::
133
+ #
134
+ # Returns the return value of the <tt>&block</tt> if given, otherwise the
135
+ # response object (a Net::HTTPResponse)
136
+ #
137
+ # Examples:
138
+ #
139
+ # #!ruby
140
+ # connection.send_request(#<Net::HTTP::Get>) # => #<Net::HTTP::Response>
141
+ #
142
+ # connection.send_request(#<Net::HTTP::Get @path="/404">) # => raises Changebase::Exception::NotFound
143
+ #
144
+ # # this will still raise an exception if the response_code is not valid
145
+ # # and the block will not be called
146
+ # connection.send_request(#<Net::HTTP::Get>) do |response|
147
+ # # ...
148
+ # end
149
+ #
150
+ # # The following example shows how to stream a response:
151
+ # connection.send_request(#<Net::HTTP::Get>) do |response|
152
+ # response.read_body do |chunk|
153
+ # io.write(chunk)
154
+ # end
155
+ # end
156
+ def send_request(request, body=nil, &block)
157
+ request_headers.each { |k, v| request[k] = v }
158
+ request['Content-Type'] ||= 'application/json'
159
+
160
+ if body.is_a?(IO)
161
+ request['Transfer-Encoding'] = 'chunked'
162
+ request.body_stream = body
163
+ elsif body.is_a?(String)
164
+ request.body = body
165
+ elsif body
166
+ request.body = JSON.generate(body)
167
+ end
168
+
169
+ return_value = nil
170
+ begin
171
+ close_connection = false
172
+ @connection.request(request) do |response|
173
+ # if response['Deprecation-Notice']
174
+ # ActiveSupport::Deprecation.warn(response['Deprecation-Notice'])
175
+ # end
176
+
177
+ validate_response_code(response)
178
+
179
+ # Get the cookies
180
+ response.each_header do |key, value|
181
+ case key.downcase
182
+ when 'connection'
183
+ close_connection = (value == 'close')
184
+ end
185
+ end
186
+
187
+ if block_given?
188
+ return_value = yield(response)
189
+ else
190
+ return_value = response
191
+ end
192
+ end
193
+ @connection.finish if close_connection
194
+ end
195
+
196
+ return_value
197
+ end
198
+
199
+ # Send a GET request to +path+ via +Connection#send_request+.
200
+ # See +Connection#send_request+ for more details on how the response is
201
+ # handled.
202
+ #
203
+ # Paramaters::
204
+ #
205
+ # * +path+ - The +path+ on the server to GET to.
206
+ # * +params+ - Either a String, Hash, or Ruby Object that responds to
207
+ # #to_param. Appended on the URL as query params
208
+ # * +block+ - An optional block to call with the +Net::HTTPResponse+ object.
209
+ #
210
+ # Return Value::
211
+ #
212
+ # See +Connection#send_request+
213
+ #
214
+ # Examples:
215
+ #
216
+ # #!ruby
217
+ # connection.get('/example') # => #<Net::HTTP::Response>
218
+ #
219
+ # connection.get('/example', 'query=stuff') # => #<Net::HTTP::Response>
220
+ #
221
+ # connection.get('/example', {:query => 'stuff'}) # => #<Net::HTTP::Response>
222
+ #
223
+ # connection.get('/404') # => raises Changebase::Exception::NotFound
224
+ #
225
+ # connection.get('/act') do |response|
226
+ # # ...
227
+ # end
228
+ def get(path, params='', &block)
229
+ params ||= ''
230
+ request = Net::HTTP::Get.new(path + '?' + params.to_param)
231
+
232
+ send_request(request, nil, &block)
233
+ end
234
+
235
+ # Send a POST request to +path+ via +Connection#send_request+.
236
+ # See +Connection#send_request+ for more details on how the response is
237
+ # handled.
238
+ #
239
+ # Paramaters::
240
+ #
241
+ # * +path+ - The +path+ on the server to POST to.
242
+ # * +body+ - Optional, See +Connection#send_request+.
243
+ # * +block+ - Optional, See +Connection#send_request+
244
+ #
245
+ # Return Value::
246
+ #
247
+ # See +Connection#send_request+
248
+ #
249
+ # Examples:
250
+ #
251
+ # #!ruby
252
+ # connection.post('/example') # => #<Net::HTTP::Response>
253
+ #
254
+ # connection.post('/example', 'body') # => #<Net::HTTP::Response>
255
+ #
256
+ # connection.post('/example', #<IO Object>) # => #<Net::HTTP::Response>
257
+ #
258
+ # connection.post('/example', {:example => 'data'}) # => #<Net::HTTP::Response>
259
+ #
260
+ # connection.post('/404') # => raises Changebase::Exception::NotFound
261
+ #
262
+ # connection.post('/act') do |response|
263
+ # # ...
264
+ # end
265
+ def post(path, body=nil, &block)
266
+ request = Net::HTTP::Post.new(path)
267
+
268
+ send_request(request, body, &block)
269
+ end
270
+
271
+ # Send a PUT request to +path+ via +Connection#send_request+.
272
+ # See +Connection#send_request+ for more details on how the response is
273
+ # handled.
274
+ #
275
+ # Paramaters::
276
+ #
277
+ # * +path+ - The +path+ on the server to POST to.
278
+ # * +body+ - Optional, See +Connection#send_request+.
279
+ # * +block+ - Optional, See +Connection#send_request+
280
+ #
281
+ # Return Value::
282
+ #
283
+ # See +Connection#send_request+
284
+ #
285
+ # Examples:
286
+ #
287
+ # #!ruby
288
+ # connection.put('/example') # => #<Net::HTTP::Response>
289
+ #
290
+ # connection.put('/example', 'body') # => #<Net::HTTP::Response>
291
+ #
292
+ # connection.put('/example', #<IO Object>) # => #<Net::HTTP::Response>
293
+ #
294
+ # connection.put('/example', {:example => 'data'}) # => #<Net::HTTP::Response>
295
+ #
296
+ # connection.put('/404') # => raises Changebase::Exception::NotFound
297
+ #
298
+ # connection.put('/act') do |response|
299
+ # # ...
300
+ # end
301
+ def put(path, body=nil, *valid_response_codes, &block)
302
+ request = Net::HTTP::Put.new(path)
303
+
304
+ send_request(request, body, &block)
305
+ end
306
+
307
+ # Send a DELETE request to +path+ via +Connection#send_request+.
308
+ # See +Connection#send_request+ for more details on how the response is
309
+ # handled
310
+ #
311
+ # Paramaters::
312
+ #
313
+ # * +path+ - The +path+ on the server to POST to.
314
+ # * +block+ - Optional, See +Connection#send_request+
315
+ #
316
+ # Return Value::
317
+ #
318
+ # See +Connection#send_request+
319
+ #
320
+ # Examples:
321
+ #
322
+ # #!ruby
323
+ # connection.delete('/example') # => #<Net::HTTP::Response>
324
+ #
325
+ # connection.delete('/404') # => raises Changebase::Exception::NotFound
326
+ #
327
+ # connection.delete('/act') do |response|
328
+ # # ...
329
+ # end
330
+ def delete(path, &block)
331
+ request = Net::HTTP::Delete.new(path)
332
+
333
+ send_request(request, nil, &block)
334
+ end
335
+
336
+ private
337
+
338
+ def request_headers
339
+ headers = {}
340
+
341
+ headers['Accept'] = 'application/json'
342
+ headers['User-Agent'] = user_agent
343
+ headers['Api-Version'] = '0.2.0'
344
+ headers['Connection'] = 'keep-alive'
345
+ headers['Api-Key'] = api_key if api_key
346
+
347
+ headers
348
+ end
349
+
350
+ # Raise an Changebase::Exception based on the response_code, unless the
351
+ # response_code is include in the valid_response_codes Array
352
+ #
353
+ # Paramaters::
354
+ #
355
+ # * +response+ - The Net::HTTP::Response object
356
+ #
357
+ # Return Value::
358
+ #
359
+ # If an exception is not raised the +response+ is returned
360
+ #
361
+ # Examples:
362
+ #
363
+ # #!ruby
364
+ # connection.validate_response_code(<Net::HTTP::Response @code=200>) # => <Net::HTTP::Response @code=200>
365
+ #
366
+ # connection.validate_response_code(<Net::HTTP::Response @code=404>) # => raises Changebase::Exception::NotFound
367
+ #
368
+ # connection.validate_response_code(<Net::HTTP::Response @code=500>) # => raises Changebase::Exception
369
+ def validate_response_code(response)
370
+ code = response.code.to_i
371
+
372
+ if !(200..299).include?(code)
373
+ case code
374
+ when 400
375
+ raise Changebase::Exception::BadRequest, response.body
376
+ when 401
377
+ raise Changebase::Exception::Unauthorized, response.body
378
+ when 403
379
+ raise Changebase::Exception::Forbidden, response.body
380
+ when 404
381
+ raise Changebase::Exception::NotFound, response.body
382
+ when 410
383
+ raise Changebase::Exception::Gone, response.body
384
+ when 422
385
+ raise Changebase::Exception::ApiVersionUnsupported, response.body
386
+ when 503
387
+ raise Changebase::Exception::ServiceUnavailable, response.body
388
+ when 301
389
+ raise Changebase::Exception::MovedPermanently, response.body
390
+ when 502
391
+ raise Changebase::Exception::BadGateway, response.body
392
+ when 500..599
393
+ raise Changebase::ServerError, response.body
394
+ else
395
+ raise Changebase::Exception, response.body
396
+ end
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,161 @@
1
+ require 'securerandom'
2
+
3
+ module Changebase::Inline
4
+
5
+ module Through
6
+ extend ActiveSupport::Concern
7
+
8
+ def delete_records(records, method)
9
+ x = super
10
+
11
+ if method != :destroy
12
+ records.each do |record|
13
+ through_model = source_reflection.active_record
14
+
15
+ columns = through_model.columns.each_with_index.reduce([]) do |acc, (column, index)|
16
+ attr_type = through_model.type_for_attribute(column.name)
17
+ previous_value = attr_type.serialize(column.name == source_reflection.foreign_key ? record.id : owner.id)
18
+ acc << {
19
+ index: index,
20
+ identity: true,
21
+ name: column.name,
22
+ type: column.sql_type,
23
+ value: nil,
24
+ previous_value: previous_value
25
+ }
26
+ acc
27
+ end
28
+
29
+ transaction = through_model.connection.changebase_transaction || Changebase::Inline::Transaction.new(
30
+ timestamp: Time.current,
31
+ metadata: through_model.connection.instance_variable_get(:@changebase_metadata)
32
+ )
33
+
34
+ transaction.event!({
35
+ schema: columns[0].try(:[], :schema) || through_model.connection.current_schema,
36
+ table: through_model.table_name,
37
+ type: :delete,
38
+ columns: columns,
39
+ timestamp: Time.current
40
+ })
41
+
42
+ # Save the Changebase::Transaction if we are not in a transaction.
43
+ transaction.save! if !through_model.connection.changebase_transaction
44
+ end
45
+ end
46
+
47
+ x
48
+ end
49
+ end
50
+
51
+ module HasMany
52
+
53
+ def delete_or_nullify_all_records(method)
54
+ x = super
55
+ if method == :delete_all
56
+ target.each { |record| record.changebase_track(:delete) }
57
+ end
58
+ x
59
+ end
60
+
61
+ end
62
+
63
+ module ActiveRecord
64
+
65
+ module PostgreSQLAdapter
66
+
67
+ attr_reader :changebase_transaction
68
+
69
+ # Begins a transaction.
70
+ def begin_db_transaction
71
+ super
72
+ @changebase_transaction = Changebase::Inline::Transaction.new(
73
+ timestamp: Time.current,
74
+ metadata: @changebase_metadata
75
+ )
76
+ end
77
+
78
+ # Aborts a transaction.
79
+ def exec_rollback_db_transaction
80
+ super
81
+ ensure
82
+ @changebase_transaction = nil
83
+ end
84
+
85
+ # Commits a transaction.
86
+ def commit_db_transaction
87
+ @changebase_transaction&.save!
88
+ @changebase_transaction = nil
89
+ super
90
+ end
91
+ end
92
+
93
+ extend ActiveSupport::Concern
94
+
95
+ class_methods do
96
+ def self.extended(other)
97
+ other.after_create { changebase_track(:insert) }
98
+ other.after_update { changebase_track(:update) }
99
+ other.after_destroy { changebase_track(:delete) }
100
+ end
101
+
102
+ end
103
+
104
+ def changebase_tracking
105
+ if Changebase.configured?# && self.class.instance_variable_defined?(:@changebase)
106
+ # self.class.instance_variable_get(:@changebase)
107
+ {exclude: []}
108
+ end
109
+ end
110
+
111
+ def changebase_transaction
112
+ self.class.connection.changebase_transaction
113
+ end
114
+
115
+ def changebase_track(type)
116
+ return if !changebase_tracking
117
+ return if type == :update && self.previous_changes.empty?
118
+
119
+ # Go through each of the Model#attributes and grab the type from the
120
+ # Model#type_for_attribute(attr) to do the serialization, grab the
121
+ # column definition using Model#column_for_attribute(attr) to write the
122
+ # type, and use Model.columns.index(col) to grab the index of the column
123
+ # in the database.
124
+ columns = self.class.columns.each_with_index.reduce([]) do |acc, (column, index)|
125
+ identity = self.class.primary_key ? self.class.primary_key == column.name : true
126
+
127
+ attr_type = self.type_for_attribute(column.name)
128
+ value = self.attributes[column.name]
129
+ previous_value = self.previous_changes[column.name].try(:[], 0)
130
+
131
+ case type
132
+ when :update
133
+ previous_value ||= value
134
+ when :delete
135
+ previous_value ||= value
136
+ value = nil
137
+ end
138
+
139
+ acc << {
140
+ index: index,
141
+ identity: identity,
142
+ name: column.name,
143
+ type: column.sql_type,
144
+ value: attr_type.serialize(value),
145
+ previous_value: attr_type.serialize(previous_value)
146
+ }
147
+ acc
148
+ end
149
+
150
+ # Emit the event
151
+ changebase_transaction.event!({
152
+ schema: columns[0].try(:[], :schema) || self.class.connection.current_schema,
153
+ table: self.class.table_name,
154
+ type: type,
155
+ columns: columns,
156
+ timestamp: Time.current
157
+ })
158
+ end
159
+
160
+ end
161
+ end
@@ -0,0 +1,25 @@
1
+ class Changebase::Inline::Event
2
+
3
+ attr_accessor :id, :database_id, :transaction_id, :type, :schema,
4
+ :table, :timestamp, :created_at, :columns
5
+
6
+ def initialize(attrs)
7
+ attrs.each { |k,v| self.send("#{k}=", v) }
8
+
9
+ self.columns ||= {}
10
+ end
11
+
12
+ def as_json
13
+ {
14
+ id: id,
15
+ transaction_id: transaction_id,
16
+ lsn: timestamp.utc.iso8601(3),
17
+ type: type,
18
+ schema: schema,
19
+ table: table,
20
+ timestamp: timestamp.utc.iso8601(3),
21
+ columns: columns.as_json
22
+ }.compact
23
+ end
24
+
25
+ end
@@ -0,0 +1,71 @@
1
+ require 'securerandom'
2
+
3
+ class Changebase::Inline::Transaction
4
+
5
+ attr_accessor :id, :metadata, :timestamp, :events
6
+
7
+ def initialize(attrs={})
8
+ attrs.each { |k,v| self.send("#{k}=", v) }
9
+
10
+ if id
11
+ @persisted = true
12
+ else
13
+ @persisted = false
14
+ @id ||= SecureRandom.uuid
15
+ end
16
+
17
+ @events ||= []
18
+ @timestamp ||= Time.now
19
+ @metadata ||= {}
20
+ end
21
+
22
+ def persisted?
23
+ @persisted
24
+ end
25
+
26
+ def event!(event_attributes)
27
+ event = Changebase::Inline::Event.new(event_attributes)
28
+ @events << event
29
+ event
30
+ end
31
+
32
+ def self.create!(attrs={})
33
+ transaction = self.new(attrs)
34
+ transaction.save!
35
+ transaction
36
+ end
37
+
38
+ def save!
39
+ persisted? ? _update : _create
40
+ end
41
+
42
+ def _update
43
+ return if events.empty?
44
+ events.delete_if { |a| a.diff.empty? }
45
+ payload = JSON.generate({events: events.as_json.map{ |json| json[:transaction_id] = id; json }})
46
+ Changebase.logger.debug("[Changebase] POST /events WITH #{payload}")
47
+ Changebase.connection.post('/events', payload)
48
+ @events = []
49
+ end
50
+
51
+ def _create
52
+ events.delete_if { |a| a.columns.empty? }
53
+ payload = JSON.generate({transaction: self.as_json})
54
+ Changebase.logger.debug("[Changebase] POST /transactions WITH #{payload}")
55
+ Changebase.connection.post('/transactions', payload)
56
+ @events = []
57
+ @persisted = true
58
+ end
59
+
60
+ def as_json
61
+ result = {
62
+ id: id,
63
+ lsn: timestamp.utc.iso8601(3),
64
+ timestamp: timestamp.utc.iso8601(3),
65
+ events: events.as_json
66
+ }
67
+ result[:metadata] = metadata.as_json if !metadata.empty?
68
+ result
69
+ end
70
+
71
+ end
@@ -0,0 +1,32 @@
1
+ require 'changebase'
2
+ Changebase.mode = 'inline'
3
+
4
+ module Changebase::Inline
5
+
6
+ autoload :Event, 'changebase/inline/event'
7
+ autoload :Transaction, 'changebase/inline/transaction'
8
+
9
+ def self.load!
10
+ require 'active_record'
11
+ require 'changebase/active_record'
12
+
13
+ ::ActiveRecord::Base.include(Changebase::ActiveRecord)
14
+ ::ActiveRecord::ConnectionAdapters::AbstractAdapter.include(Changebase::ActiveRecord::Connection)
15
+
16
+ require 'active_record/connection_adapters/postgresql_adapter'
17
+ require 'changebase/inline/active_record'
18
+ ::ActiveRecord::Base.include(Changebase::Inline::ActiveRecord)
19
+ ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(Changebase::Inline::ActiveRecord::PostgreSQLAdapter)
20
+ ::ActiveRecord::Associations::HasManyThroughAssociation.prepend(Changebase::Inline::Through)
21
+ ::ActiveRecord::Associations::HasManyAssociation.prepend(Changebase::Inline::HasMany)
22
+
23
+ @loaded = true
24
+ end
25
+
26
+ def self.loaded?
27
+ @loaded
28
+ end
29
+
30
+ end
31
+
32
+ Changebase::Inline.load!
@@ -1,23 +1,31 @@
1
1
  class Changebase::Engine < ::Rails::Engine
2
2
 
3
3
  config.changebase = ActiveSupport::OrderedOptions.new
4
+ # config.changebase.mode = nil
4
5
  config.changebase.metadata_table = "changebase_metadata"
5
-
6
+
6
7
  initializer :changebase do |app|
7
8
  migration_paths = config.paths['db/migrate'].expanded
8
-
9
+
9
10
  ActiveSupport.on_load(:active_record) do
10
- require 'changebase/active_record'
11
- migration_paths.each do |path|
12
- ActiveRecord::Tasks::DatabaseTasks.migrations_paths << path
11
+ Changebase.logger = ActiveRecord::Base.logger
12
+
13
+ case Changebase.mode
14
+ when 'replication'
15
+ Changebase::Replication.load! if !Changebase::Replication.loaded?
16
+ migration_paths.each do |path|
17
+ ActiveRecord::Tasks::DatabaseTasks.migrations_paths << path
18
+ end
19
+ when 'inline'
20
+ Changebase::Inline.load! if !Changebase::Inline.loaded?
13
21
  end
14
22
  end
15
-
23
+
16
24
  ActiveSupport.on_load(:action_controller) do
17
25
  require 'changebase/action_controller'
18
26
  end
19
-
20
- Changebase.metadata_table = app.config.changebase.metadata_table
27
+
28
+ Changebase.configure(**app.config.changebase.to_h)
21
29
  end
22
-
23
- end
30
+
31
+ end
@@ -0,0 +1,96 @@
1
+ module Changebase::Replication
2
+ module ActiveRecord
3
+ module PostgreSQLAdapter
4
+
5
+ def initialize(*args, **margs)
6
+ @without_changebase = false
7
+ @changebase_metadata = nil
8
+ super
9
+ end
10
+
11
+ def without_changebase
12
+ @without_changebase = true
13
+ yield
14
+ ensure
15
+ @without_changebase = false
16
+ end
17
+
18
+ def drop_database(name) # :nodoc:
19
+ without_changebase { super }
20
+ end
21
+
22
+ def drop_table(table_name, **options) # :nodoc:
23
+ without_changebase { super }
24
+ end
25
+
26
+ def create_database(name, options = {})
27
+ without_changebase { super }
28
+ end
29
+
30
+ def recreate_database(name, options = {}) # :nodoc:
31
+ without_changebase { super }
32
+ end
33
+
34
+ def execute(sql, name = nil)
35
+ if !@without_changebase && !current_transaction.open? && write_query?(sql)
36
+ transaction { super }
37
+ else
38
+ super
39
+ end
40
+ end
41
+
42
+ def exec_query(sql, name = "SQL", binds = [], prepare: false)
43
+ if !@without_changebase && !current_transaction.open? && write_query?(sql)
44
+ transaction { super }
45
+ else
46
+ super
47
+ end
48
+ end
49
+
50
+ def exec_delete(sql, name = nil, binds = []) # :nodoc:
51
+ if !@without_changebase && !current_transaction.open? && write_query?(sql)
52
+ transaction { super }
53
+ else
54
+ super
55
+ end
56
+ end
57
+
58
+ def commit_db_transaction
59
+ if !@without_changebase && @changebase_metadata && !@changebase_metadata.empty?
60
+ sql = ::ActiveRecord::Base.send(:replace_named_bind_variables, <<~SQL, {version: 1, metadata: ActiveSupport::JSON.encode(@changebase_metadata)})
61
+ INSERT INTO #{quote_table_name(Changebase.metadata_table)} ( version, data )
62
+ VALUES ( :version, :metadata )
63
+ ON CONFLICT ( version )
64
+ DO UPDATE SET version = :version, data = :metadata;
65
+ SQL
66
+
67
+ log(sql, "CHANGEBASE") do
68
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
69
+ @connection.async_exec(sql)
70
+ end
71
+ end
72
+ end
73
+ super
74
+ end
75
+
76
+ if ::ActiveRecord.gem_version < ::Gem::Version.new("6.0.0")
77
+ CHANGEBASE_COMMENT_REGEX = %r{(?:--.*\n)|/\*(?:[^*]|\*[^/])*\*/}m
78
+ def self.changebase_build_read_query_regexp(*parts) # :nodoc:
79
+ parts += [:begin, :commit, :explain, :release, :rollback, :savepoint, :select, :with]
80
+ parts = parts.map { |part| /#{part}/i }
81
+ /\A(?:[(\s]|#{CHANGEBASE_COMMENT_REGEX})*#{Regexp.union(*parts)}/
82
+ end
83
+
84
+ CHANGEBASE_READ_QUERY = changebase_build_read_query_regexp(
85
+ :close, :declare, :fetch, :move, :set, :show
86
+ )
87
+ def write_query?(sql)
88
+ !CHANGEBASE_READ_QUERY.match?(sql)
89
+ rescue ArgumentError # Invalid encoding
90
+ !CHANGEBASE_READ_QUERY.match?(sql.b)
91
+ end
92
+ end
93
+
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,24 @@
1
+ require 'changebase'
2
+ Changebase.mode = 'replication'
3
+
4
+ module Changebase::Replication
5
+ def self.load!
6
+ require 'active_record'
7
+
8
+ ::ActiveRecord::Base.include(Changebase::ActiveRecord)
9
+ ::ActiveRecord::ConnectionAdapters::AbstractAdapter.include(Changebase::ActiveRecord::Connection)
10
+
11
+ require 'active_record/connection_adapters/postgresql_adapter'
12
+ require 'changebase/replication/active_record'
13
+ ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(Changebase::Replication::ActiveRecord::PostgreSQLAdapter)
14
+
15
+ @loaded = true
16
+ end
17
+
18
+ def self.loaded?
19
+ @loaded
20
+ end
21
+
22
+ end
23
+
24
+ Changebase::Replication.load!
@@ -1,3 +1,3 @@
1
1
  module Changebase
2
- VERSION = '0.1'
2
+ VERSION = '0.2'
3
3
  end
data/lib/changebase.rb CHANGED
@@ -1,16 +1,67 @@
1
1
  module Changebase
2
+
3
+ autoload :VERSION, 'changebase/version'
4
+ autoload :Connection, 'changebase/connection'
5
+ autoload :Inline, 'changebase/inline'
6
+ autoload :Replication, 'changebase/replication'
2
7
  autoload :ActiveRecord, 'changebase/active_record'
3
8
  autoload :ActionController, 'changebase/action_controller'
4
-
5
- @metadata_table = "changebase_metadata"
6
-
9
+
10
+ @config = {
11
+ mode: "replication",
12
+ metadata_table: "changebase_metadata"
13
+ }
14
+
7
15
  def self.metadata_table=(value)
8
- @metadata_table = value
16
+ @config[:metadata_table] = value
9
17
  end
10
-
18
+
11
19
  def self.metadata_table
12
- @metadata_table
20
+ @config[:metadata_table]
21
+ end
22
+
23
+ def self.mode=(value)
24
+ @config[:mode] = value
25
+ end
26
+
27
+ def self.mode
28
+ @config[:mode]
29
+ end
30
+
31
+ def self.connection=(value)
32
+ @config[:connection] = value
33
+ end
34
+
35
+ def self.connection
36
+ Thread.current[:changebase_connection] ||= Changebase::Connection.new({
37
+ url: @config[:connection]
38
+ })
39
+ end
40
+
41
+ def self.configure(**config)
42
+ @config.merge!(config)
43
+ self.logger = @config[:logger] if @config[:logger]
13
44
  end
45
+
46
+ def self.configured?
47
+ case @config[:mode]
48
+ when 'inline'
49
+ !!@config[:connection]
50
+ else
51
+ true
52
+ end
53
+ end
54
+
55
+ def self.logger
56
+ return @logger if defined?(@logger)
57
+
58
+ @logger = Logger.new(STDOUT)
59
+ end
60
+
61
+ def self.logger=(logger)
62
+ @logger = logger
63
+ end
64
+
14
65
  end
15
66
 
16
- require 'changebase/railtie' if defined?(Rails)
67
+ require 'changebase/railtie' if defined?(Rails)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: changebase
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.1'
4
+ version: '0.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-02-17 00:00:00.000000000 Z
12
+ date: 2022-06-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -17,7 +17,7 @@ dependencies:
17
17
  requirements:
18
18
  - - ">="
19
19
  - !ruby/object:Gem::Version
20
- version: '6'
20
+ version: '5.2'
21
21
  - - "<"
22
22
  - !ruby/object:Gem::Version
23
23
  version: '8'
@@ -27,7 +27,7 @@ dependencies:
27
27
  requirements:
28
28
  - - ">="
29
29
  - !ruby/object:Gem::Version
30
- version: '6'
30
+ version: '5.2'
31
31
  - - "<"
32
32
  - !ruby/object:Gem::Version
33
33
  version: '8'
@@ -37,7 +37,7 @@ dependencies:
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '6'
40
+ version: '5.2'
41
41
  - - "<"
42
42
  - !ruby/object:Gem::Version
43
43
  version: '8'
@@ -47,7 +47,7 @@ dependencies:
47
47
  requirements:
48
48
  - - ">="
49
49
  - !ruby/object:Gem::Version
50
- version: '6'
50
+ version: '5.2'
51
51
  - - "<"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '8'
@@ -57,7 +57,7 @@ dependencies:
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '6'
60
+ version: '5.2'
61
61
  - - "<"
62
62
  - !ruby/object:Gem::Version
63
63
  version: '8'
@@ -67,7 +67,7 @@ dependencies:
67
67
  requirements:
68
68
  - - ">="
69
69
  - !ruby/object:Gem::Version
70
- version: '6'
70
+ version: '5.2'
71
71
  - - "<"
72
72
  - !ruby/object:Gem::Version
73
73
  version: '8'
@@ -141,6 +141,20 @@ dependencies:
141
141
  - - ">="
142
142
  - !ruby/object:Gem::Version
143
143
  version: '0'
144
+ - !ruby/object:Gem::Dependency
145
+ name: webmock
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
144
158
  - !ruby/object:Gem::Dependency
145
159
  name: mocha
146
160
  requirement: !ruby/object:Gem::Requirement
@@ -225,7 +239,14 @@ files:
225
239
  - lib/changebase.rb
226
240
  - lib/changebase/action_controller.rb
227
241
  - lib/changebase/active_record.rb
242
+ - lib/changebase/connection.rb
243
+ - lib/changebase/inline.rb
244
+ - lib/changebase/inline/active_record.rb
245
+ - lib/changebase/inline/event.rb
246
+ - lib/changebase/inline/transaction.rb
228
247
  - lib/changebase/railtie.rb
248
+ - lib/changebase/replication.rb
249
+ - lib/changebase/replication/active_record.rb
229
250
  - lib/changebase/version.rb
230
251
  homepage: https://changebase.io
231
252
  licenses: []