database_recorder 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +81 -0
- data/lib/database_recorder/activerecord/abstract_adapter_ext.rb +33 -0
- data/lib/database_recorder/activerecord/base_ext.rb +21 -0
- data/lib/database_recorder/activerecord/recorded_result.rb +23 -0
- data/lib/database_recorder/activerecord/recorder.rb +19 -0
- data/lib/database_recorder/config.rb +38 -0
- data/lib/database_recorder/core.rb +24 -0
- data/lib/database_recorder/mysql2/recorded_result.rb +35 -0
- data/lib/database_recorder/mysql2/recorder.rb +53 -0
- data/lib/database_recorder/pg/recorded_result.rb +49 -0
- data/lib/database_recorder/pg/recorder.rb +83 -0
- data/lib/database_recorder/recording.rb +72 -0
- data/lib/database_recorder/rspec.rb +29 -0
- data/lib/database_recorder/storage/file.rb +44 -0
- data/lib/database_recorder/storage/redis.rb +37 -0
- data/lib/database_recorder/version.rb +5 -0
- data/lib/database_recorder.rb +27 -0
- metadata +79 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5d51e080ba82dada7af93fbb37380af72e25fef001ecc3f691080048b951f67f
|
4
|
+
data.tar.gz: 5a902b17cab7f30b5cedae2b1e00e374fdc58b3e992cf3bc08d1be8421f37421
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 936d740c398c162be09d7f9631c64ad3c516a55ab787991104df7e32bd53299ebf1d7b50d0f6b605f7118e495a7c7e1d551f2029085d123451291a6c054547f5
|
7
|
+
data.tar.gz: 260f2d6c7bd1d3b65c67a25db41fce5efbd65dc19f294af7e852003c08e78126d58d0f6679b48299ff2ad0646eb96577f8ffc2383e137278e44f222d13326f70
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2022 Mattia Roccoberton
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
# Database Recorder
|
2
|
+
|
3
|
+
Record database queries for testing and development purposes only.
|
4
|
+
Support only RSpec at the moment, storing logs data on files or Redis.
|
5
|
+
|
6
|
+
Main features:
|
7
|
+
- store the history of the queries of a test when it run (for monitoring);
|
8
|
+
- eventually check if the current queries match the recorded ones (to prevent regressions);
|
9
|
+
- [EXPERIMENTAL] optionally replay the recorded queries replacing the original requests.
|
10
|
+
|
11
|
+
See below for more details.
|
12
|
+
|
13
|
+
## Install
|
14
|
+
|
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
|
+
|
21
|
+
```rb
|
22
|
+
it 'returns 3 posts', :dbr do
|
23
|
+
# ...
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
## Config
|
28
|
+
|
29
|
+
Add to your _spec_helper.rb_:
|
30
|
+
|
31
|
+
```rb
|
32
|
+
# To print the queries while executing the specs: false | true | :color
|
33
|
+
DatabaseRecorder::Config.print_queries = true
|
34
|
+
|
35
|
+
# Replay the recordings intercepting the queries
|
36
|
+
DatabaseRecorder::Config.replay_recordings = true
|
37
|
+
|
38
|
+
# To store the queries: :file | :redis | nil
|
39
|
+
DatabaseRecorder::Config.storage = :redis
|
40
|
+
# nil to avoid storing the queries
|
41
|
+
```
|
42
|
+
|
43
|
+
## History of the queries
|
44
|
+
|
45
|
+
Using the `print_queries` config option is possible to see the executed queries while running the specs. It can be used to identify easily what is going on in a specific example without having to analyze the log files.
|
46
|
+
|
47
|
+
Using the `:file` storage, the history is also recorded to files (default path: **spec/dbr**) in YAML format. This is useful for checking what's happening with more details, it includes the query results and some extra data.
|
48
|
+
|
49
|
+
## Test queries' changes
|
50
|
+
|
51
|
+
This feature can be used to prevent queries regressions.
|
52
|
+
It requires to have previously stored the history of the queries (which could be versioned if using file storage).
|
53
|
+
It can be activated using `dbr: { verify_queries: true }` metadata.
|
54
|
+
|
55
|
+
To work correctly in requires `prepared_statements: true` option in the **database.yml** config file, in the connection block options (available for both Postgres and MySQL).
|
56
|
+
|
57
|
+
## Replay the recorded queries
|
58
|
+
|
59
|
+
This feature is not stable (at this stage), so use it carefully and supports only deterministic tests (so it doesn't work with Faker, random data or random order specs) and only Postgres is supported for now.
|
60
|
+
It requires to have previously stored the history of the queries.
|
61
|
+
Using this feature can improve the test suite performances (especially using redis storage).
|
62
|
+
It can be activated using `replay_recordings` config option.
|
63
|
+
|
64
|
+
Some workarounds to make it works:
|
65
|
+
- Run specs with `bin/rspec --order defined`
|
66
|
+
- Set a specific seed for Faker (optionally with an ENV var): `Faker::Config.random = Random.new(42)`
|
67
|
+
- Set a specific Ruby seed (optionally with an ENV var): `srand(42)`
|
68
|
+
|
69
|
+
## Do you like it? Star it!
|
70
|
+
|
71
|
+
If you use this component just star it. A developer is more motivated to improve a project when there is some interest.
|
72
|
+
|
73
|
+
Or consider offering me a coffee, it's a small thing but it is greatly appreciated: [about me](https://www.blocknot.es/about-me).
|
74
|
+
|
75
|
+
## Contributors
|
76
|
+
|
77
|
+
- [Mattia Roccoberton](https://blocknot.es): author
|
78
|
+
|
79
|
+
## License
|
80
|
+
|
81
|
+
The gem is available as open-source under the terms of the [MIT](MIT-LICENSE).
|
@@ -0,0 +1,33 @@
|
|
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
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module ActiveRecord
|
5
|
+
module BaseExt
|
6
|
+
def create_or_update(**, &block)
|
7
|
+
return super if !new_record? || !Recording.started?
|
8
|
+
|
9
|
+
cached = Config.replay_recordings && Recording.from_cache ? Recording.pull_entity : false
|
10
|
+
if cached
|
11
|
+
# self.id = cached['id']
|
12
|
+
super
|
13
|
+
else
|
14
|
+
super.tap do |_result|
|
15
|
+
Recording.new_entity(model: self.class.to_s, id: id)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# require 'forwardable'
|
4
|
+
|
5
|
+
module DatabaseRecorder
|
6
|
+
module ActiveRecord
|
7
|
+
class RecordedResult < ::ActiveRecord::Result
|
8
|
+
alias :cmd_tuples :count
|
9
|
+
alias :fields :columns
|
10
|
+
alias :values :rows
|
11
|
+
|
12
|
+
def clear; end
|
13
|
+
|
14
|
+
def fmod(_index)
|
15
|
+
-1
|
16
|
+
end
|
17
|
+
|
18
|
+
def ftype(_index)
|
19
|
+
20
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,19 @@
|
|
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
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require 'singleton'
|
5
|
+
|
6
|
+
module DatabaseRecorder
|
7
|
+
class Config
|
8
|
+
include Singleton
|
9
|
+
|
10
|
+
attr_accessor :db_driver, :print_queries, :replay_recordings, :storage
|
11
|
+
|
12
|
+
class << self
|
13
|
+
extend Forwardable
|
14
|
+
|
15
|
+
def_delegators :instance, :db_driver, :db_driver=, :print_queries, :print_queries=, :replay_recordings,
|
16
|
+
:replay_recordings=, :storage
|
17
|
+
|
18
|
+
def load_defaults
|
19
|
+
instance.db_driver = :active_record
|
20
|
+
instance.print_queries = false # false | true | :color
|
21
|
+
instance.replay_recordings = false
|
22
|
+
self.storage = :file # :file | :redis
|
23
|
+
end
|
24
|
+
|
25
|
+
def storage=(value)
|
26
|
+
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}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
DatabaseRecorder::Config.load_defaults
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module Core
|
5
|
+
module_function
|
6
|
+
|
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
|
14
|
+
end
|
15
|
+
|
16
|
+
def setup
|
17
|
+
case DatabaseRecorder::Config.db_driver
|
18
|
+
when :active_record then ActiveRecord::Recorder.setup
|
19
|
+
when :mysql2 then Mysql2.setup
|
20
|
+
when :pg then PG.setup
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# require 'forwardable'
|
4
|
+
|
5
|
+
module DatabaseRecorder
|
6
|
+
module Mysql2
|
7
|
+
class RecordedResult # < ::Mysql2::Result
|
8
|
+
# include Enumerable
|
9
|
+
# extend Forwardable
|
10
|
+
|
11
|
+
# def_delegators :to_a, :each
|
12
|
+
|
13
|
+
attr_reader :count, :entries, :fields # , :values
|
14
|
+
|
15
|
+
alias :size :count
|
16
|
+
|
17
|
+
def prepare(data)
|
18
|
+
@count = data['count']
|
19
|
+
@fields = data['fields']
|
20
|
+
@entries = data['values']
|
21
|
+
# @values = data['values']
|
22
|
+
end
|
23
|
+
|
24
|
+
# def server_flags
|
25
|
+
# { no_good_index_used: false, no_index_used: true, query_was_slow: false }
|
26
|
+
# end
|
27
|
+
|
28
|
+
# def to_a
|
29
|
+
# @to_a ||= values.each_with_object([]) do |value, list|
|
30
|
+
# list << fields.zip(value).to_h
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module Mysql2
|
5
|
+
module Recorder
|
6
|
+
def query(sql, options = {})
|
7
|
+
Mysql2.record(self, sql: sql) do
|
8
|
+
super
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module_function
|
14
|
+
|
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
|
21
|
+
|
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)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def setup
|
48
|
+
::Mysql2::Client.class_eval do
|
49
|
+
prepend Recorder
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module PG
|
5
|
+
class RecordedResult
|
6
|
+
attr_reader :count, :fields, :values, :type_map
|
7
|
+
|
8
|
+
alias :cmd_tuples :count
|
9
|
+
alias :columns :fields
|
10
|
+
alias :ntuples :count
|
11
|
+
alias :rows :values
|
12
|
+
|
13
|
+
def initialize(data)
|
14
|
+
@count = data['count']
|
15
|
+
@fields = data['fields']
|
16
|
+
@values = data['values']
|
17
|
+
end
|
18
|
+
|
19
|
+
def clear; end
|
20
|
+
|
21
|
+
def first
|
22
|
+
to_a.first
|
23
|
+
end
|
24
|
+
|
25
|
+
def fmod(_index)
|
26
|
+
-1
|
27
|
+
end
|
28
|
+
|
29
|
+
def ftype(_index)
|
30
|
+
20
|
31
|
+
end
|
32
|
+
|
33
|
+
def map_types!(type_map)
|
34
|
+
@type_map = type_map
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
def nfields
|
39
|
+
fields.size
|
40
|
+
end
|
41
|
+
|
42
|
+
def to_a
|
43
|
+
@to_a ||= values.each_with_object([]) do |value, list|
|
44
|
+
list << fields.zip(value).to_h
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module PG
|
5
|
+
module Recorder
|
6
|
+
def async_exec(sql)
|
7
|
+
PG.record(sql: sql, source: :async_exec) do
|
8
|
+
super
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def sync_exec(sql)
|
13
|
+
PG.record(sql: sql, source: :sync_exec) do
|
14
|
+
super
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# def exec(*args)
|
19
|
+
# puts "-E- #{args[0]}"
|
20
|
+
# super
|
21
|
+
# end
|
22
|
+
|
23
|
+
# def query(*args)
|
24
|
+
# puts "-Q- #{args[0]}"
|
25
|
+
# super
|
26
|
+
# end
|
27
|
+
|
28
|
+
def exec_params(*args)
|
29
|
+
PG.record(sql: args[0], binds: args[1], source: :exec_params) do
|
30
|
+
super
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
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
|
43
|
+
|
44
|
+
def exec_prepared(*args)
|
45
|
+
PG.record(sql: args[0], binds: args[1], source: :exec_prepared) do
|
46
|
+
super
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
module_function
|
52
|
+
|
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
|
67
|
+
|
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)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def setup
|
78
|
+
::PG::Connection.class_eval do
|
79
|
+
prepend Recorder
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module DatabaseRecorder
|
6
|
+
class Recording
|
7
|
+
attr_accessor :cache, :entities
|
8
|
+
attr_reader :from_cache, :options, :queries, :started
|
9
|
+
|
10
|
+
def initialize(options: {})
|
11
|
+
(@@instances ||= {})[Process.pid] = self
|
12
|
+
@cache = nil
|
13
|
+
@entities = []
|
14
|
+
@options = options
|
15
|
+
@queries = []
|
16
|
+
@search_index = 0
|
17
|
+
end
|
18
|
+
|
19
|
+
def cached_query_for(sql)
|
20
|
+
current = @search_index
|
21
|
+
match = cache[@search_index..].find do |item|
|
22
|
+
current += 1
|
23
|
+
item['sql'] == sql # TODo: try matching by normalized query (no values)
|
24
|
+
end
|
25
|
+
return unless match
|
26
|
+
|
27
|
+
@search_index = current
|
28
|
+
match
|
29
|
+
|
30
|
+
# cache.shift # TMP
|
31
|
+
end
|
32
|
+
|
33
|
+
def new_entity(model:, id:)
|
34
|
+
@entities.push('model' => model, 'id' => id)
|
35
|
+
end
|
36
|
+
|
37
|
+
def pull_entity
|
38
|
+
@entities.shift
|
39
|
+
end
|
40
|
+
|
41
|
+
def push(sql:, binds: nil, result: nil, name: nil)
|
42
|
+
query = { 'name' => name, 'sql' => sql, 'binds' => binds, 'result' => result }.compact
|
43
|
+
@queries.push(query)
|
44
|
+
end
|
45
|
+
|
46
|
+
def start
|
47
|
+
@started = true
|
48
|
+
storage = Config.storage&.new(self, name: options[:name])
|
49
|
+
@from_cache = storage&.load
|
50
|
+
yield
|
51
|
+
storage&.save unless from_cache
|
52
|
+
@started = false
|
53
|
+
result = { current_queries: queries.map { _1['sql'] } }
|
54
|
+
result[:stored_queries] = cache.map { _1['sql'] } if from_cache
|
55
|
+
result
|
56
|
+
end
|
57
|
+
|
58
|
+
class << self
|
59
|
+
extend Forwardable
|
60
|
+
|
61
|
+
def_delegators :current_instance, :cache, :cached_query_for, :from_cache, :new_entity, :pull_entity, :push
|
62
|
+
|
63
|
+
def current_instance
|
64
|
+
(@@instances ||= {})[Process.pid]
|
65
|
+
end
|
66
|
+
|
67
|
+
def started?
|
68
|
+
current_instance&.started
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module RSpec
|
5
|
+
module_function
|
6
|
+
|
7
|
+
def setup
|
8
|
+
::RSpec.configure do |config|
|
9
|
+
config.before(:suite) do
|
10
|
+
Core.setup
|
11
|
+
end
|
12
|
+
|
13
|
+
config.around(:each, :dbr) do |example|
|
14
|
+
ref = (example.metadata[:scoped_id] || '').split(':')[-1]
|
15
|
+
options = {}
|
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
|
19
|
+
Recording.new(options: options).tap do |recording|
|
20
|
+
result = recording.start { example.run }
|
21
|
+
if options[:verify_queries] && result[:stored_queries]
|
22
|
+
expect(result[:stored_queries]).to match_array(result[:current_queries])
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module Storage
|
5
|
+
class File
|
6
|
+
def initialize(recording, name:)
|
7
|
+
@recording = recording
|
8
|
+
@name = name
|
9
|
+
end
|
10
|
+
|
11
|
+
def load
|
12
|
+
stored_data = ::File.exist?(record_file) ? ::File.read(record_file) : false
|
13
|
+
if stored_data
|
14
|
+
data = YAML.load(stored_data)
|
15
|
+
@recording.cache = data['queries']
|
16
|
+
@recording.entities = data['entities']
|
17
|
+
true
|
18
|
+
else
|
19
|
+
false
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
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)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def normalize_name(string)
|
33
|
+
string.gsub(%r{[:/]}, '-').gsub(/[^\w-]/, '_')
|
34
|
+
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
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DatabaseRecorder
|
4
|
+
module Storage
|
5
|
+
class Redis
|
6
|
+
def initialize(recording, name:)
|
7
|
+
@recording = recording
|
8
|
+
@name = name
|
9
|
+
end
|
10
|
+
|
11
|
+
def load
|
12
|
+
stored_data = self.class.connection.get(@name)
|
13
|
+
if stored_data
|
14
|
+
data = JSON.parse(stored_data)
|
15
|
+
@recording.cache = data['queries']
|
16
|
+
@recording.entities = data['entities']
|
17
|
+
true
|
18
|
+
else
|
19
|
+
false
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def save
|
24
|
+
data = { 'queries' => @recording.queries }
|
25
|
+
data['entities'] = @recording.entities if @recording.entities.any?
|
26
|
+
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
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'database_recorder/core'
|
4
|
+
|
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'
|
10
|
+
end
|
11
|
+
|
12
|
+
if defined? ::Mysql2
|
13
|
+
require_relative 'database_recorder/mysql2/recorded_result'
|
14
|
+
require_relative 'database_recorder/mysql2/recorder'
|
15
|
+
end
|
16
|
+
|
17
|
+
if defined? ::PG
|
18
|
+
require_relative 'database_recorder/pg/recorded_result'
|
19
|
+
require_relative 'database_recorder/pg/recorder'
|
20
|
+
end
|
21
|
+
|
22
|
+
require_relative 'database_recorder/storage/file'
|
23
|
+
require_relative 'database_recorder/storage/redis'
|
24
|
+
|
25
|
+
require_relative 'database_recorder/rspec'
|
26
|
+
require_relative 'database_recorder/recording'
|
27
|
+
require_relative 'database_recorder/config'
|
metadata
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: database_recorder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mattia Roccoberton
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-04-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: coderay
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.1'
|
27
|
+
description: Record application queries, verify them against stored queries, and replay
|
28
|
+
them.
|
29
|
+
email:
|
30
|
+
- mat@blocknot.es
|
31
|
+
executables: []
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- MIT-LICENSE
|
36
|
+
- README.md
|
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
|
42
|
+
- lib/database_recorder/config.rb
|
43
|
+
- lib/database_recorder/core.rb
|
44
|
+
- lib/database_recorder/mysql2/recorded_result.rb
|
45
|
+
- lib/database_recorder/mysql2/recorder.rb
|
46
|
+
- lib/database_recorder/pg/recorded_result.rb
|
47
|
+
- lib/database_recorder/pg/recorder.rb
|
48
|
+
- lib/database_recorder/recording.rb
|
49
|
+
- lib/database_recorder/rspec.rb
|
50
|
+
- lib/database_recorder/storage/file.rb
|
51
|
+
- lib/database_recorder/storage/redis.rb
|
52
|
+
- lib/database_recorder/version.rb
|
53
|
+
homepage: https://github.com/blocknotes/database_recorder
|
54
|
+
licenses:
|
55
|
+
- MIT
|
56
|
+
metadata:
|
57
|
+
homepage_uri: https://github.com/blocknotes/database_recorder
|
58
|
+
source_code_uri: https://github.com/blocknotes/database_recorder
|
59
|
+
rubygems_mfa_required: 'true'
|
60
|
+
post_install_message:
|
61
|
+
rdoc_options: []
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 2.6.0
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
requirements: []
|
75
|
+
rubygems_version: 3.1.6
|
76
|
+
signing_key:
|
77
|
+
specification_version: 4
|
78
|
+
summary: A SQL database recorder
|
79
|
+
test_files: []
|