Skip to content

Commit 39996ce

Browse files
committed
Add files and license
1 parent c65d019 commit 39996ce

File tree

15 files changed

+630
-0
lines changed

15 files changed

+630
-0
lines changed

.github/workflows/ci.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: CI
2+
on: [push, pull_request]
3+
jobs:
4+
test:
5+
strategy:
6+
fail-fast: false
7+
matrix:
8+
ruby: ['3.0']
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v2
12+
- uses: ruby/setup-ruby@v1
13+
with:
14+
ruby-version: ${{ matrix.ruby }}
15+
bundler-cache: true
16+
# - run: bundle exec rake

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/.bundle/
2+
/.yardoc
3+
/_yardoc/
4+
/coverage/
5+
/doc/
6+
/pkg/
7+
/spec/reports/
8+
/tmp/
9+
10+
# rspec failure tracking
11+
.rspec_status

.rspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--format documentation
2+
--color
3+
--require spec_helper

Gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2021 Qualified, Inc
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
13+
all 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
21+
THE SOFTWARE.

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SqlSpecHelper
2+
3+
`spec/spec_helper.rb`
4+
5+
```ruby
6+
require 'sql_spec_helper'
7+
8+
# Add `$sql`, `DB`, `run_sql`, `compare_with`, and `Display` to main for backwards compatibility.
9+
$sql_spec_helper = SqlSpecHelper.new('/workspace/solution.txt')
10+
$sql = $sql_spec_helper.sql
11+
DB = $sql_spec_helper.db
12+
def run_sql(...)
13+
$sql_spec_helper.run_sql(...)
14+
end
15+
def compare_with(...)
16+
$sql_spec_helper.compare_with(...)
17+
end
18+
Display = SqlSpecHelper::Display
19+
```

Rakefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/gem_tasks"
4+
require "rspec/core/rake_task"
5+
6+
RSpec::Core::RakeTask.new(:spec)
7+
8+
task default: :spec

bin/console

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "bundler/setup"
5+
require "sql_spec_helper"
6+
7+
# You can add fixtures and/or initialization code here to make experimenting
8+
# with your gem easier. You can also use a different console, if you like.
9+
10+
# (If you use this, don't forget to add pry to your Gemfile!)
11+
# require "pry"
12+
# Pry.start
13+
14+
require "irb"
15+
IRB.start(__FILE__)

bin/setup

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
IFS=$'\n\t'
4+
set -vx
5+
6+
bundle install
7+
8+
# Do any other automated setup that you need to do here

lib/sql_spec_helper.rb

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# frozen_string_literal: true
2+
3+
require 'sequel'
4+
5+
require_relative 'sql_spec_helper/display'
6+
require_relative 'sql_spec_helper/daff_wrapper'
7+
require_relative 'sql_spec_helper/compare'
8+
9+
class SqlSpecHelper
10+
attr_reader :sql, :db
11+
12+
def initialize(solution_path)
13+
@sql = File.read(solution_path)
14+
@commands = sql_commands(@sql)
15+
@db = connect
16+
end
17+
18+
# `show_daff_table: true` display diff table
19+
# `daff_csv_show_index: true` include index in test output
20+
def compare_with(expected, limit: 100, collapsed: false, show_daff_table: true, daff_csv_show_index: false, &block)
21+
sql_compare = SqlCompare.new(
22+
self,
23+
expected,
24+
cmds: @commands,
25+
limit: limit,
26+
collapsed: collapsed,
27+
show_daff_table: show_daff_table,
28+
daff_csv_show_index: daff_csv_show_index
29+
)
30+
sql_compare.instance_eval(&block) if block
31+
32+
sql_compare.spec
33+
sql_compare.actual
34+
end
35+
36+
# The main method used when running user's code.
37+
# Returns an Array of `Sequel::Adapter::Dataset` unless commands contained only one `SELECT`.
38+
# Returns `nil` if no `SELECT`.
39+
def run_sql(cmds: nil, limit: 100, print: true, label: 'SELECT Results', collapsed: false, &block)
40+
Display.status("Running sql commands...")
41+
cmds ||= @commands
42+
results = Array(cmds).each_with_object([]) do |cmd, results|
43+
dataset = run_cmd(cmd) || []
44+
result = dataset.to_a
45+
next if result.empty?
46+
47+
lbl = label
48+
lbl += " (Top #{limit} of #{result.size})" if result.size > limit
49+
lbl = "-" + lbl if collapsed
50+
51+
block.call(dataset, lbl) if block
52+
53+
Display.table(result.take(limit), label: lbl, allow_preview: true) if print
54+
results.push(dataset)
55+
end
56+
57+
if results.length > 1
58+
results
59+
else
60+
results.first
61+
end
62+
63+
rescue Sequel::DatabaseError => ex
64+
Display.print("ERROR", ex.message.strip)
65+
end
66+
67+
private
68+
69+
# Connect the database
70+
def connect
71+
Display.status "Connecting to database..."
72+
case ENV['DATABASE_TYPE']
73+
when 'sqlite'
74+
Sequel.sqlite
75+
when 'postgres'
76+
Sequel.connect(
77+
adapter: 'postgres',
78+
host: ENV['PGHOST'],
79+
user: ENV['PGUSER'],
80+
port: ENV['PGPORT'],
81+
database: ENV['DATABASE_NAME'] || ENV['PGDATABASE'],
82+
)
83+
when 'mssql'
84+
Sequel.connect(
85+
adapter: 'tinytds',
86+
host: ENV['MSSQL_HOST'],
87+
port: ENV['MSSQL_PORT'],
88+
user: ENV['MSSQL_USER'],
89+
password: ENV['MSSQL_PASS'],
90+
)
91+
else
92+
raise "Unknown database type #{ENV['DATABASE_TYPE']}"
93+
end
94+
end
95+
96+
def sql_commands(sql)
97+
split_sql_commands(clean_sql(sql))
98+
end
99+
100+
def run_cmd(cmd)
101+
select_cmd?(cmd) ? @db[cmd] : @db.run(cmd)
102+
end
103+
104+
def clean_sql(sql)
105+
sql.gsub(/(\/\*([\s\S]*?)\*\/|--.*)/, "")
106+
end
107+
108+
def select_cmd?(cmd)
109+
(cmd.strip =~ /^(SELECT|WITH)/i) == 0
110+
end
111+
112+
def split_sql_commands(sql)
113+
# first we want to seperate select statements into chunks
114+
chunks = sql.split(/;[ \n\r]*$/i).select {|s| !s.empty?}.chunk {|s| select_cmd?(s)}
115+
# select statements need to stay individual so that we can return individual datasets, but we can group other statements together
116+
chunks.each_with_object([]) do |(select, cmds), final|
117+
if select
118+
final.concat(cmds)
119+
else
120+
final.push(cmds.join(";\n"))
121+
end
122+
end
123+
end
124+
end

lib/sql_spec_helper/compare.rb

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
require 'rspec/expectations'
2+
3+
class SqlSpecHelper
4+
class SqlCompare
5+
attr_reader :actual, :expected
6+
7+
def initialize(helper, expected, cmds: nil, limit: 100, collapsed: false, show_daff_table: true, daff_csv_show_index: false)
8+
@results = helper.run_sql(cmds: cmds, label: 'Results: Actual', limit: limit, collapsed: collapsed)
9+
@actual = @results.is_a?(Array) ? @results.last.to_a : @results.to_a
10+
@limit = limit
11+
Display.status("Running expected query...")
12+
@expected = expected.to_a
13+
@daff_csv_show_index = daff_csv_show_index
14+
15+
Display.log('Query returned no rows', 'Results: Actual') if @actual.size == 0
16+
Display.table(@expected, label: 'Results: Expected', tab: true, allow_preview: true)
17+
if show_daff_table && @actual.size > 0 && @expected != @actual
18+
# set `index: true` to collect ordering data (row/column mapping)
19+
daff_data = DaffWrapper.new(@actual, @expected, index: true).serializable
20+
Display.daff_table(daff_data, label: 'Diff', tab: true, allow_preview: true)
21+
end
22+
23+
@column_blocks ||= {}
24+
@rows_blocks = []
25+
end
26+
27+
def column(name, &block)
28+
@column_blocks[name.to_sym] = block.to_proc
29+
self
30+
end
31+
32+
def rows(&block)
33+
@rows_blocks << block.to_proc
34+
self
35+
end
36+
37+
def spec(&block)
38+
return if @spec_called
39+
@spec_called = true
40+
41+
_self = self
42+
column_blocks = @column_blocks
43+
rows_blocks = @rows_blocks
44+
it_blocks = @it_blocks
45+
daff_csv_show_index = @daff_csv_show_index
46+
47+
RSpec.describe "Query" do
48+
let(:actual) { _self.actual }
49+
let(:expected) { _self.expected }
50+
51+
describe "columns" do
52+
expected.first.each do |key, value|
53+
describe "column \"#{key}\"" do
54+
it "should be included within results" do
55+
if (actual_row = actual&.first)
56+
expect(actual_row).to have_key(key), "missing column \"#{key}\""
57+
else
58+
RSpec::Expectations.fail_with("the query returned no row")
59+
end
60+
end
61+
62+
if value
63+
# TODO `value.class` is type in Ruby, and can be misleading
64+
it "should be a #{value.class.name} value" do
65+
if (actual_row = actual&.first)
66+
if (actual_value = actual_row[key])
67+
expect(actual_value).to be_a(value.class), "column \"#{key}\" should be #{value.class}, not #{actual_value.class}"
68+
else
69+
RSpec::Expectations.fail_with("missing column \"#{key}\"")
70+
end
71+
else
72+
RSpec::Expectations.fail_with("the query returned no row")
73+
end
74+
end
75+
end
76+
77+
self.instance_eval(&column_blocks[key]) if column_blocks[key]
78+
end
79+
end
80+
end
81+
82+
describe "rows" do
83+
matcher :eq_table do |expected|
84+
match {|actual| actual == expected}
85+
failure_message do |actual|
86+
("rows did not match expected\n" + DaffWrapper.new(actual, expected, index: daff_csv_show_index).as_csv).gsub(/\r?\n/, '<:LF:>')
87+
end
88+
end
89+
90+
it "should have #{expected.count} rows" do
91+
if (count = actual&.count)
92+
expect(count).to eq(expected.count), "expected #{expected.count} rows, got #{count} rows"
93+
else
94+
RSpec::Expectations.fail_with("the query returned no row")
95+
end
96+
end
97+
98+
rows_blocks.each do |block|
99+
self.instance_eval(&block)
100+
end
101+
102+
it "should match the expected" do
103+
if actual.empty?
104+
RSpec::Expectations.fail_with("the query returned no row")
105+
else
106+
expect(actual).to eq_table expected
107+
end
108+
end
109+
end
110+
111+
self.instance_eval(&block) if block
112+
end
113+
end
114+
end
115+
end

0 commit comments

Comments
 (0)