database_recorder 0.1.0 → 0.2.1

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: 5d51e080ba82dada7af93fbb37380af72e25fef001ecc3f691080048b951f67f
4
- data.tar.gz: 5a902b17cab7f30b5cedae2b1e00e374fdc58b3e992cf3bc08d1be8421f37421
3
+ metadata.gz: d0b2575a34a9e68ee59f68884a492a33c071a05b9d9999caf3baeca5c070c11d
4
+ data.tar.gz: 29b78920c82393f3e9a23d334b7675d29dc96f3b77f13ff1bf33384cc3206a0d
5
5
  SHA512:
6
- metadata.gz: 936d740c398c162be09d7f9631c64ad3c516a55ab787991104df7e32bd53299ebf1d7b50d0f6b605f7118e495a7c7e1d551f2029085d123451291a6c054547f5
7
- data.tar.gz: 260f2d6c7bd1d3b65c67a25db41fce5efbd65dc19f294af7e852003c08e78126d58d0f6679b48299ff2ad0646eb96577f8ffc2383e137278e44f222d13326f70
6
+ metadata.gz: 0c7c56cc0c95e24b410ec5d63ac64b1944a20d7084d63ae64b0d80ea265b815435d2c3ce14eec3fb9d2f69261c567ddd59eaba24b4ca0c28f3993272fbb2e33b
7
+ data.tar.gz: 78db7a6377ba564f5e039bd74f32ffe4c4f6d6c6ff5719340818738a7a4ea40caf4f2164da1cf72e1ebb3800863e1921d3c2bbf32952ff00dc1154487367fe4a
data/README.md CHANGED
@@ -1,34 +1,66 @@
1
1
  # Database Recorder
2
+ [![Gem Version](https://badge.fury.io/rb/database_recorder.svg)](https://badge.fury.io/rb/database_recorder)
3
+ [![Linters](https://github.com/blocknotes/database_recorder/actions/workflows/linters.yml/badge.svg)](https://github.com/blocknotes/database_recorder/actions/workflows/linters.yml)
4
+ [![Specs ActiveRecord](https://github.com/blocknotes/database_recorder/actions/workflows/specs_active_record.yml/badge.svg)](https://github.com/blocknotes/database_recorder/actions/workflows/specs_active_record.yml)
5
+ [![Specs MySQL](https://github.com/blocknotes/database_recorder/actions/workflows/specs_mysql.yml/badge.svg)](https://github.com/blocknotes/database_recorder/actions/workflows/specs_mysql.yml)
6
+ [![Specs PostgreSQL](https://github.com/blocknotes/database_recorder/actions/workflows/specs_postgres.yml/badge.svg)](https://github.com/blocknotes/database_recorder/actions/workflows/specs_postgres.yml)
2
7
 
3
- Record database queries for testing and development purposes only.
4
- Support only RSpec at the moment, storing logs data on files or Redis.
8
+ Record database queries for testing and development purposes.
9
+ Supports only RSpec at the moment. Store queries information on files or Redis.
5
10
 
6
11
  Main features:
7
12
  - store the history of the queries of a test when it run (for monitoring);
8
13
  - eventually check if the current queries match the recorded ones (to prevent regressions);
9
14
  - [EXPERIMENTAL] optionally replay the recorded queries replacing the original requests.
10
15
 
11
- See below for more details.
16
+ Sample output: [test.yml](extra/sample.yml)
12
17
 
13
18
  ## Install
14
19
 
15
- - Add to your Gemfile: `gem 'database_recorder'` (:development, :test groups recommended)
16
- - With RSpec:
17
- + Add to the `spec_helper.rb` (or rails_helper): `DatabaseRecorder::RSpec.setup`
18
- + In RSpec examples: add `:dbr` metadata
19
- + To verify the matching with the recorded query use: `dbr: { verify_queries: true }`
20
+ - Add to your Gemfile: `gem 'database_recorder', require: false` (:development, :test groups recommended)
21
+ - Using RSpec, add in **rails_helper.rb**:
20
22
 
21
23
  ```rb
24
+ require 'database_recorder'
25
+ DatabaseRecorder::RSpec.setup
26
+ ```
27
+
28
+ - In the tests add `:dbr` metadata, examples:
29
+
30
+ ```rb
31
+ # Activate DatabaseRecorder with the default options
22
32
  it 'returns 3 posts', :dbr do
23
33
  # ...
24
34
  end
35
+
36
+ # Verify queries comparing with the stored ones:
37
+ it 'returns more posts', dbr: { verify_queries: true } do
38
+ # ...
39
+ end
25
40
  ```
26
41
 
42
+ Or eventually apply the metadata per path:
43
+
44
+ ```rb
45
+ RSpec.configure do |config|
46
+ config.define_derived_metadata(file_path: %r{/spec/models/}) do |metadata|
47
+ metadata[:dbr] = true
48
+ end
49
+ end
50
+ ```
51
+
52
+ Using an environment variable to enable it:
53
+
54
+ ![image1](extra/image1.png)
55
+
27
56
  ## Config
28
57
 
29
58
  Add to your _spec_helper.rb_:
30
59
 
31
60
  ```rb
61
+ # Database driver to use: :active_record | :mysql2 | :pg
62
+ DatabaseRecorder::Config.db_driver = :pg
63
+
32
64
  # To print the queries while executing the specs: false | true | :color
33
65
  DatabaseRecorder::Config.print_queries = true
34
66
 
@@ -38,6 +70,12 @@ DatabaseRecorder::Config.replay_recordings = true
38
70
  # To store the queries: :file | :redis | nil
39
71
  DatabaseRecorder::Config.storage = :redis
40
72
  # nil to avoid storing the queries
73
+
74
+ # File storage options
75
+ DatabaseRecorder::Config.storage_options = { recordings_path: '/some/path' }
76
+
77
+ # Redis storage options
78
+ DatabaseRecorder::Config.storage_options = { connection: Redis.new }
41
79
  ```
42
80
 
43
81
  ## History of the queries
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseRecorder
4
+ module ActiveRecord
5
+ module AbstractAdapterExt
6
+ def log(*args)
7
+ Recorder.record(self, *args) do
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseRecorder
4
+ module ActiveRecord
5
+ module Recorder
6
+ module_function
7
+
8
+ def ignore_query?(sql, name)
9
+ !Recording.started? ||
10
+ %w[schema transaction].include?(name&.downcase) ||
11
+ sql.downcase.match?(/\A(begin|commit|release|rollback|savepoint)/i)
12
+ end
13
+
14
+ def record(adapter, sql, name = 'SQL', binds = [], type_casted_binds = [], *args)
15
+ return yield if ignore_query?(sql, name)
16
+
17
+ Core.log_query(sql, name)
18
+ if Config.replay_recordings && Recording.from_cache
19
+ Recording.push(sql: sql, binds: binds)
20
+ data = Recording.cached_query_for(sql)
21
+ return yield if !data || !data[:result] # cache miss
22
+
23
+ RecordedResult.new(data[:result][:fields], data[:result][:values])
24
+ else
25
+ yield.tap do |result|
26
+ result_data =
27
+ if result && (result.respond_to?(:fields) || result.respond_to?(:columns))
28
+ fields = result.respond_to?(:fields) ? result.fields : result.columns
29
+ values = result.respond_to?(:values) ? result.values : result.to_a
30
+ { count: result.count, fields: fields, values: values }
31
+ end
32
+ Recording.push(sql: sql, name: name, binds: type_casted_binds, result: result_data)
33
+ end
34
+ end
35
+ end
36
+
37
+ def setup
38
+ ::ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do
39
+ prepend AbstractAdapterExt
40
+ end
41
+
42
+ # ::ActiveRecord::Base.class_eval do
43
+ # prepend BaseExt
44
+ # end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -7,28 +7,46 @@ module DatabaseRecorder
7
7
  class Config
8
8
  include Singleton
9
9
 
10
- attr_accessor :db_driver, :print_queries, :replay_recordings, :storage
10
+ DEFAULT_DB_DRIVER = :active_record
11
+ DEFAULT_STORAGE = DatabaseRecorder::Storage::File
12
+
13
+ DB_DRIVER_VALUES = %i[active_record mysql2 pg].freeze
14
+ PRINT_QUERIES_VALUES = [false, true, :color].freeze
15
+ STORAGE_VALUES = {
16
+ file: DatabaseRecorder::Storage::File,
17
+ redis: DatabaseRecorder::Storage::Redis
18
+ }.freeze
19
+
20
+ attr_accessor :db_driver, :print_queries, :replay_recordings, :storage, :storage_options
11
21
 
12
22
  class << self
13
23
  extend Forwardable
14
24
 
15
- def_delegators :instance, :db_driver, :db_driver=, :print_queries, :print_queries=, :replay_recordings,
16
- :replay_recordings=, :storage
25
+ def_delegators :instance, :db_driver, :print_queries, :replay_recordings, :replay_recordings=, :storage,
26
+ :storage_options, :storage_options=
17
27
 
18
28
  def load_defaults
19
- instance.db_driver = :active_record
20
- instance.print_queries = false # false | true | :color
29
+ instance.db_driver = DEFAULT_DB_DRIVER
30
+ instance.print_queries = false
21
31
  instance.replay_recordings = false
22
- self.storage = :file # :file | :redis
32
+ instance.storage = DEFAULT_STORAGE
33
+ instance.storage_options = {}
34
+ end
35
+
36
+ def db_driver=(value)
37
+ instance.db_driver = DB_DRIVER_VALUES.include?(value) ? value : DEFAULT_DB_DRIVER
38
+ end
39
+
40
+ def print_queries=(value)
41
+ instance.print_queries = PRINT_QUERIES_VALUES.include?(value) ? value : false
23
42
  end
24
43
 
25
44
  def storage=(value)
26
45
  instance.storage =
27
- case value
28
- when :file then DatabaseRecorder::Storage::File
29
- when :redis then DatabaseRecorder::Storage::Redis
30
- when nil then nil
31
- else raise ArgumentError, "Unknown storage: #{value}"
46
+ if value.is_a?(Class) && value < Storage::Base
47
+ value
48
+ else
49
+ STORAGE_VALUES[value]
32
50
  end
33
51
  end
34
52
  end
@@ -5,19 +5,45 @@ module DatabaseRecorder
5
5
  module_function
6
6
 
7
7
  def log_query(sql, source = nil)
8
- case DatabaseRecorder::Config.print_queries
9
- when true
10
- puts "[DB] #{sql} [#{source}]"
11
- when :color
12
- puts "[DB] #{CodeRay.scan(sql, :sql).term} [#{source}]"
13
- end
8
+ log =
9
+ case DatabaseRecorder::Config.print_queries
10
+ when true then "[DB] #{sql} [#{source}]"
11
+ when :color then "[DB] #{CodeRay.scan(sql, :sql).term} [#{source}]"
12
+ end
13
+
14
+ puts log if log
15
+ log
14
16
  end
15
17
 
16
18
  def setup
17
19
  case DatabaseRecorder::Config.db_driver
18
20
  when :active_record then ActiveRecord::Recorder.setup
19
- when :mysql2 then Mysql2.setup
20
- when :pg then PG.setup
21
+ when :mysql2 then Mysql2::Recorder.setup
22
+ when :pg then PG::Recorder.setup
23
+ end
24
+ end
25
+
26
+ def string_keys_recursive(hash)
27
+ {}.tap do |h|
28
+ hash.each do |key, value|
29
+ h[key.to_s] = transform(value, :string_keys_recursive)
30
+ end
31
+ end
32
+ end
33
+
34
+ def symbolize_recursive(hash)
35
+ {}.tap do |h|
36
+ hash.each do |key, value|
37
+ h[key.to_sym] = transform(value, :symbolize_recursive)
38
+ end
39
+ end
40
+ end
41
+
42
+ def transform(value, source_method)
43
+ case value
44
+ when Hash then method(source_method).call(value)
45
+ when Array then value.map { |v| transform(v, source_method) }
46
+ else value
21
47
  end
22
48
  end
23
49
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseRecorder
4
+ module Mysql2
5
+ module ClientExt
6
+ def query(sql, options = {})
7
+ Recorder.store_query(self, sql: sql, source: :query) do
8
+ super
9
+ end
10
+ end
11
+
12
+ def prepare(*args)
13
+ Recorder.prepare_statement(self, sql: args[0], source: :prepare) do
14
+ super
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -4,7 +4,9 @@
4
4
 
5
5
  module DatabaseRecorder
6
6
  module Mysql2
7
- class RecordedResult # < ::Mysql2::Result
7
+ class RecordedResult
8
+ # < ::Mysql2::Result
9
+
8
10
  # include Enumerable
9
11
  # extend Forwardable
10
12
 
@@ -15,10 +17,10 @@ module DatabaseRecorder
15
17
  alias :size :count
16
18
 
17
19
  def prepare(data)
18
- @count = data['count']
19
- @fields = data['fields']
20
- @entries = data['values']
21
- # @values = data['values']
20
+ @count = data[:count]
21
+ @fields = data[:fields]
22
+ @entries = data[:values]
23
+ # @values = data[:values]
22
24
  end
23
25
 
24
26
  # def server_flags
@@ -3,50 +3,69 @@
3
3
  module DatabaseRecorder
4
4
  module Mysql2
5
5
  module Recorder
6
- def query(sql, options = {})
7
- Mysql2.record(self, sql: sql) do
8
- super
9
- end
6
+ module_function
7
+
8
+ def ignore_query?(sql)
9
+ !Recording.started? ||
10
+ sql == 'SELECT LAST_INSERT_ID() AS _dbr_last_insert_id' ||
11
+ sql.downcase.match?(/\A(begin|commit|release|rollback|savepoint|show full fields from)/i) ||
12
+ sql.match?(/information_schema.statistics/)
13
+ end
14
+
15
+ def format_result(result)
16
+ { count: result.count, fields: result.fields, values: result.to_a } if result.is_a?(::Mysql2::Result)
17
+ # else
18
+ # last_insert_id = adapter.query('SELECT LAST_INSERT_ID() AS _dbr_last_insert_id').to_a
19
+ # { 'count' => last_insert_id.count, 'fields' => ['id'], 'values' => last_insert_id }
10
20
  end
11
- end
12
21
 
13
- module_function
22
+ def prepare_statement(adapter, sql: nil, name: nil, binds: nil, source: nil)
23
+ @last_prepared = Recording.push_prepared(name: name, sql: sql, binds: binds, source: source)
24
+ yield if !Config.replay_recordings || Recording.cache.nil?
25
+ end
14
26
 
15
- def ignore_query?(sql)
16
- !Recording.started? ||
17
- sql == 'SELECT LAST_INSERT_ID() AS _dbr_last_insert_id' ||
18
- sql.downcase.match?(/\A(begin|commit|release|rollback|savepoint|show full fields from)/i) ||
19
- sql.match?(/information_schema.statistics/)
20
- end
27
+ def setup
28
+ ::Mysql2::Client.class_eval do
29
+ prepend ClientExt
30
+ end
21
31
 
22
- def record(recorder, sql:)
23
- return yield if ignore_query?(sql)
24
-
25
- Core.log_query(sql)
26
- if Config.replay_recordings && !Recording.cache.nil?
27
- Recording.push(sql: sql)
28
- data = Recording.cached_query_for(sql)
29
- return yield unless data # cache miss
30
-
31
- RecordedResult.new.prepare(data['result'].slice('count', 'fields', 'values')) if data['result']
32
- else
33
- yield.tap do |result|
34
- result_data =
35
- if result
36
- { 'count' => result.count, 'fields' => result.fields, 'values' => result.to_a }
37
- else
38
- last_insert_id = recorder.query('SELECT LAST_INSERT_ID() AS _dbr_last_insert_id').to_a
39
- { 'count' => last_insert_id.count, 'fields' => ['id'], 'values' => last_insert_id }
40
- end
41
-
42
- Recording.push(sql: sql, result: result_data)
32
+ ::Mysql2::Statement.class_eval do
33
+ prepend StatementExt
43
34
  end
44
35
  end
45
- end
46
36
 
47
- def setup
48
- ::Mysql2::Client.class_eval do
49
- prepend Recorder
37
+ def store_prepared_statement(adapter, source:, binds:)
38
+ # sql = @last_prepared&.send(:[], 'sql')
39
+ sql = @last_prepared[:sql]
40
+ Core.log_query(sql, source)
41
+ if Config.replay_recordings && !Recording.cache.nil?
42
+ data = Recording.cache.find { |query| query[:sql] == sql }
43
+ return yield unless data # cache miss
44
+
45
+ Recording.push(sql: data[:sql], binds: data[:binds], source: source)
46
+ RecordedResult.new(data[:result].slice(:count, :fields, :values))
47
+ else
48
+ yield.tap do |result|
49
+ Recording.update_prepared(sql: sql, binds: binds, result: format_result(result), source: source)
50
+ end
51
+ end
52
+ end
53
+
54
+ def store_query(adapter, sql:, source:)
55
+ return yield if ignore_query?(sql)
56
+
57
+ Core.log_query(sql, source)
58
+ if Config.replay_recordings && !Recording.cache.nil?
59
+ Recording.push(sql: sql, source: source)
60
+ data = Recording.cached_query_for(sql)
61
+ return yield unless data # cache miss
62
+
63
+ RecordedResult.new.prepare(data[:result].slice(:count, :fields, :values)) if data[:result]
64
+ else
65
+ yield.tap do |result|
66
+ Recording.push(sql: sql, result: format_result(result), source: source)
67
+ end
68
+ end
50
69
  end
51
70
  end
52
71
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseRecorder
4
+ module Mysql2
5
+ module StatementExt
6
+ def execute(*args, **kwargs)
7
+ Recorder.store_prepared_statement(self, source: :execute, binds: args) do
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseRecorder
4
+ module PG
5
+ module ConnectionExt
6
+ def async_exec(sql)
7
+ Recorder.store_query(sql: sql, source: :async_exec) do
8
+ super
9
+ end
10
+ end
11
+
12
+ def sync_exec(sql)
13
+ Recorder.store_query(sql: sql, source: :sync_exec) do
14
+ super
15
+ end
16
+ end
17
+
18
+ def exec(*args)
19
+ Recorder.store_query(sql: args[0], source: :exec) do
20
+ super
21
+ end
22
+ end
23
+
24
+ def exec_params(*args)
25
+ Recorder.store_query(sql: args[0], binds: args[1], source: :exec_params) do
26
+ super
27
+ end
28
+ end
29
+
30
+ def exec_prepared(*args)
31
+ Recorder.store_prepared_statement(name: args[0], binds: args[1], source: :exec_prepared) do
32
+ super
33
+ end
34
+ end
35
+
36
+ def prepare(*args)
37
+ Recorder.prepare_statement(name: args[0], sql: args[1], source: :prepare) do
38
+ super
39
+ end
40
+ end
41
+
42
+ def query(*args)
43
+ Recorder.store_query(sql: args[0], source: :query) do
44
+ super
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -11,9 +11,9 @@ module DatabaseRecorder
11
11
  alias :rows :values
12
12
 
13
13
  def initialize(data)
14
- @count = data['count']
15
- @fields = data['fields']
16
- @values = data['values']
14
+ @count = data[:count]
15
+ @fields = data[:fields]
16
+ @values = data[:values]
17
17
  end
18
18
 
19
19
  def clear; end
@@ -3,81 +3,64 @@
3
3
  module DatabaseRecorder
4
4
  module PG
5
5
  module Recorder
6
- def async_exec(sql)
7
- PG.record(sql: sql, source: :async_exec) do
8
- super
9
- end
10
- end
6
+ module_function
11
7
 
12
- def sync_exec(sql)
13
- PG.record(sql: sql, source: :sync_exec) do
14
- super
15
- end
8
+ def ignore_query?(sql)
9
+ !Recording.started? ||
10
+ sql.downcase.match?(/\A(begin|commit|release|rollback|savepoint)/i) ||
11
+ sql.match?(/ pg_attribute |SHOW max_identifier_length|SHOW search_path/)
16
12
  end
17
13
 
18
- # def exec(*args)
19
- # puts "-E- #{args[0]}"
20
- # super
21
- # end
14
+ def format_result(result)
15
+ { count: result.count, fields: result.fields, values: result.values } if result
16
+ end
22
17
 
23
- # def query(*args)
24
- # puts "-Q- #{args[0]}"
25
- # super
26
- # end
18
+ def prepare_statement(sql: nil, name: nil, binds: nil, source: nil)
19
+ Recording.push_prepared(name: name, sql: sql, binds: binds, source: source)
20
+ yield if !Config.replay_recordings || Recording.cache.nil?
21
+ end
27
22
 
28
- def exec_params(*args)
29
- PG.record(sql: args[0], binds: args[1], source: :exec_params) do
30
- super
23
+ def setup
24
+ ::PG::Connection.class_eval do
25
+ prepend ConnectionExt
31
26
  end
32
27
  end
33
28
 
34
- # def async_exec_params(*args)
35
- # puts ">>> #{args[0]}"
36
- # super
37
- # end
38
-
39
- # def sync_exec_params(*args)
40
- # puts ">>> #{args[0]}"
41
- # super
42
- # end
29
+ def store_prepared_statement(name: nil, sql: nil, binds: nil, source: nil)
30
+ if Config.replay_recordings && !Recording.cache.nil?
31
+ data = Recording.cache.find { |query| query[:name] == name }
32
+ return yield unless data # cache miss
43
33
 
44
- def exec_prepared(*args)
45
- PG.record(sql: args[0], binds: args[1], source: :exec_prepared) do
46
- super
34
+ Core.log_query(data[:sql], source)
35
+ Recording.push(sql: data[:sql], binds: data[:binds], source: source)
36
+ RecordedResult.new(data[:result].slice(:count, :fields, :values))
37
+ else
38
+ Core.log_query(sql, source)
39
+ yield.tap do |query_result|
40
+ result = format_result(query_result)
41
+ query = Recording.update_prepared(name: name, sql: sql, binds: binds, result: result, source: source)
42
+ Core.log_query(query[:sql], source)
43
+ end
47
44
  end
48
45
  end
49
- end
50
46
 
51
- module_function
47
+ def store_query(name: nil, sql: nil, binds: nil, source: nil)
48
+ return yield if ignore_query?(sql)
52
49
 
53
- def ignore_query?(sql)
54
- !Recording.started? ||
55
- sql.downcase.match?(/\A(begin|commit|release|rollback|savepoint)/i) ||
56
- sql.match?(/ pg_attribute |SHOW max_identifier_length|SHOW search_path/)
57
- end
58
-
59
- def record(sql:, binds: nil, source: nil)
60
- return yield if ignore_query?(sql)
61
-
62
- Core.log_query(sql, source)
63
- if Config.replay_recordings && !Recording.cache.nil?
64
- Recording.push(sql: sql, binds: binds)
65
- data = Recording.cached_query_for(sql)
66
- return yield unless data # cache miss
50
+ Core.log_query(sql, source)
51
+ @prepared_statement = nil
52
+ if Config.replay_recordings && !Recording.cache.nil?
53
+ Recording.push(sql: sql, binds: binds, source: source)
54
+ data = Recording.cached_query_for(sql)
55
+ return yield unless data # cache miss
67
56
 
68
- RecordedResult.new(data['result'].slice('count', 'fields', 'values'))
69
- else
70
- yield.tap do |result|
71
- result_data = result ? { 'count' => result.count, 'fields' => result.fields, 'values' => result.values } : nil
72
- Recording.push(sql: sql, binds: binds, result: result_data)
57
+ RecordedResult.new(data[:result].slice(:count, :fields, :values))
58
+ else
59
+ yield.tap do |result|
60
+ Recording.push(name: name, sql: sql, binds: binds, result: format_result(result), source: source)
61
+ end
73
62
  end
74
63
  end
75
64
  end
76
-
77
- def setup
78
- ::PG::Connection.class_eval do
79
- prepend Recorder
80
- end
81
- end
82
65
  end
83
66
  end
@@ -4,23 +4,25 @@ require 'forwardable'
4
4
 
5
5
  module DatabaseRecorder
6
6
  class Recording
7
- attr_accessor :cache, :entities
8
- attr_reader :from_cache, :options, :queries, :started
7
+ attr_accessor :cache, :entities, :metadata
8
+ attr_reader :from_cache, :options, :prepared_queries, :queries, :started
9
9
 
10
10
  def initialize(options: {})
11
11
  (@@instances ||= {})[Process.pid] = self
12
12
  @cache = nil
13
13
  @entities = []
14
+ @metadata = {}
14
15
  @options = options
15
16
  @queries = []
16
17
  @search_index = 0
18
+ @@prepared_queries ||= {}
17
19
  end
18
20
 
19
21
  def cached_query_for(sql)
20
22
  current = @search_index
21
23
  match = cache[@search_index..].find do |item|
22
24
  current += 1
23
- item['sql'] == sql # TODo: try matching by normalized query (no values)
25
+ item[:sql] == sql
24
26
  end
25
27
  return unless match
26
28
 
@@ -31,34 +33,49 @@ module DatabaseRecorder
31
33
  end
32
34
 
33
35
  def new_entity(model:, id:)
34
- @entities.push('model' => model, 'id' => id)
36
+ @entities.push(model: model, id: id)
35
37
  end
36
38
 
37
39
  def pull_entity
38
40
  @entities.shift
39
41
  end
40
42
 
41
- def push(sql:, binds: nil, result: nil, name: nil)
42
- query = { 'name' => name, 'sql' => sql, 'binds' => binds, 'result' => result }.compact
43
+ def push(sql:, name: nil, binds: nil, result: nil, source: nil)
44
+ query = { name: name, sql: sql, binds: binds, result: result }.compact
43
45
  @queries.push(query)
44
46
  end
45
47
 
48
+ def push_prepared(name: nil, sql: nil, binds: nil, result: nil, source: nil)
49
+ query = { name: name, sql: sql, binds: binds, result: result }.compact
50
+ @@prepared_queries[name || sql] = query
51
+ end
52
+
46
53
  def start
47
54
  @started = true
48
- storage = Config.storage&.new(self, name: options[:name])
55
+ storage = Config.storage&.new(self, name: options[:name], options: Config.storage_options)
49
56
  @from_cache = storage&.load
50
57
  yield
51
58
  storage&.save unless from_cache
52
59
  @started = false
53
- result = { current_queries: queries.map { _1['sql'] } }
54
- result[:stored_queries] = cache.map { _1['sql'] } if from_cache
60
+ result = { current_queries: queries.map { |query| query[:sql] } }
61
+ result[:stored_queries] = cache.map { |query| query[:sql] } if from_cache
55
62
  result
56
63
  end
57
64
 
65
+ def update_prepared(name: nil, sql: nil, binds: nil, result: nil, source: nil)
66
+ query = @@prepared_queries[name || sql]
67
+ query[:sql] = sql if sql
68
+ query[:binds] = binds if binds
69
+ query[:result] = result if result
70
+ @queries.push(query)
71
+ query
72
+ end
73
+
58
74
  class << self
59
75
  extend Forwardable
60
76
 
61
- def_delegators :current_instance, :cache, :cached_query_for, :from_cache, :new_entity, :pull_entity, :push
77
+ def_delegators :current_instance, :cache, :cached_query_for, :from_cache, :new_entity, :prepared_queries,
78
+ :pull_entity, :push, :push_prepared, :queries, :update_prepared
62
79
 
63
80
  def current_instance
64
81
  (@@instances ||= {})[Process.pid]
@@ -14,9 +14,9 @@ module DatabaseRecorder
14
14
  ref = (example.metadata[:scoped_id] || '').split(':')[-1]
15
15
  options = {}
16
16
  options.merge!(example.metadata[:dbr]) if example.metadata[:dbr].is_a?(Hash)
17
- options.merge!(example: example)
18
- options.merge!(name: "#{example.full_description}__#{ref}") # TODO: if name is already set, append ref
17
+ options.merge!(example: example, name: "#{example.full_description}__#{ref}")
19
18
  Recording.new(options: options).tap do |recording|
19
+ recording.metadata = { example: example.id, started_at: Time.now }
20
20
  result = recording.start { example.run }
21
21
  if options[:verify_queries] && result[:stored_queries]
22
22
  expect(result[:stored_queries]).to match_array(result[:current_queries])
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseRecorder
4
+ module Storage
5
+ class Base
6
+ def initialize(recording, name:, options: {})
7
+ @recording = recording
8
+ @name = name
9
+ @options = options
10
+ end
11
+ end
12
+ end
13
+ end
@@ -2,18 +2,14 @@
2
2
 
3
3
  module DatabaseRecorder
4
4
  module Storage
5
- class File
6
- def initialize(recording, name:)
7
- @recording = recording
8
- @name = name
9
- end
10
-
5
+ class File < Base
11
6
  def load
12
- stored_data = ::File.exist?(record_file) ? ::File.read(record_file) : false
7
+ stored_data = ::File.exist?(storage_path) ? ::File.read(storage_path) : false
13
8
  if stored_data
14
- data = YAML.load(stored_data)
15
- @recording.cache = data['queries']
16
- @recording.entities = data['entities']
9
+ parsed_data = YAML.load(stored_data) # rubocop:disable Security/YAMLLoad
10
+ data = Core.symbolize_recursive(parsed_data)
11
+ @recording.cache = data[:queries] || []
12
+ @recording.entities = data[:entities]
17
13
  true
18
14
  else
19
15
  false
@@ -21,10 +17,22 @@ module DatabaseRecorder
21
17
  end
22
18
 
23
19
  def save
24
- data = { 'queries' => @recording.queries }
25
- data['entities'] = @recording.entities if @recording.entities.any?
26
- serialized_data = data.to_yaml
27
- ::File.write(record_file, serialized_data)
20
+ data = {}
21
+ data[:metadata] = @recording.metadata unless @recording.metadata.empty?
22
+ data[:queries] = @recording.queries if @recording.queries.any?
23
+ data[:entities] = @recording.entities if @recording.entities.any?
24
+ serialized_data = Core.string_keys_recursive(data).to_yaml
25
+ ::File.write(storage_path, serialized_data)
26
+ true
27
+ end
28
+
29
+ def storage_path
30
+ @storage_path ||= begin
31
+ name = normalize_name(@name)
32
+ path = @options[:recordings_path] || 'spec/dbr'
33
+ FileUtils.mkdir_p(path)
34
+ "#{path}/#{name}.yml"
35
+ end
28
36
  end
29
37
 
30
38
  private
@@ -32,13 +40,6 @@ module DatabaseRecorder
32
40
  def normalize_name(string)
33
41
  string.gsub(%r{[:/]}, '-').gsub(/[^\w-]/, '_')
34
42
  end
35
-
36
- def record_file
37
- name = normalize_name(@name)
38
- path = 'spec/dbr'
39
- FileUtils.mkdir_p(path)
40
- "#{path}/#{name}.yml"
41
- end
42
43
  end
43
44
  end
44
45
  end
@@ -2,18 +2,18 @@
2
2
 
3
3
  module DatabaseRecorder
4
4
  module Storage
5
- class Redis
6
- def initialize(recording, name:)
7
- @recording = recording
8
- @name = name
5
+ class Redis < Base
6
+ def connection
7
+ @connection ||= @options[:connection] || ::Redis.new
9
8
  end
10
9
 
11
10
  def load
12
- stored_data = self.class.connection.get(@name)
11
+ stored_data = connection.get(@name)
13
12
  if stored_data
14
- data = JSON.parse(stored_data)
15
- @recording.cache = data['queries']
16
- @recording.entities = data['entities']
13
+ parsed_data = JSON.parse(stored_data)
14
+ data = Core.symbolize_recursive(parsed_data)
15
+ @recording.cache = data[:queries] || []
16
+ @recording.entities = data[:entities]
17
17
  true
18
18
  else
19
19
  false
@@ -21,16 +21,13 @@ module DatabaseRecorder
21
21
  end
22
22
 
23
23
  def save
24
- data = { 'queries' => @recording.queries }
25
- data['entities'] = @recording.entities if @recording.entities.any?
24
+ data = {}
25
+ data[:metadata] = @recording.metadata unless @recording.metadata.empty?
26
+ data[:queries] = @recording.queries if @recording.queries.any?
27
+ data[:entities] = @recording.entities if @recording.entities.any?
26
28
  serialized_data = data.to_json
27
- self.class.connection.set(@name, serialized_data)
28
- end
29
-
30
- class << self
31
- def connection
32
- @connection ||= ::Redis.new
33
- end
29
+ connection.set(@name, serialized_data)
30
+ true
34
31
  end
35
32
  end
36
33
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # :nocov:
3
4
  module DatabaseRecorder
4
- VERSION = '0.1.0'
5
+ VERSION = '0.2.1'
5
6
  end
7
+ # :nocov:
@@ -3,22 +3,22 @@
3
3
  require_relative 'database_recorder/core'
4
4
 
5
5
  if defined? ::ActiveRecord
6
- require_relative 'database_recorder/activerecord/abstract_adapter_ext'
7
- require_relative 'database_recorder/activerecord/base_ext'
8
- require_relative 'database_recorder/activerecord/recorded_result'
9
- require_relative 'database_recorder/activerecord/recorder'
6
+ require_relative 'database_recorder/active_record/abstract_adapter_ext'
7
+ require_relative 'database_recorder/active_record/base_ext'
8
+ require_relative 'database_recorder/active_record/recorded_result'
9
+ require_relative 'database_recorder/active_record/recorder'
10
10
  end
11
11
 
12
- if defined? ::Mysql2
13
- require_relative 'database_recorder/mysql2/recorded_result'
14
- require_relative 'database_recorder/mysql2/recorder'
15
- end
12
+ require_relative 'database_recorder/mysql2/client_ext'
13
+ require_relative 'database_recorder/mysql2/recorded_result'
14
+ require_relative 'database_recorder/mysql2/recorder'
15
+ require_relative 'database_recorder/mysql2/statement_ext'
16
16
 
17
- if defined? ::PG
18
- require_relative 'database_recorder/pg/recorded_result'
19
- require_relative 'database_recorder/pg/recorder'
20
- end
17
+ require_relative 'database_recorder/pg/connection_ext'
18
+ require_relative 'database_recorder/pg/recorded_result'
19
+ require_relative 'database_recorder/pg/recorder'
21
20
 
21
+ require_relative 'database_recorder/storage/base'
22
22
  require_relative 'database_recorder/storage/file'
23
23
  require_relative 'database_recorder/storage/redis'
24
24
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: database_recorder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mattia Roccoberton
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-04-08 00:00:00.000000000 Z
11
+ date: 2022-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: coderay
@@ -35,18 +35,22 @@ files:
35
35
  - MIT-LICENSE
36
36
  - README.md
37
37
  - lib/database_recorder.rb
38
- - lib/database_recorder/activerecord/abstract_adapter_ext.rb
39
- - lib/database_recorder/activerecord/base_ext.rb
40
- - lib/database_recorder/activerecord/recorded_result.rb
41
- - lib/database_recorder/activerecord/recorder.rb
38
+ - lib/database_recorder/active_record/abstract_adapter_ext.rb
39
+ - lib/database_recorder/active_record/base_ext.rb
40
+ - lib/database_recorder/active_record/recorded_result.rb
41
+ - lib/database_recorder/active_record/recorder.rb
42
42
  - lib/database_recorder/config.rb
43
43
  - lib/database_recorder/core.rb
44
+ - lib/database_recorder/mysql2/client_ext.rb
44
45
  - lib/database_recorder/mysql2/recorded_result.rb
45
46
  - lib/database_recorder/mysql2/recorder.rb
47
+ - lib/database_recorder/mysql2/statement_ext.rb
48
+ - lib/database_recorder/pg/connection_ext.rb
46
49
  - lib/database_recorder/pg/recorded_result.rb
47
50
  - lib/database_recorder/pg/recorder.rb
48
51
  - lib/database_recorder/recording.rb
49
52
  - lib/database_recorder/rspec.rb
53
+ - lib/database_recorder/storage/base.rb
50
54
  - lib/database_recorder/storage/file.rb
51
55
  - lib/database_recorder/storage/redis.rb
52
56
  - lib/database_recorder/version.rb
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DatabaseRecorder
4
- module ActiveRecord
5
- module AbstractAdapterExt
6
- def log(sql, name = 'SQL', binds = [], type_casted_binds = [], *args)
7
- # puts "--- #{sql} | #{type_casted_binds}", " > #{name}"
8
- return super unless Recording.started?
9
- return super if %w[schema transaction].include?(name&.downcase)
10
- return super if sql.downcase.match(/\A(begin|commit|release|rollback|savepoint)/i)
11
-
12
- Core.log_query(sql, name)
13
- if Config.replay_recordings && Recording.from_cache
14
- Recording.push(sql: sql, binds: binds)
15
- data = Recording.cached_query_for(sql)
16
- return yield if !data || !data['result'] # cache miss
17
-
18
- RecordedResult.new(data['result']['fields'], data['result']['values'])
19
- else
20
- super.tap do |result|
21
- result_data =
22
- if result.is_a?(::ActiveRecord::Result)
23
- fields = result.respond_to?(:fields) ? result.fields : result.columns
24
- values = result.respond_to?(:values) ? result.values : result.to_a
25
- { 'count' => result.count, 'fields' => fields, 'values' => values }
26
- end
27
- Recording.push(sql: sql, name: name, binds: type_casted_binds, result: result_data)
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DatabaseRecorder
4
- module ActiveRecord
5
- module Recorder
6
- module_function
7
-
8
- def setup
9
- ::ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do
10
- prepend AbstractAdapterExt
11
- end
12
-
13
- # ::ActiveRecord::Base.class_eval do
14
- # prepend BaseExt
15
- # end
16
- end
17
- end
18
- end
19
- end