dbee-active_record 2.0.2 → 2.1.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.
Files changed (26) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -12
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +3 -10
  5. data/CHANGELOG.md +22 -0
  6. data/dbee-active_record.gemspec +14 -6
  7. data/exe/.gitkeep +0 -0
  8. data/lib/dbee/providers/active_record_provider/expression_builder.rb +55 -24
  9. data/lib/dbee/providers/active_record_provider/maker.rb +37 -0
  10. data/lib/dbee/providers/active_record_provider/{expression_builder/constraint_maker.rb → makers/constraint.rb} +12 -12
  11. data/lib/dbee/providers/active_record_provider/{expression_builder/order_maker.rb → makers/order.rb} +9 -9
  12. data/lib/dbee/providers/active_record_provider/makers/select.rb +81 -0
  13. data/lib/dbee/providers/active_record_provider/makers/where.rb +91 -0
  14. data/lib/dbee/providers/active_record_provider/version.rb +1 -1
  15. data/spec/db_helper.rb +134 -14
  16. data/spec/dbee/providers/active_record_provider/expression_builder_spec.rb +117 -0
  17. data/spec/dbee/providers/active_record_provider_spec.rb +101 -3
  18. data/spec/fixtures/active_record_snapshots/one_table_empty_query.yaml +10 -0
  19. data/spec/fixtures/active_record_snapshots/one_table_query_with_filters.yaml +24 -16
  20. data/spec/fixtures/active_record_snapshots/two_table_query_with_aggregation.yaml +71 -0
  21. data/spec/fixtures/active_record_snapshots/two_table_query_with_pivoting.yaml +88 -0
  22. data/spec/fixtures/models.yaml +20 -0
  23. data/spec/spec_helper.rb +4 -0
  24. metadata +33 -19
  25. data/lib/dbee/providers/active_record_provider/expression_builder/select_maker.rb +0 -33
  26. data/lib/dbee/providers/active_record_provider/expression_builder/where_maker.rb +0 -56
@@ -0,0 +1,91 @@
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
+ module Makers # :nodoc: all
14
+ # Derives Arel#where predicates.
15
+ class Where
16
+ include Singleton
17
+
18
+ def make(filter, arel_column)
19
+ # If the filter has a value of nil, then simply return an IS NULL predicate
20
+ return make_is_null_predicate(arel_column) unless filter.value
21
+
22
+ values = Array(filter.value).flatten
23
+
24
+ # This logic helps ensure that if a null exists that it translates to an IS NULL
25
+ # predicate and does not get put into an in or not_in clause.
26
+ predicates = values.include?(nil) ? [make_is_null_predicate(arel_column)] : []
27
+ predicates += make_predicates(filter, arel_column, values - [nil])
28
+
29
+ # Chain all predicates together
30
+ predicates.inject(predicates.shift) do |memo, predicate|
31
+ memo.or(predicate)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ FILTER_EVALUATORS = {
38
+ Query::Filters::Contains => ->(node, val) { node.matches("%#{val}%") },
39
+ Query::Filters::Equals => ->(node, val) { node.eq(val) },
40
+ Query::Filters::GreaterThan => ->(node, val) { node.gt(val) },
41
+ Query::Filters::GreaterThanOrEqualTo => ->(node, val) { node.gteq(val) },
42
+ Query::Filters::LessThan => ->(node, val) { node.lt(val) },
43
+ Query::Filters::LessThanOrEqualTo => ->(node, val) { node.lteq(val) },
44
+ Query::Filters::NotContain => ->(node, val) { node.does_not_match("%#{val}%") },
45
+ Query::Filters::NotEquals => ->(node, val) { node.not_eq(val) },
46
+ Query::Filters::NotStartWith => ->(node, val) { node.does_not_match("#{val}%") },
47
+ Query::Filters::StartsWith => ->(node, val) { node.matches("#{val}%") }
48
+ }.freeze
49
+
50
+ private_constant :FILTER_EVALUATORS
51
+
52
+ def make_predicates(filter, arel_column, values)
53
+ if use_in?(filter, values)
54
+ [arel_column.in(values)]
55
+ elsif use_not_in?(filter, values)
56
+ [arel_column.not_in(values)]
57
+ else
58
+ make_or_predicates(filter, arel_column, values)
59
+ end
60
+ end
61
+
62
+ def use_in?(filter, values)
63
+ filter.is_a?(Query::Filters::Equals) && values.length > 1
64
+ end
65
+
66
+ def use_not_in?(filter, values)
67
+ filter.is_a?(Query::Filters::NotEquals) && values.length > 1
68
+ end
69
+
70
+ def make_or_predicates(filter, arel_column, values)
71
+ values.map do |value|
72
+ make_predicate(arel_column, filter.class, value)
73
+ end
74
+ end
75
+
76
+ def make_predicate(arel_column, filter_class, value)
77
+ method = FILTER_EVALUATORS[filter_class]
78
+
79
+ raise ArgumentError, "cannot compile filter: #{filter}" unless method
80
+
81
+ method.call(arel_column, value)
82
+ end
83
+
84
+ def make_is_null_predicate(arel_column)
85
+ make_predicate(arel_column, Query::Filters::Equals, nil)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -10,7 +10,7 @@
10
10
  module Dbee
11
11
  module Providers
12
12
  class ActiveRecordProvider
13
- VERSION = '2.0.2'
13
+ VERSION = '2.1.1'
14
14
  end
15
15
  end
16
16
  end
@@ -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, :string
43
+ t.column :name, :string
22
44
  t.column :partition, :string
23
- t.column :active, :boolean
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, :integer
51
+ t.column :tid, :integer
30
52
  t.column :account_number, :string
31
- t.column :partition, :string
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, :string
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, :string
44
- t.column :phone_number, :string
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, :string
51
- t.column :genre, :string
52
- t.column :favorite, :boolean, default: false, null: false
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, :integer
64
- t.column :type, :string
65
- t.column :name, :string
66
- t.column :deleted, :boolean
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,103 @@ 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
+ add_index :fields, %i[section key], unique: true
110
+
111
+ create_table :patients do |t|
112
+ t.column :first, :string
113
+ t.column :middle, :string
114
+ t.column :last, :string
115
+ t.timestamps
116
+ end
117
+
118
+ create_table :patient_field_values do |t|
119
+ t.column :patient_id, :integer, foreign_key: true
120
+ t.column :field_id, :integer, foreign_key: true
121
+ t.column :value, :string
122
+ t.timestamps
123
+ end
124
+
125
+ add_index :patient_field_values, %i[patient_id field_id], unique: true
126
+
127
+ create_table :patient_payments do |t|
128
+ t.column :patient_id, :integer, foreign_key: true
129
+ t.column :amount, :decimal
130
+ t.timestamps
131
+ end
79
132
  end
80
133
  end
134
+
135
+ def load_data
136
+ demo_dob_field = Field.create!(section: 'demographics', key: 'dob')
137
+ demo_drivers_license_field = Field.create!(section: 'demographics', key: 'drivers_license')
138
+ demo_notes_field = Field.create!(section: 'demographics', key: 'notes')
139
+
140
+ contact_phone_number_field = Field.create!(section: 'contact', key: 'phone_number')
141
+ contact_notes_field = Field.create!(section: 'contact', key: 'notes')
142
+
143
+ Patient.create!(
144
+ first: 'Bozo',
145
+ middle: 'The',
146
+ last: 'Clown',
147
+ patient_field_values_attributes: [
148
+ {
149
+ field: demo_dob_field,
150
+ value: '1904-04-04'
151
+ },
152
+ {
153
+ field: demo_notes_field,
154
+ value: 'The patient is funny!'
155
+ },
156
+ {
157
+ field: demo_drivers_license_field,
158
+ value: '82-54-hut-hut-hike!'
159
+ },
160
+ {
161
+ field: contact_phone_number_field,
162
+ value: '555-555-5555'
163
+ },
164
+ {
165
+ field: contact_notes_field,
166
+ value: 'Do not call this patient at night!'
167
+ }
168
+ ],
169
+ patient_payments_attributes: [
170
+ { amount: 5 },
171
+ { amount: 10 },
172
+ { amount: 15 }
173
+ ]
174
+ )
175
+
176
+ Patient.create!(
177
+ first: 'Frank',
178
+ last: 'Rizzo',
179
+ patient_payments_attributes: [
180
+ { amount: 50 },
181
+ { amount: 150 }
182
+ ]
183
+ )
184
+
185
+ Patient.create!(
186
+ first: 'Bugs',
187
+ middle: 'The',
188
+ last: 'Bunny',
189
+ patient_field_values_attributes: [
190
+ {
191
+ field: demo_dob_field,
192
+ value: '2040-01-01'
193
+ },
194
+ {
195
+ field: contact_notes_field,
196
+ value: 'Call anytime!!'
197
+ }
198
+ ]
199
+ )
200
+ end
@@ -0,0 +1,117 @@
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
+ require 'spec_helper'
11
+ require 'db_helper'
12
+
13
+ describe Dbee::Providers::ActiveRecordProvider::ExpressionBuilder do
14
+ let(:model) { Dbee::Model.make(models['Patients']) }
15
+ let(:alias_maker) { Dbee::Providers::ActiveRecordProvider::SafeAliasMaker.new }
16
+
17
+ let(:id_and_average_query) do
18
+ Dbee::Query.make(
19
+ fields: [
20
+ {
21
+ key_path: :id,
22
+ display: 'ID #'
23
+ },
24
+ {
25
+ key_path: 'patient_payments.amount',
26
+ display: 'Ave Payment',
27
+ aggregator: :ave
28
+ }
29
+ ],
30
+ sorters: [
31
+ {
32
+ key_path: :id
33
+ }
34
+ ],
35
+ filters: [
36
+ {
37
+ key_path: :id,
38
+ value: 123
39
+ }
40
+ ]
41
+ )
42
+ end
43
+
44
+ let(:first_and_count_query) do
45
+ Dbee::Query.make(
46
+ fields: [
47
+ {
48
+ key_path: :first,
49
+ display: 'First Name'
50
+ },
51
+ {
52
+ key_path: 'patient_payments.amount',
53
+ display: 'Number of Payments',
54
+ aggregator: :count
55
+ }
56
+ ]
57
+ )
58
+ end
59
+
60
+ subject { described_class.new(model, alias_maker, alias_maker) }
61
+
62
+ before(:all) do
63
+ connect_to_db(:sqlite)
64
+ end
65
+
66
+ describe '#clear' do
67
+ it 'provides fluent interface (returns self)' do
68
+ expect(subject.clear).to eq(subject)
69
+ end
70
+
71
+ it 'resets selecting, grouping, sorting, and filtering' do
72
+ subject.add(id_and_average_query)
73
+
74
+ sql = subject.to_sql
75
+
76
+ expect(sql).not_to include('*')
77
+ expect(sql).to include('GROUP')
78
+ expect(sql).to include('WHERE')
79
+ expect(sql).to include('ORDER')
80
+
81
+ sql = subject.clear.to_sql
82
+
83
+ expect(sql).to include('*')
84
+ expect(sql).not_to include('GROUP')
85
+ expect(sql).not_to include('WHERE')
86
+ expect(sql).not_to include('ORDER')
87
+ end
88
+ end
89
+
90
+ describe '#to_sql' do
91
+ specify 'when called with no fields, then called with fields removes star select' do
92
+ expect(subject.to_sql).to include('*')
93
+
94
+ subject.add(id_and_average_query)
95
+
96
+ expect(subject.to_sql).not_to include('*')
97
+ end
98
+
99
+ context 'with aggregation' do
100
+ it 'generates the same sql when called multiple times' do
101
+ subject.add(id_and_average_query)
102
+
103
+ first_sql = subject.to_sql
104
+ second_sql = subject.to_sql
105
+
106
+ expect(first_sql).to eq(second_sql)
107
+
108
+ subject.add(first_and_count_query)
109
+
110
+ first_sql = subject.to_sql
111
+ second_sql = subject.to_sql
112
+
113
+ expect(first_sql).to eq(second_sql)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -11,8 +11,6 @@ require 'spec_helper'
11
11
  require 'db_helper'
12
12
 
13
13
  describe Dbee::Providers::ActiveRecordProvider do
14
- let(:models) { yaml_fixture('models.yaml') }
15
-
16
14
  describe '#sql' do
17
15
  before(:all) do
18
16
  connect_to_db(:sqlite)
@@ -79,7 +77,7 @@ describe Dbee::Providers::ActiveRecordProvider do
79
77
  end
80
78
  end
81
79
 
82
- context 'Executing SQL' do
80
+ context 'Shallow SQL Execution' do
83
81
  %w[sqlite].each do |dbms|
84
82
  context dbms do
85
83
  before(:all) do
@@ -108,4 +106,104 @@ describe Dbee::Providers::ActiveRecordProvider do
108
106
  end
109
107
  end
110
108
  end
109
+
110
+ describe 'Deep SQL execution' do
111
+ before(:all) do
112
+ connect_to_db(:sqlite)
113
+ load_schema
114
+ load_data
115
+ end
116
+
117
+ describe 'pivoting' do
118
+ let(:snapshot_path) do
119
+ %w[
120
+ spec
121
+ fixtures
122
+ active_record_snapshots
123
+ two_table_query_with_pivoting.yaml
124
+ ]
125
+ end
126
+
127
+ let(:snapshot) { yaml_file_read(*snapshot_path) }
128
+ let(:query) { Dbee::Query.make(snapshot['query']) }
129
+ let(:model) { Dbee::Model.make(models['Patients']) }
130
+
131
+ it 'pivots table rows into columns' do
132
+ sql = described_class.new.sql(model, query)
133
+
134
+ results = ActiveRecord::Base.connection.execute(sql)
135
+
136
+ expect(results[0]).to include(
137
+ 'First Name' => 'Bozo',
138
+ 'Date of Birth' => '1904-04-04',
139
+ 'Drivers License #' => '82-54-hut-hut-hike!',
140
+ 'Demographic Notes' => 'The patient is funny!',
141
+ 'Contact Notes' => 'Do not call this patient at night!'
142
+ )
143
+
144
+ expect(results[1]).to include(
145
+ 'First Name' => 'Frank',
146
+ 'Date of Birth' => nil,
147
+ 'Drivers License #' => nil,
148
+ 'Demographic Notes' => nil,
149
+ 'Contact Notes' => nil
150
+ )
151
+
152
+ expect(results[2]).to include(
153
+ 'First Name' => 'Bugs',
154
+ 'Date of Birth' => '2040-01-01',
155
+ 'Drivers License #' => nil,
156
+ 'Demographic Notes' => nil,
157
+ 'Contact Notes' => 'Call anytime!!'
158
+ )
159
+ end
160
+ end
161
+
162
+ describe 'aggregation' do
163
+ let(:snapshot_path) do
164
+ %w[
165
+ spec
166
+ fixtures
167
+ active_record_snapshots
168
+ two_table_query_with_aggregation.yaml
169
+ ]
170
+ end
171
+
172
+ let(:snapshot) { yaml_file_read(*snapshot_path) }
173
+ let(:query) { Dbee::Query.make(snapshot['query']) }
174
+ let(:model) { Dbee::Model.make(models['Patients']) }
175
+
176
+ it 'executes correct SQL aggregate functions' do
177
+ sql = described_class.new.sql(model, query)
178
+ results = ActiveRecord::Base.connection.execute(sql)
179
+
180
+ expect(results[0]).to include(
181
+ 'First Name' => 'Bozo',
182
+ 'Ave Payment' => 10,
183
+ 'Number of Payments' => 3,
184
+ 'Max Payment' => 15,
185
+ 'Min Payment' => 5,
186
+ 'Total Paid' => 30
187
+ )
188
+
189
+ expect(results[1]).to include(
190
+ 'First Name' => 'Frank',
191
+ 'Ave Payment' => 100,
192
+ 'Number of Payments' => 2,
193
+ 'Max Payment' => 150,
194
+ 'Min Payment' => 50,
195
+ 'Total Paid' => 200
196
+ )
197
+
198
+ expect(results[2]).to include(
199
+ 'First Name' => 'Bugs',
200
+ 'Ave Payment' => nil,
201
+ 'Number of Payments' => 0,
202
+ 'Max Payment' => nil,
203
+ 'Min Payment' => nil,
204
+ 'Total Paid' => nil
205
+ )
206
+ end
207
+ end
208
+ end
111
209
  end