dbee-active_record 2.0.0 → 2.1.0.pre.alpha
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +24 -9
- data/.ruby-version +1 -1
- data/.travis.yml +3 -7
- data/CHANGELOG.md +30 -0
- data/dbee-active_record.gemspec +14 -6
- data/exe/.gitkeep +0 -0
- data/lib/dbee/providers/active_record_provider/expression_builder.rb +47 -19
- data/lib/dbee/providers/active_record_provider/expression_builder/{constraint_maker.rb → constraint.rb} +11 -11
- data/lib/dbee/providers/active_record_provider/expression_builder/{order_maker.rb → order.rb} +8 -8
- data/lib/dbee/providers/active_record_provider/expression_builder/select.rb +71 -0
- data/lib/dbee/providers/active_record_provider/expression_builder/where.rb +68 -0
- data/lib/dbee/providers/active_record_provider/version.rb +1 -1
- data/spec/db_helper.rb +130 -14
- data/spec/dbee/providers/active_record_provider_spec.rb +102 -1
- data/spec/fixtures/active_record_snapshots/one_table_query_with_filters.yaml +24 -16
- data/spec/fixtures/active_record_snapshots/two_table_query_with_aggregation.yaml +71 -0
- data/spec/fixtures/active_record_snapshots/two_table_query_with_pivoting.yaml +88 -0
- data/spec/fixtures/models.yaml +20 -0
- metadata +32 -23
- data/lib/dbee/providers/active_record_provider/expression_builder/select_maker.rb +0 -33
- data/lib/dbee/providers/active_record_provider/expression_builder/where_maker.rb +0 -56
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Dbee
|
11
|
+
module Providers
|
12
|
+
class ActiveRecordProvider
|
13
|
+
class ExpressionBuilder
|
14
|
+
# Derives Arel#where predicates.
|
15
|
+
class Where
|
16
|
+
include Singleton
|
17
|
+
|
18
|
+
def make(filter, arel_column)
|
19
|
+
values = normalize(filter.value)
|
20
|
+
|
21
|
+
if filter.is_a?(Query::Filters::Equals) && values.length > 1
|
22
|
+
arel_column.in(values)
|
23
|
+
elsif filter.is_a?(Query::Filters::NotEquals) && values.length > 1
|
24
|
+
arel_column.not_in(values)
|
25
|
+
else
|
26
|
+
use_or(filter, arel_column)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
FILTER_EVALUATORS = {
|
33
|
+
Query::Filters::Contains => ->(node, val) { node.matches("%#{val}%") },
|
34
|
+
Query::Filters::Equals => ->(node, val) { node.eq(val) },
|
35
|
+
Query::Filters::GreaterThan => ->(node, val) { node.gt(val) },
|
36
|
+
Query::Filters::GreaterThanOrEqualTo => ->(node, val) { node.gteq(val) },
|
37
|
+
Query::Filters::LessThan => ->(node, val) { node.lt(val) },
|
38
|
+
Query::Filters::LessThanOrEqualTo => ->(node, val) { node.lteq(val) },
|
39
|
+
Query::Filters::NotContain => ->(node, val) { node.does_not_match("%#{val}%") },
|
40
|
+
Query::Filters::NotEquals => ->(node, val) { node.not_eq(val) },
|
41
|
+
Query::Filters::NotStartWith => ->(node, val) { node.does_not_match("#{val}%") },
|
42
|
+
Query::Filters::StartsWith => ->(node, val) { node.matches("#{val}%") }
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
private_constant :FILTER_EVALUATORS
|
46
|
+
|
47
|
+
def normalize(value)
|
48
|
+
value ? Array(value).flatten : [nil]
|
49
|
+
end
|
50
|
+
|
51
|
+
def use_or(filter, arel_column)
|
52
|
+
predicates = normalize(filter.value).map do |coerced_value|
|
53
|
+
method = FILTER_EVALUATORS[filter.class]
|
54
|
+
|
55
|
+
raise ArgumentError, "cannot compile filter: #{filter}" unless method
|
56
|
+
|
57
|
+
method.call(arel_column, coerced_value)
|
58
|
+
end
|
59
|
+
|
60
|
+
predicates.inject(predicates.shift) do |memo, predicate|
|
61
|
+
memo.or(predicate)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/spec/db_helper.rb
CHANGED
@@ -10,6 +10,27 @@
|
|
10
10
|
# Enable logging using something like:
|
11
11
|
# ActiveRecord::Base.logger = Logger.new(STDERR)
|
12
12
|
|
13
|
+
class Field < ActiveRecord::Base
|
14
|
+
has_many :patient_field_values
|
15
|
+
end
|
16
|
+
|
17
|
+
class Patient < ActiveRecord::Base
|
18
|
+
has_many :patient_field_values
|
19
|
+
has_many :patient_payments
|
20
|
+
|
21
|
+
accepts_nested_attributes_for :patient_field_values
|
22
|
+
accepts_nested_attributes_for :patient_payments
|
23
|
+
end
|
24
|
+
|
25
|
+
class PatientFieldValue < ActiveRecord::Base
|
26
|
+
belongs_to :patient
|
27
|
+
belongs_to :field
|
28
|
+
end
|
29
|
+
|
30
|
+
class PatientPayment < ActiveRecord::Base
|
31
|
+
belongs_to :patient
|
32
|
+
end
|
33
|
+
|
13
34
|
def connect_to_db(name)
|
14
35
|
config = yaml_file_read('spec', 'config', 'database.yaml')[name.to_s]
|
15
36
|
ActiveRecord::Base.establish_connection(config)
|
@@ -17,39 +38,40 @@ end
|
|
17
38
|
|
18
39
|
def load_schema
|
19
40
|
ActiveRecord::Schema.define do
|
41
|
+
# Movie Theater Schema
|
20
42
|
create_table :theaters do |t|
|
21
|
-
t.column :name,
|
43
|
+
t.column :name, :string
|
22
44
|
t.column :partition, :string
|
23
|
-
t.column :active,
|
45
|
+
t.column :active, :boolean
|
24
46
|
t.column :inspected, :boolean
|
25
47
|
t.timestamps
|
26
48
|
end
|
27
49
|
|
28
50
|
create_table :members do |t|
|
29
|
-
t.column :tid,
|
51
|
+
t.column :tid, :integer
|
30
52
|
t.column :account_number, :string
|
31
|
-
t.column :partition,
|
53
|
+
t.column :partition, :string
|
32
54
|
t.timestamps
|
33
55
|
end
|
34
56
|
|
35
57
|
create_table :demographics do |t|
|
36
58
|
t.column :member_id, :integer
|
37
|
-
t.column :name,
|
59
|
+
t.column :name, :string
|
38
60
|
t.timestamps
|
39
61
|
end
|
40
62
|
|
41
63
|
create_table :phone_numbers do |t|
|
42
64
|
t.column :demographic_id, :integer
|
43
|
-
t.column :phone_type,
|
44
|
-
t.column :phone_number,
|
65
|
+
t.column :phone_type, :string
|
66
|
+
t.column :phone_number, :string
|
45
67
|
t.timestamps
|
46
68
|
end
|
47
69
|
|
48
70
|
create_table :movies do |t|
|
49
71
|
t.column :member_id, :integer
|
50
|
-
t.column :name,
|
51
|
-
t.column :genre,
|
52
|
-
t.column :favorite,
|
72
|
+
t.column :name, :string
|
73
|
+
t.column :genre, :string
|
74
|
+
t.column :favorite, :boolean, default: false, null: false
|
53
75
|
t.timestamps
|
54
76
|
end
|
55
77
|
|
@@ -60,10 +82,10 @@ def load_schema
|
|
60
82
|
|
61
83
|
create_table :animals do |t|
|
62
84
|
t.column :owner_id, :integer
|
63
|
-
t.column :toy_id,
|
64
|
-
t.column :type,
|
65
|
-
t.column :name,
|
66
|
-
t.column :deleted,
|
85
|
+
t.column :toy_id, :integer
|
86
|
+
t.column :type, :string
|
87
|
+
t.column :name, :string
|
88
|
+
t.column :deleted, :boolean
|
67
89
|
t.timestamps
|
68
90
|
end
|
69
91
|
|
@@ -76,5 +98,99 @@ def load_schema
|
|
76
98
|
t.column :laser, :boolean
|
77
99
|
t.timestamps
|
78
100
|
end
|
101
|
+
|
102
|
+
# Patient Schema
|
103
|
+
create_table :fields do |t|
|
104
|
+
t.column :section, :string
|
105
|
+
t.column :key, :string
|
106
|
+
t.timestamps
|
107
|
+
end
|
108
|
+
|
109
|
+
create_table :patients do |t|
|
110
|
+
t.column :first, :string
|
111
|
+
t.column :middle, :string
|
112
|
+
t.column :last, :string
|
113
|
+
t.timestamps
|
114
|
+
end
|
115
|
+
|
116
|
+
create_table :patient_field_values do |t|
|
117
|
+
t.column :patient_id, :integer
|
118
|
+
t.column :field_id, :integer
|
119
|
+
t.column :value, :string
|
120
|
+
t.timestamps
|
121
|
+
end
|
122
|
+
|
123
|
+
create_table :patient_payments do |t|
|
124
|
+
t.column :patient_id, :integer
|
125
|
+
t.column :amount, :decimal
|
126
|
+
t.timestamps
|
127
|
+
end
|
79
128
|
end
|
80
129
|
end
|
130
|
+
|
131
|
+
def load_data
|
132
|
+
demo_dob_field = Field.create!(section: 'demographics', key: 'dob')
|
133
|
+
demo_drivers_license_field = Field.create!(section: 'demographics', key: 'drivers_license')
|
134
|
+
demo_notes_field = Field.create!(section: 'demographics', key: 'notes')
|
135
|
+
|
136
|
+
contact_phone_number_field = Field.create!(section: 'contact', key: 'phone_number')
|
137
|
+
contact_notes_field = Field.create!(section: 'contact', key: 'notes')
|
138
|
+
|
139
|
+
Patient.create!(
|
140
|
+
first: 'Bozo',
|
141
|
+
middle: 'The',
|
142
|
+
last: 'Clown',
|
143
|
+
patient_field_values_attributes: [
|
144
|
+
{
|
145
|
+
field: demo_dob_field,
|
146
|
+
value: '1904-04-04'
|
147
|
+
},
|
148
|
+
{
|
149
|
+
field: demo_notes_field,
|
150
|
+
value: 'The patient is funny!'
|
151
|
+
},
|
152
|
+
{
|
153
|
+
field: demo_drivers_license_field,
|
154
|
+
value: '82-54-hut-hut-hike!'
|
155
|
+
},
|
156
|
+
{
|
157
|
+
field: contact_phone_number_field,
|
158
|
+
value: '555-555-5555'
|
159
|
+
},
|
160
|
+
{
|
161
|
+
field: contact_notes_field,
|
162
|
+
value: 'Do not call this patient at night!'
|
163
|
+
}
|
164
|
+
],
|
165
|
+
patient_payments_attributes: [
|
166
|
+
{ amount: 5 },
|
167
|
+
{ amount: 10 },
|
168
|
+
{ amount: 15 }
|
169
|
+
]
|
170
|
+
)
|
171
|
+
|
172
|
+
Patient.create!(
|
173
|
+
first: 'Frank',
|
174
|
+
last: 'Rizzo',
|
175
|
+
patient_payments_attributes: [
|
176
|
+
{ amount: 50 },
|
177
|
+
{ amount: 150 }
|
178
|
+
]
|
179
|
+
)
|
180
|
+
|
181
|
+
Patient.create!(
|
182
|
+
first: 'Bugs',
|
183
|
+
middle: 'The',
|
184
|
+
last: 'Bunny',
|
185
|
+
patient_field_values_attributes: [
|
186
|
+
{
|
187
|
+
field: demo_dob_field,
|
188
|
+
value: '2040-01-01'
|
189
|
+
},
|
190
|
+
{
|
191
|
+
field: contact_notes_field,
|
192
|
+
value: 'Call anytime!!'
|
193
|
+
}
|
194
|
+
]
|
195
|
+
)
|
196
|
+
end
|
@@ -79,7 +79,7 @@ describe Dbee::Providers::ActiveRecordProvider do
|
|
79
79
|
end
|
80
80
|
end
|
81
81
|
|
82
|
-
context '
|
82
|
+
context 'Shallow SQL Execution' do
|
83
83
|
%w[sqlite].each do |dbms|
|
84
84
|
context dbms do
|
85
85
|
before(:all) do
|
@@ -108,4 +108,105 @@ describe Dbee::Providers::ActiveRecordProvider do
|
|
108
108
|
end
|
109
109
|
end
|
110
110
|
end
|
111
|
+
|
112
|
+
describe 'Deep SQL execution' do
|
113
|
+
before(:all) do
|
114
|
+
connect_to_db(:sqlite)
|
115
|
+
load_schema
|
116
|
+
load_data
|
117
|
+
end
|
118
|
+
|
119
|
+
describe 'pivoting' do
|
120
|
+
let(:snapshot_path) do
|
121
|
+
%w[
|
122
|
+
spec
|
123
|
+
fixtures
|
124
|
+
active_record_snapshots
|
125
|
+
two_table_query_with_pivoting.yaml
|
126
|
+
]
|
127
|
+
end
|
128
|
+
|
129
|
+
let(:snapshot) { yaml_file_read(*snapshot_path) }
|
130
|
+
let(:query) { Dbee::Query.make(snapshot['query']) }
|
131
|
+
let(:model) { Dbee::Model.make(models['Patients']) }
|
132
|
+
|
133
|
+
it 'pivots table rows into columns' do
|
134
|
+
sql = described_class.new.sql(model, query)
|
135
|
+
|
136
|
+
results = ActiveRecord::Base.connection.execute(sql)
|
137
|
+
|
138
|
+
expect(results[0]).to include(
|
139
|
+
'First Name' => 'Bozo',
|
140
|
+
'Date of Birth' => '1904-04-04',
|
141
|
+
'Drivers License #' => '82-54-hut-hut-hike!',
|
142
|
+
'Demographic Notes' => 'The patient is funny!',
|
143
|
+
'Contact Notes' => 'Do not call this patient at night!'
|
144
|
+
)
|
145
|
+
|
146
|
+
expect(results[1]).to include(
|
147
|
+
'First Name' => 'Frank',
|
148
|
+
'Date of Birth' => nil,
|
149
|
+
'Drivers License #' => nil,
|
150
|
+
'Demographic Notes' => nil,
|
151
|
+
'Contact Notes' => nil
|
152
|
+
)
|
153
|
+
|
154
|
+
expect(results[2]).to include(
|
155
|
+
'First Name' => 'Bugs',
|
156
|
+
'Date of Birth' => '2040-01-01',
|
157
|
+
'Drivers License #' => nil,
|
158
|
+
'Demographic Notes' => nil,
|
159
|
+
'Contact Notes' => 'Call anytime!!'
|
160
|
+
)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
describe 'aggregation' do
|
165
|
+
let(:snapshot_path) do
|
166
|
+
%w[
|
167
|
+
spec
|
168
|
+
fixtures
|
169
|
+
active_record_snapshots
|
170
|
+
two_table_query_with_aggregation.yaml
|
171
|
+
]
|
172
|
+
end
|
173
|
+
|
174
|
+
let(:snapshot) { yaml_file_read(*snapshot_path) }
|
175
|
+
let(:query) { Dbee::Query.make(snapshot['query']) }
|
176
|
+
let(:model) { Dbee::Model.make(models['Patients']) }
|
177
|
+
|
178
|
+
it 'executes correct SQL aggregate functions' do
|
179
|
+
sql = described_class.new.sql(model, query)
|
180
|
+
|
181
|
+
results = ActiveRecord::Base.connection.execute(sql)
|
182
|
+
|
183
|
+
expect(results[0]).to include(
|
184
|
+
'First Name' => 'Bozo',
|
185
|
+
'Ave Payment' => 10,
|
186
|
+
'Number of Payments' => 3,
|
187
|
+
'Max Payment' => 15,
|
188
|
+
'Min Payment' => 5,
|
189
|
+
'Total Paid' => 30
|
190
|
+
)
|
191
|
+
|
192
|
+
expect(results[1]).to include(
|
193
|
+
'First Name' => 'Frank',
|
194
|
+
'Ave Payment' => 100,
|
195
|
+
'Number of Payments' => 2,
|
196
|
+
'Max Payment' => 150,
|
197
|
+
'Min Payment' => 50,
|
198
|
+
'Total Paid' => 200
|
199
|
+
)
|
200
|
+
|
201
|
+
expect(results[2]).to include(
|
202
|
+
'First Name' => 'Bugs',
|
203
|
+
'Ave Payment' => nil,
|
204
|
+
'Number of Payments' => 0,
|
205
|
+
'Max Payment' => nil,
|
206
|
+
'Min Payment' => nil,
|
207
|
+
'Total Paid' => nil
|
208
|
+
)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
111
212
|
end
|
@@ -45,6 +45,10 @@ query:
|
|
45
45
|
- 'Netflix'
|
46
46
|
- 'Hulu'
|
47
47
|
key_path: name
|
48
|
+
- type: not_equals
|
49
|
+
value:
|
50
|
+
- 'YouTube Super Video'
|
51
|
+
key_path: name
|
48
52
|
- type: not_contain
|
49
53
|
value:
|
50
54
|
- 'tfli'
|
@@ -59,67 +63,71 @@ sqlite_readable: |+
|
|
59
63
|
SELECT "theaters"."id" AS 'ID #',
|
60
64
|
"theaters"."name" AS 'name'
|
61
65
|
FROM "theaters" "theaters"
|
62
|
-
WHERE
|
66
|
+
WHERE "theaters"."name" IN ('AMC', 'Regal') AND
|
63
67
|
"theaters"."name" LIKE 'A%' AND
|
64
68
|
"theaters"."name" LIKE '%m%' AND
|
65
69
|
"theaters"."active" = 'false' AND
|
66
|
-
|
67
|
-
|
70
|
+
"theaters"."active" IN ('t', 'f', NULL) AND
|
71
|
+
"theaters"."inspected" IN ('f', 't', NULL) AND
|
68
72
|
"theaters"."created_at" <= '2019-03-04' AND
|
69
73
|
"theaters"."created_at" < '2018-03-04' AND
|
70
74
|
"theaters"."created_at" >= '2001-03-04' AND
|
71
75
|
"theaters"."created_at" > '2002-03-04' AND
|
72
|
-
|
76
|
+
"theaters"."name" NOT IN ('Netflix', 'Hulu') AND
|
77
|
+
"theaters"."name" != 'YouTube Super Video' AND
|
73
78
|
("theaters"."name" NOT LIKE '%tfli%' OR "theaters"."name" NOT LIKE '%ul%') AND
|
74
79
|
("theaters"."name" NOT LIKE 'netf%' OR "theaters"."name" NOT LIKE 'hu%')
|
75
80
|
sqlite_not_readable: |+
|
76
81
|
SELECT "t0"."id" AS 'c0',
|
77
82
|
"t0"."name" AS 'c1'
|
78
83
|
FROM "theaters" "t0"
|
79
|
-
WHERE
|
84
|
+
WHERE "t0"."name" IN ('AMC', 'Regal') AND
|
80
85
|
"t0"."name" LIKE 'A%' AND
|
81
86
|
"t0"."name" LIKE '%m%' AND
|
82
87
|
"t0"."active" = 'false' AND
|
83
|
-
|
84
|
-
|
88
|
+
"t0"."active" IN ('t', 'f', NULL) AND
|
89
|
+
"t0"."inspected" IN ('f', 't', NULL) AND
|
85
90
|
"t0"."created_at" <= '2019-03-04' AND
|
86
91
|
"t0"."created_at" < '2018-03-04' AND
|
87
92
|
"t0"."created_at" >= '2001-03-04' AND
|
88
93
|
"t0"."created_at" > '2002-03-04' AND
|
89
|
-
|
94
|
+
"t0"."name" NOT IN ('Netflix', 'Hulu') AND
|
95
|
+
"t0"."name" != 'YouTube Super Video' AND
|
90
96
|
("t0"."name" NOT LIKE '%tfli%' OR "t0"."name" NOT LIKE '%ul%') AND
|
91
97
|
("t0"."name" NOT LIKE 'netf%' OR "t0"."name" NOT LIKE 'hu%')
|
92
98
|
mysql_readable: |+
|
93
99
|
SELECT `theaters`.`id` AS 'ID #',
|
94
100
|
`theaters`.`name` AS 'name'
|
95
101
|
FROM `theaters` `theaters`
|
96
|
-
WHERE
|
102
|
+
WHERE `theaters`.`name` IN ('AMC', 'Regal') AND
|
97
103
|
`theaters`.`name` LIKE 'A%' AND
|
98
104
|
`theaters`.`name` LIKE '%m%' AND
|
99
105
|
`theaters`.`active` = 'false' AND
|
100
|
-
|
101
|
-
|
106
|
+
`theaters`.`active` IN (TRUE, FALSE, NULL) AND
|
107
|
+
`theaters`.`inspected` IN (FALSE, TRUE, NULL) AND
|
102
108
|
`theaters`.`created_at` <= '2019-03-04' AND
|
103
109
|
`theaters`.`created_at` < '2018-03-04' AND
|
104
110
|
`theaters`.`created_at` >= '2001-03-04' AND
|
105
111
|
`theaters`.`created_at` > '2002-03-04' AND
|
106
|
-
|
112
|
+
`theaters`.`name` NOT IN ('Netflix', 'Hulu') AND
|
113
|
+
`theaters`.`name` != 'YouTube Super Video' AND
|
107
114
|
(`theaters`.`name` NOT LIKE '%tfli%' OR `theaters`.`name` NOT LIKE '%ul%') AND
|
108
115
|
(`theaters`.`name` NOT LIKE 'netf%' OR `theaters`.`name` NOT LIKE 'hu%')
|
109
116
|
mysql_not_readable: |+
|
110
117
|
SELECT `t0`.`id` AS 'c0',
|
111
118
|
`t0`.`name` AS 'c1'
|
112
119
|
FROM `theaters` `t0`
|
113
|
-
WHERE
|
120
|
+
WHERE `t0`.`name` IN ('AMC', 'Regal') AND
|
114
121
|
`t0`.`name` LIKE 'A%' AND
|
115
122
|
`t0`.`name` LIKE '%m%' AND
|
116
123
|
`t0`.`active` = 'false' AND
|
117
|
-
|
118
|
-
|
124
|
+
`t0`.`active` IN (TRUE, FALSE, NULL) AND
|
125
|
+
`t0`.`inspected` IN (FALSE, TRUE, NULL) AND
|
119
126
|
`t0`.`created_at` <= '2019-03-04' AND
|
120
127
|
`t0`.`created_at` < '2018-03-04' AND
|
121
128
|
`t0`.`created_at` >= '2001-03-04' AND
|
122
129
|
`t0`.`created_at` > '2002-03-04' AND
|
123
|
-
|
130
|
+
`t0`.`name` NOT IN ('Netflix', 'Hulu') AND
|
131
|
+
`t0`.`name` != 'YouTube Super Video' AND
|
124
132
|
(`t0`.`name` NOT LIKE '%tfli%' OR `t0`.`name` NOT LIKE '%ul%') AND
|
125
133
|
(`t0`.`name` NOT LIKE 'netf%' OR `t0`.`name` NOT LIKE 'hu%')
|