super8 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +96 -0
- data/lib/super8/cassette.rb +119 -0
- data/lib/super8/config.rb +12 -0
- data/lib/super8/diff_helper.rb +39 -0
- data/lib/super8/errors.rb +10 -0
- data/lib/super8/playback_database_wrapper.rb +57 -0
- data/lib/super8/playback_statement_wrapper.rb +80 -0
- data/lib/super8/recording_database_wrapper.rb +37 -0
- data/lib/super8/recording_statement_wrapper.rb +64 -0
- data/lib/super8/version.rb +5 -0
- data/lib/super8.rb +100 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/super8/cassette_spec.rb +76 -0
- data/spec/super8/config_spec.rb +27 -0
- data/spec/super8/odbc_interception_spec.rb +534 -0
- data/spec/super8/playback_spec.rb +127 -0
- data/spec/super8_spec.rb +35 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 300247513d3de06e1d28c93a61d7d2e4f499004aafac7bbcff91ad75faf5f7d6
|
|
4
|
+
data.tar.gz: 6ee5b1c1729277708ee73248413ec604cc7224ec3d342213fc75fd3ec92748a8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 58f16618e726f6d1d665839a85f63a6864a39899b81f2d31139d69ff47545a3a3a5261dc1f73d955fa080b350454d6c7bf21046ec9713fd1a262dc589dff53b1
|
|
7
|
+
data.tar.gz: 407ad63d77e0ad4e60f87209d4e0b36c51003ecf951d874e66ecbadadbff77b8f6714579ba1e81964df53a770a560438650ec21d2780bfb24380570fc511c572
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 John Cipriano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Super 8 🎞️
|
|
2
|
+
|
|
3
|
+
Record and replay ruby-odbc sessions for easier, more accurate testing.
|
|
4
|
+
|
|
5
|
+
Super 8 is a "vcr"-like library for recording and playing back ODBC interactions made with ruby-odbc, for use with automated testing. More accurate than manually mocking and less work than adding an ODBC-enabled server to your test stack.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add this line to your application's Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "super8", github: "johncip/super8", branch: "main"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then execute:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Ruby 3.1 or higher
|
|
26
|
+
- ruby-odbc gem
|
|
27
|
+
|
|
28
|
+
I haven't tested on lower Ruby versions, but it may work.
|
|
29
|
+
* It uses YAML from stdlib, which I think sets hard minimum of 1.8.
|
|
30
|
+
* It uses CSV from stdlib, so for Ruby 3.4+ you will need to add `gem "csv"`.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Configure Super8 in your test setup (e.g., `spec/spec_helper.rb`):
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "super8"
|
|
39
|
+
|
|
40
|
+
Super8.configure do |config|
|
|
41
|
+
# defaults to "spec/super8_cassettes"
|
|
42
|
+
config.cassette_directory = "spec/super8_cassettes"
|
|
43
|
+
|
|
44
|
+
# for writing cassettes, defaults to "utf8"
|
|
45
|
+
config.encoding = "ibm437"
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Use cassettes in your tests with `use_cassette`:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
RSpec.describe "ODBC stuff" do
|
|
55
|
+
it "queries the database" do
|
|
56
|
+
Super8.use_cassette("users/all", mode: :playback) do
|
|
57
|
+
db = ODBC.connect("dsn")
|
|
58
|
+
result = db.run("SELECT * FROM users")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Modes:
|
|
65
|
+
- `:record` - Records ODBC interactions to a cassette file
|
|
66
|
+
- `:playback` - Replays recorded interactions without connecting to the database
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
## Supported Methods
|
|
70
|
+
|
|
71
|
+
Currently supported ODBC methods:
|
|
72
|
+
- `ODBC` : `.connect`
|
|
73
|
+
- `ODBC::Database`: `#run`
|
|
74
|
+
- `ODBC::Statement`: `#columns`, `#fetch_all`, `#drop`, `#cancel`, `#close`
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## Motivation
|
|
78
|
+
|
|
79
|
+
I wrote and maintain an application that communicates with the [Retalix](https://en.wikipedia.org/wiki/Retalix) ERP. I use [ruby-odbc](https://github.com/larskanis/ruby-odbc) to connect to the instance and incrementally sync a number of tables.
|
|
80
|
+
|
|
81
|
+
In testing, I would stub the ODBC parts out, which was low-level and noisy. It also meant I had to check the queries for regressions manually.
|
|
82
|
+
|
|
83
|
+
**vcr** is a library for "recording" HTTP API requests, so that they can be accurately mocked in ruby tests. It stores the request as well as the response, so that if the request made changes, it can be considered a test failure. It's also very easy to record "cassettes." I've wanted something like that for for ODBC.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
This project was developed with AI assistance as an experiment in improving and documenting my workflow. The process and decisions are documented in the [design directory](design/).
|
|
89
|
+
|
|
90
|
+
* [Design docs](design/)
|
|
91
|
+
* [Copilot chat logs](design/copilot_chat_logs/)
|
|
92
|
+
* [Architecture decision records](design/decision_records/)
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "csv"
|
|
3
|
+
|
|
4
|
+
module Super8
|
|
5
|
+
# Represents a recorded cassette stored on disk.
|
|
6
|
+
# Each cassette is a directory containing commands and row data.
|
|
7
|
+
class Cassette
|
|
8
|
+
SCHEMA_VERSION = 3
|
|
9
|
+
|
|
10
|
+
attr_reader :name
|
|
11
|
+
|
|
12
|
+
def initialize(name)
|
|
13
|
+
@name = name
|
|
14
|
+
@current_connection_id = nil
|
|
15
|
+
@connections = {}
|
|
16
|
+
@commands = []
|
|
17
|
+
@command_index = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Full path to the cassette directory.
|
|
21
|
+
def path
|
|
22
|
+
File.join(Super8.config.cassette_directory, name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exists?
|
|
26
|
+
Dir.exist?(path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate next connection ID (a, b, c, ..., z, aa, ab, ...)
|
|
30
|
+
def next_connection_id
|
|
31
|
+
return @current_connection_id = "a" if @current_connection_id.nil?
|
|
32
|
+
@current_connection_id = @current_connection_id.succ
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Writes all cassette data to disk in one pass.
|
|
36
|
+
# Creates directory, row files, commands.yml, and metadata.yml.
|
|
37
|
+
def save
|
|
38
|
+
FileUtils.mkdir_p(path)
|
|
39
|
+
process_row_data!
|
|
40
|
+
save_metadata_file
|
|
41
|
+
save_commands_file
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Loads cassette data for playback.
|
|
45
|
+
# Raises CassetteNotFoundError if missing.
|
|
46
|
+
def load
|
|
47
|
+
raise CassetteNotFoundError, "Cassette not found: #{path}" unless exists?
|
|
48
|
+
@commands = YAML.load_file(
|
|
49
|
+
File.join(path, "commands.yml"),
|
|
50
|
+
permitted_classes: [ODBC::Column]
|
|
51
|
+
)
|
|
52
|
+
@command_index = 0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get next command during playback.
|
|
56
|
+
def next_command
|
|
57
|
+
return nil if @command_index >= @commands.length
|
|
58
|
+
command = @commands[@command_index]
|
|
59
|
+
@command_index += 1
|
|
60
|
+
command
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Load row data from CSV files.
|
|
64
|
+
def load_rows_from_file(filename)
|
|
65
|
+
CSV.read(File.join(path, filename))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generic recording — stores method name and context in memory.
|
|
69
|
+
def record(method, **context)
|
|
70
|
+
@commands << {"method" => method.to_s, **stringify_keys(context)}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def stringify_keys(hash)
|
|
76
|
+
hash.transform_keys(&:to_s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Extracts row data from commands, writes to CSV files, replaces with file references
|
|
80
|
+
def process_row_data!
|
|
81
|
+
@commands.each do |command|
|
|
82
|
+
next unless command.key?("rows_data")
|
|
83
|
+
|
|
84
|
+
conn_id = command["connection_id"]
|
|
85
|
+
stmt_id = command["statement_id"]
|
|
86
|
+
method = command["method"]
|
|
87
|
+
file_name = "#{conn_id}_#{stmt_id}_#{method}.csv"
|
|
88
|
+
write_rows_csv_file(file_name, command.delete("rows_data"))
|
|
89
|
+
command["rows_file"] = file_name
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def write_rows_csv_file(file_name, data)
|
|
94
|
+
file_path = File.join(path, file_name)
|
|
95
|
+
CSV.open(file_path, "w") do |csv|
|
|
96
|
+
data.each do |row|
|
|
97
|
+
csv << row.map do |field|
|
|
98
|
+
field&.encode(Super8.config.encoding, invalid: :replace, undef: :replace, replace: "?")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def save_metadata_file
|
|
105
|
+
metadata_file = File.join(path, "metadata.yml")
|
|
106
|
+
metadata = {
|
|
107
|
+
"schema_version" => SCHEMA_VERSION,
|
|
108
|
+
"recorded_with" => Super8::VERSION,
|
|
109
|
+
"recorded_on" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
110
|
+
}
|
|
111
|
+
File.write(metadata_file, metadata.to_yaml)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def save_commands_file
|
|
115
|
+
commands_file = File.join(path, "commands.yml")
|
|
116
|
+
File.write(commands_file, @commands.to_yaml)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Super8
|
|
2
|
+
# Configuration class for Super8 settings.
|
|
3
|
+
class Config
|
|
4
|
+
attr_accessor :cassette_directory, :record_mode, :encoding
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@cassette_directory = "spec/super8_cassettes"
|
|
8
|
+
@record_mode = :once
|
|
9
|
+
@encoding = "utf-8"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "diff/lcs"
|
|
4
|
+
|
|
5
|
+
module Super8
|
|
6
|
+
# Helper module for generating diffs in error messages.
|
|
7
|
+
module DiffHelper
|
|
8
|
+
# Generate a diff between expected and actual values.
|
|
9
|
+
def self.generate_diff(expected, actual, key: "value")
|
|
10
|
+
# Handle nil cases
|
|
11
|
+
return "#{key}: expected #{expected.inspect}, got #{actual.inspect}" if expected.nil? || actual.nil?
|
|
12
|
+
|
|
13
|
+
# For non-string values, fall back to inspect
|
|
14
|
+
return "#{key}: expected #{expected.inspect}, got #{actual.inspect}" unless expected.is_a?(String) && actual.is_a?(String)
|
|
15
|
+
|
|
16
|
+
expected_lines = expected.lines.map(&:chomp)
|
|
17
|
+
actual_lines = actual.lines.map(&:chomp)
|
|
18
|
+
|
|
19
|
+
diff = Diff::LCS.sdiff(expected_lines, actual_lines)
|
|
20
|
+
|
|
21
|
+
result = ["#{key} mismatch:"]
|
|
22
|
+
diff.each_with_index do |change, i|
|
|
23
|
+
case change.action
|
|
24
|
+
when "-"
|
|
25
|
+
result << " - #{change.old_element}"
|
|
26
|
+
when "+"
|
|
27
|
+
result << " + #{change.new_element}"
|
|
28
|
+
when "!"
|
|
29
|
+
result << " - #{change.old_element}"
|
|
30
|
+
result << " + #{change.new_element}"
|
|
31
|
+
when "="
|
|
32
|
+
result << " #{change.old_element}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result.join("\n")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Super8
|
|
2
|
+
# Base error class for Super 8 exceptions.
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised when a requested cassette is not found on disk.
|
|
6
|
+
class CassetteNotFoundError < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when playback interactions don't match recorded commands.
|
|
9
|
+
class CommandMismatchError < Error; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative "diff_helper"
|
|
2
|
+
|
|
3
|
+
module Super8
|
|
4
|
+
# Wraps ODBC calls during playback mode.
|
|
5
|
+
# Validates that interactions match recorded commands and returns recorded data.
|
|
6
|
+
class PlaybackDatabaseWrapper
|
|
7
|
+
def initialize(cassette, connection_id)
|
|
8
|
+
@cassette = cassette
|
|
9
|
+
@connection_id = connection_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(sql, *params)
|
|
13
|
+
expected_command = @cassette.next_command
|
|
14
|
+
validate_command_match(expected_command, :run,
|
|
15
|
+
connection_id: @connection_id, sql: sql, params: params)
|
|
16
|
+
|
|
17
|
+
statement_id = expected_command["statement_id"]
|
|
18
|
+
PlaybackStatementWrapper.new(statement_id, @cassette, @connection_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Delegate other methods - they shouldn't be called in typical playback scenarios
|
|
22
|
+
def method_missing(method_name, ...)
|
|
23
|
+
raise NoMethodError, "Method #{method_name} not implemented for playback"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def respond_to_missing?(_method_name, _include_private=false)
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def validate_command_match(expected_command, method, **actual_context)
|
|
33
|
+
raise CommandMismatchError, "No more recorded interactions" if expected_command.nil?
|
|
34
|
+
|
|
35
|
+
# Check method
|
|
36
|
+
if expected_command["method"] != method.to_s
|
|
37
|
+
raise CommandMismatchError,
|
|
38
|
+
"method: expected '#{expected_command['method']}', got '#{method}'"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check each key/value pair
|
|
42
|
+
actual_context.each do |key, value|
|
|
43
|
+
expected_value = expected_command[key.to_s]
|
|
44
|
+
next if expected_value == value
|
|
45
|
+
|
|
46
|
+
# Use diff for SQL queries to show detailed differences
|
|
47
|
+
error_message = if key == :sql && expected_value.is_a?(String) && value.is_a?(String)
|
|
48
|
+
DiffHelper.generate_diff(expected_value, value, key: "sql")
|
|
49
|
+
else
|
|
50
|
+
"#{key}: expected #{expected_value.inspect}, got #{value.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
raise CommandMismatchError, error_message
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Super8
|
|
2
|
+
# Wraps ODBC Statement calls during playback mode.
|
|
3
|
+
# Validates that statement interactions match recorded commands and returns recorded data.
|
|
4
|
+
class PlaybackStatementWrapper
|
|
5
|
+
def initialize(statement_id, cassette, connection_id)
|
|
6
|
+
@statement_id = statement_id
|
|
7
|
+
@cassette = cassette
|
|
8
|
+
@connection_id = connection_id
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fetch_all
|
|
12
|
+
expected_command = @cassette.next_command
|
|
13
|
+
validate_statement_command(expected_command, :fetch_all)
|
|
14
|
+
|
|
15
|
+
rows_file_path = expected_command["rows_file"]
|
|
16
|
+
rows_file_path ? @cassette.load_rows_from_file(rows_file_path) : []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def columns
|
|
20
|
+
expected_command = @cassette.next_command
|
|
21
|
+
validate_statement_command(expected_command, :columns)
|
|
22
|
+
expected_command["result"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def drop
|
|
26
|
+
expected_command = @cassette.next_command
|
|
27
|
+
validate_statement_command(expected_command, :drop)
|
|
28
|
+
# drop doesn't return anything
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def close
|
|
32
|
+
expected_command = @cassette.next_command
|
|
33
|
+
validate_statement_command(expected_command, :close)
|
|
34
|
+
# close doesn't return anything
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cancel
|
|
38
|
+
expected_command = @cassette.next_command
|
|
39
|
+
validate_statement_command(expected_command, :cancel)
|
|
40
|
+
# cancel doesn't return anything
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Delegate other methods - raise error for unexpected calls
|
|
44
|
+
def method_missing(method_name, ...)
|
|
45
|
+
raise NoMethodError, "Method #{method_name} not implemented for playback"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def respond_to_missing?(_method_name, _include_private=false)
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_statement_command(expected_command, method)
|
|
55
|
+
validate_command_match(expected_command, method,
|
|
56
|
+
connection_id: @connection_id, statement_id: @statement_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# :reek:FeatureEnvy
|
|
60
|
+
# :reek:NilCheck
|
|
61
|
+
def validate_command_match(expected_command, method, **actual_context)
|
|
62
|
+
raise CommandMismatchError, "No more recorded interactions" if expected_command.nil?
|
|
63
|
+
|
|
64
|
+
# Check method
|
|
65
|
+
if expected_command["method"] != method.to_s
|
|
66
|
+
raise CommandMismatchError,
|
|
67
|
+
"method: expected '#{expected_command['method']}', got '#{method}'"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check each key/value pair
|
|
71
|
+
actual_context.each do |key, value|
|
|
72
|
+
expected_value = expected_command[key.to_s]
|
|
73
|
+
if expected_value != value
|
|
74
|
+
raise CommandMismatchError,
|
|
75
|
+
"#{key}: expected #{expected_value.inspect}, got #{value.inspect}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Super8
|
|
2
|
+
# Wraps an ODBC::Database to intercept method calls for recording.
|
|
3
|
+
# Delegates to the real database and logs interactions to the cassette.
|
|
4
|
+
class RecordingDatabaseWrapper
|
|
5
|
+
def initialize(real_db, cassette, connection_id)
|
|
6
|
+
@real_db = real_db
|
|
7
|
+
@cassette = cassette
|
|
8
|
+
@connection_id = connection_id
|
|
9
|
+
@statement_counter = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Intercept run method to record SQL queries
|
|
13
|
+
def run(sql, *params)
|
|
14
|
+
statement_id = next_statement_id
|
|
15
|
+
@cassette.record(:run, connection_id: @connection_id,
|
|
16
|
+
sql: sql, params: params, statement_id: statement_id)
|
|
17
|
+
real_statement = @real_db.run(sql, *params)
|
|
18
|
+
RecordingStatementWrapper.new(real_statement, statement_id, @cassette, @connection_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Delegate all other method calls to the real database for now.
|
|
22
|
+
def method_missing(method_name, ...)
|
|
23
|
+
@real_db.send(method_name, ...)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def respond_to_missing?(method_name, include_private=false)
|
|
27
|
+
@real_db.respond_to?(method_name, include_private) || super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def next_statement_id
|
|
33
|
+
@statement_counter += 1
|
|
34
|
+
@statement_counter
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Super8
|
|
2
|
+
# Wraps an ODBC::Statement to intercept method calls for recording.
|
|
3
|
+
# During record mode, delegates to the real statement and logs interactions.
|
|
4
|
+
class RecordingStatementWrapper
|
|
5
|
+
def initialize(real_statement, statement_id, cassette, connection_id)
|
|
6
|
+
@real_statement = real_statement
|
|
7
|
+
@statement_id = statement_id
|
|
8
|
+
@cassette = cassette
|
|
9
|
+
@connection_id = connection_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Intercept columns method to record column metadata
|
|
13
|
+
# :reek:BooleanParameter
|
|
14
|
+
def columns(as_ary=false) # rubocop:disable Style/OptionalBooleanParameter
|
|
15
|
+
result = @real_statement.columns(as_ary)
|
|
16
|
+
@cassette.record(:columns,
|
|
17
|
+
connection_id: @connection_id, statement_id: @statement_id,
|
|
18
|
+
as_ary: as_ary, result: result)
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Intercept fetch_all method to record row data
|
|
23
|
+
def fetch_all
|
|
24
|
+
result = @real_statement.fetch_all
|
|
25
|
+
# Normalize nil to empty array to match CSV playback behavior
|
|
26
|
+
normalized_result = result || []
|
|
27
|
+
@cassette.record(:fetch_all,
|
|
28
|
+
connection_id: @connection_id, statement_id: @statement_id,
|
|
29
|
+
rows_data: normalized_result)
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Intercept drop method to record cleanup call
|
|
34
|
+
def drop
|
|
35
|
+
result = @real_statement.drop
|
|
36
|
+
@cassette.record(:drop, connection_id: @connection_id, statement_id: @statement_id)
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Intercept cancel method to record statement cancellation
|
|
41
|
+
def cancel
|
|
42
|
+
result = @real_statement.cancel
|
|
43
|
+
@cassette.record(:cancel, connection_id: @connection_id, statement_id: @statement_id)
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Intercept close method to record statement closure
|
|
48
|
+
def close
|
|
49
|
+
result = @real_statement.close
|
|
50
|
+
@cassette.record(:close, connection_id: @connection_id, statement_id: @statement_id)
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Delegate all other method calls to the real statement for now.
|
|
55
|
+
# Future phases will intercept specific methods (fetch, etc.)
|
|
56
|
+
def method_missing(method_name, ...)
|
|
57
|
+
@real_statement.send(method_name, ...)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def respond_to_missing?(method_name, include_private=false)
|
|
61
|
+
@real_statement.respond_to?(method_name, include_private) || super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/super8.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require_relative "super8/version"
|
|
2
|
+
require_relative "super8/config"
|
|
3
|
+
require_relative "super8/errors"
|
|
4
|
+
require_relative "super8/cassette"
|
|
5
|
+
require_relative "super8/recording_database_wrapper"
|
|
6
|
+
require_relative "super8/recording_statement_wrapper"
|
|
7
|
+
require_relative "super8/playback_database_wrapper"
|
|
8
|
+
require_relative "super8/playback_statement_wrapper"
|
|
9
|
+
require "odbc"
|
|
10
|
+
|
|
11
|
+
# Top-level module for Super 8.
|
|
12
|
+
module Super8
|
|
13
|
+
class << self
|
|
14
|
+
def config
|
|
15
|
+
@config ||= Config.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configure
|
|
19
|
+
yield config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Wraps a block with cassette recording/playback.
|
|
23
|
+
# In record mode, ODBC calls are captured and saved.
|
|
24
|
+
# In playback mode, recorded responses are returned.
|
|
25
|
+
def use_cassette(name, mode: :record)
|
|
26
|
+
cassette = Cassette.new(name)
|
|
27
|
+
|
|
28
|
+
case mode
|
|
29
|
+
when :record
|
|
30
|
+
setup_recording_mode(cassette)
|
|
31
|
+
when :playback
|
|
32
|
+
setup_playback_mode(cassette)
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "Unknown mode: #{mode}. Use :record or :playback"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
cleanup_odbc_intercept
|
|
40
|
+
cassette.save if mode == :record
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def setup_recording_mode(cassette)
|
|
46
|
+
original_connect = ODBC.method(:connect)
|
|
47
|
+
@original_connect = original_connect
|
|
48
|
+
|
|
49
|
+
ODBC.define_singleton_method(:connect) do |dsn, &block|
|
|
50
|
+
# Generate connection ID for this connection
|
|
51
|
+
connection_id = cassette.next_connection_id
|
|
52
|
+
|
|
53
|
+
# Record connect command with connection metadata
|
|
54
|
+
cassette.record(:connect, connection_id: connection_id, dsn: dsn)
|
|
55
|
+
|
|
56
|
+
original_connect.call(dsn) do |db|
|
|
57
|
+
wrapped_db = RecordingDatabaseWrapper.new(db, cassette, connection_id)
|
|
58
|
+
block&.call(wrapped_db)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def setup_playback_mode(cassette)
|
|
64
|
+
cassette.load # Load cassette data
|
|
65
|
+
|
|
66
|
+
original_connect = ODBC.method(:connect)
|
|
67
|
+
@original_connect = original_connect
|
|
68
|
+
|
|
69
|
+
ODBC.define_singleton_method(:connect) do |dsn, &block|
|
|
70
|
+
# Get next connect command from cassette
|
|
71
|
+
expected_command = cassette.next_command
|
|
72
|
+
raise CommandMismatchError, "No more recorded interactions" if expected_command.nil?
|
|
73
|
+
|
|
74
|
+
# Validate it's a connect command
|
|
75
|
+
unless expected_command["method"] == "connect"
|
|
76
|
+
raise CommandMismatchError,
|
|
77
|
+
"method: expected 'connect', got '#{expected_command['method']}'"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validate DSN matches
|
|
81
|
+
recorded_dsn = expected_command["dsn"]
|
|
82
|
+
unless dsn == recorded_dsn
|
|
83
|
+
raise CommandMismatchError, "dsn: expected '#{recorded_dsn}', got '#{dsn}'"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Extract connection ID
|
|
87
|
+
connection_id = expected_command["connection_id"]
|
|
88
|
+
|
|
89
|
+
# Return fake database object
|
|
90
|
+
playback_db = PlaybackDatabaseWrapper.new(cassette, connection_id)
|
|
91
|
+
block&.call(playback_db)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cleanup_odbc_intercept
|
|
96
|
+
ODBC.define_singleton_method(:connect, @original_connect) if @original_connect
|
|
97
|
+
@original_connect = nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "fakefs/spec_helpers"
|
|
5
|
+
|
|
6
|
+
require "super8"
|
|
7
|
+
|
|
8
|
+
RSpec.configure do |config|
|
|
9
|
+
config.expect_with :rspec do |expectations|
|
|
10
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
config.mock_with :rspec do |mocks|
|
|
14
|
+
mocks.verify_partial_doubles = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
18
|
+
config.filter_run_when_matching :focus
|
|
19
|
+
config.disable_monkey_patching!
|
|
20
|
+
config.order = :random
|
|
21
|
+
Kernel.srand config.seed
|
|
22
|
+
end
|