sbf-dm-aggregates 1.3.0.beta

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,98 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module DataObjectsAdapter
4
+ extend Chainable
5
+
6
+ def aggregate(query)
7
+ fields = query.fields
8
+ types = fields.map { |p| p.respond_to?(:operator) ? String : p.dump_class }
9
+
10
+ field_size = fields.size
11
+
12
+ records = []
13
+
14
+ with_connection do |connection|
15
+ statement, bind_values = select_statement(query)
16
+
17
+ command = connection.create_command(statement)
18
+ command.set_types(types)
19
+
20
+ reader = command.execute_reader(*bind_values)
21
+
22
+ begin
23
+ while reader.next!
24
+ row = fields.zip(reader.values).map do |field, value|
25
+ if field.respond_to?(:operator)
26
+ send(field.operator, field.target, value)
27
+ else
28
+ field.load(value)
29
+ end
30
+ end
31
+
32
+ records << ((field_size > 1) ? row : row[0])
33
+ end
34
+ ensure
35
+ reader.close
36
+ end
37
+ end
38
+
39
+ records
40
+ end
41
+
42
+ private def count(_property, value)
43
+ value.to_i
44
+ end
45
+
46
+ private def min(property, value)
47
+ property.load(value)
48
+ end
49
+
50
+ private def max(property, value)
51
+ property.load(value)
52
+ end
53
+
54
+ private def avg(property, value)
55
+ property.dump_class.equal?(::Integer) ? value.to_f : property.load(value)
56
+ end
57
+
58
+ private def sum(property, value)
59
+ property.load(value)
60
+ end
61
+
62
+ chainable do
63
+ def property_to_column_name(property, qualify)
64
+ case property
65
+ when DataMapper::Query::Operator
66
+ aggregate_field_statement(property.operator, property.target, qualify)
67
+
68
+ when Property, DataMapper::Query::Path
69
+ super
70
+
71
+ else
72
+ raise ArgumentError, '+property+ must be a DataMapper::Query::Operator, a DataMapper::Property or a Query::Path, but was a ' \
73
+ "#{property.class} (#{property.inspect})"
74
+ end
75
+ end
76
+ end
77
+
78
+ private def aggregate_field_statement(aggregate_function, property, qualify)
79
+ column_name = if aggregate_function == :count && property == :all
80
+ '*'
81
+ else
82
+ property_to_column_name(property, qualify)
83
+ end
84
+
85
+ function_name = case aggregate_function
86
+ when :count then 'COUNT'
87
+ when :min then 'MIN'
88
+ when :max then 'MAX'
89
+ when :avg then 'AVG'
90
+ when :sum then 'SUM'
91
+ else raise "Invalid aggregate function: #{aggregate_function.inspect}"
92
+ end
93
+
94
+ "#{function_name}(#{column_name})"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1 @@
1
+ require 'dm-aggregates/functions'
@@ -0,0 +1,11 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module Collection
4
+ include Functions
5
+
6
+ private def property_by_name(property_name)
7
+ properties[property_name]
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ require 'dm-aggregates/symbol_operators'
2
+
3
+ class Symbol
4
+ include DataMapper::Aggregates::SymbolOperators
5
+ end
@@ -0,0 +1,231 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module Functions
4
+ include DataMapper::Assertions
5
+
6
+ # Count results (given the conditions)
7
+ #
8
+ # @example the count of all friends
9
+ # Friend.count
10
+ #
11
+ # @example the count of all friends older then 18
12
+ # Friend.count(:age.gt => 18)
13
+ #
14
+ # @example the count of all your female friends
15
+ # Friend.count(:conditions => [ 'gender = ?', 'female' ])
16
+ #
17
+ # @example the count of all friends with an address (NULL values are not included)
18
+ # Friend.count(:address)
19
+ #
20
+ # @example the count of all friends with an address that are older then 18
21
+ # Friend.count(:address, :age.gt => 18)
22
+ #
23
+ # @example the count of all your female friends with an address
24
+ # Friend.count(:address, :conditions => [ 'gender = ?', 'female' ])
25
+ #
26
+ # @param args [Mixed]
27
+ # property [Symbol] of the property you with to count (optional)
28
+ # opts [Hash, Symbol] the conditions
29
+ #
30
+ # @return [Integer] return the count given the conditions
31
+ #
32
+ # @api public
33
+ def count(*args)
34
+ query = args.last.is_a?(Hash) ? args.pop : {}
35
+ property_name = args.first
36
+
37
+ assert_kind_of 'property', property_by_name(property_name), Property if property_name
38
+
39
+ aggregate(query&.merge(fields: [property_name ? property_name.count : :all.count])).to_i
40
+ end
41
+
42
+ # Get the lowest value of a property
43
+ #
44
+ # @example the age of the youngest friend
45
+ # Friend.min(:age)
46
+ #
47
+ # @example the age of the youngest female friend
48
+ # Friend.min(:age, :conditions => [ 'gender = ?', 'female' ])
49
+ #
50
+ # @param args [Mixed]
51
+ # property [Symbol] the property you wish to get the lowest value of
52
+ # opts [Hash, Symbol] the conditions
53
+ #
54
+ # @return [Integer] return the lowest value of a property given the conditions
55
+ #
56
+ # @api public
57
+ def min(*args)
58
+ query = args.last.is_a?(Hash) ? args.pop : {}
59
+ property_name = args.first
60
+
61
+ assert_property_type property_name, ::Integer, ::Float, ::BigDecimal, ::DateTime, ::Date, ::Time
62
+
63
+ aggregate(query&.merge(fields: [property_name&.min]))
64
+ end
65
+
66
+ # Get the highest value of a property
67
+ #
68
+ # @example the age of the oldest friend
69
+ # Friend.max(:age)
70
+ #
71
+ # @example the age of the oldest female friend
72
+ # Friend.max(:age, :conditions => [ 'gender = ?', 'female' ])
73
+ #
74
+ # @param args [Mixed]
75
+ # property [Symbol] the property you wish to get the highest value of
76
+ # opts [Hash, Symbol] the conditions
77
+ #
78
+ # @return [Integer] return the highest value of a property given the conditions
79
+ #
80
+ # @api public
81
+ def max(*args)
82
+ query = args.last.is_a?(Hash) ? args.pop : {}
83
+ property_name = args.first
84
+
85
+ assert_property_type property_name, ::Integer, ::Float, ::BigDecimal, ::DateTime, ::Date, ::Time
86
+
87
+ aggregate(query&.merge(fields: [property_name&.max]))
88
+ end
89
+
90
+ # Get the average value of a property
91
+ #
92
+ # @example the average age of all friends
93
+ # Friend.avg(:age)
94
+ #
95
+ # @example the average age of all female friends
96
+ # Friend.avg(:age, :conditions => [ 'gender = ?', 'female' ])
97
+ #
98
+ # @param args [Mixed]
99
+ # property [Symbol] the property you wish to get the average value of
100
+ # opts [Hash, Symbol] the conditions
101
+ #
102
+ # @return [Integer] return the average value of a property given the conditions
103
+ #
104
+ # @api public
105
+ def avg(*args)
106
+ query = args.last.is_a?(Hash) ? args.pop : {}
107
+ property_name = args.first
108
+
109
+ assert_property_type property_name, ::Integer, ::Float, ::BigDecimal
110
+
111
+ aggregate(query&.merge(fields: [property_name&.avg]))
112
+ end
113
+
114
+ # Get the total value of a property
115
+ #
116
+ # @example the total age of all friends
117
+ # Friend.sum(:age)
118
+ #
119
+ # @example the total age of all female friends
120
+ # Friend.max(:age, :conditions => [ 'gender = ?', 'female' ])
121
+ #
122
+ # @param args [Mixed]
123
+ # property [Symbol] the property you wish to get the total value of
124
+ # opts [Hash, Symbol] the conditions
125
+ #
126
+ # @return [Integer] return the total value of a property given the conditions
127
+ #
128
+ # @api public
129
+ def sum(*args)
130
+ query = args.last.is_a?(::Hash) ? args.pop : {}
131
+ property_name = args.first
132
+
133
+ assert_property_type property_name, ::Integer, ::Float, ::BigDecimal
134
+
135
+ aggregate(query.merge(fields: [property_name.sum]))
136
+ end
137
+
138
+ # Perform aggregate queries
139
+ #
140
+ # @example the count of friends
141
+ # Friend.aggregate(:all.count)
142
+ #
143
+ # @example the minimum age, the maximum age and the total age of friends
144
+ # Friend.aggregate(:age.min, :age.max, :age.sum)
145
+ #
146
+ # @example the average age, grouped by gender
147
+ # Friend.aggregate(:age.avg, :fields => [ :gender ])
148
+ #
149
+ # @param args [Mixed]
150
+ # aggregates [Symbol, ...] operators to aggregate with
151
+ # query [Hash] the conditions
152
+ #
153
+ # @return [Array,Numeric,DateTime,Date,Time] the results of the
154
+ # aggregate query
155
+ #
156
+ # @api public
157
+ def aggregate(*args)
158
+ query = args.last.is_a?(Hash) ? args.pop : {}
159
+
160
+ query[:fields] ||= []
161
+ query[:fields] |= args
162
+ query[:fields].map! { |f| normalize_field(f) }
163
+
164
+ raise ArgumentError, 'query[:fields] must not be empty' if query[:fields].empty?
165
+
166
+ unless query&.key?(:order)
167
+ # the current collection/model is already sorted by attributes
168
+ # and since we are projecting away some of the attributes,
169
+ # and then performing aggregate functions on the remainder,
170
+ # we need to honor the existing order, as if it were already
171
+ # materialized, and we are looping over the rows in order.
172
+
173
+ directions = direction_map
174
+
175
+ query[:order] = []
176
+
177
+ # use the current query order for each property if available
178
+ query[:fields].each do |property|
179
+ next unless property.is_a?(Property)
180
+
181
+ query[:order] << directions.fetch(property, property)
182
+ end
183
+ end
184
+
185
+ query = scoped_query(query)
186
+
187
+ if query.fields.any? { |p| p.is_a?(Property) }
188
+ query.repository.aggregate(query.update(unique: true))
189
+ else
190
+ query.repository.aggregate(query).first # only return one row
191
+ end
192
+ end
193
+
194
+ private def assert_property_type(name, *types)
195
+ raise ArgumentError, 'property name must not be nil' if name.nil?
196
+
197
+ property = property_by_name(name)
198
+ type = property.dump_class
199
+
200
+ raise ArgumentError, "#{name} must be #{types * ' or '}, but was #{type}" unless types.include?(type)
201
+ end
202
+
203
+ private def normalize_field(field)
204
+ assert_kind_of 'field', field, DataMapper::Query::Operator, Symbol, Property
205
+
206
+ case field
207
+ when DataMapper::Query::Operator
208
+ if field.target == :all
209
+ field
210
+ else
211
+ field.class.new(property_by_name(field.target), field.operator)
212
+ end
213
+
214
+ when Symbol
215
+ property_by_name(field)
216
+
217
+ when Property
218
+ field
219
+ end
220
+ end
221
+
222
+ private def direction_map
223
+ direction_map = {}
224
+ query.order.each do |direction|
225
+ direction_map[direction.target] = direction
226
+ end
227
+ direction_map
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,11 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module Model
4
+ include Functions
5
+
6
+ private def property_by_name(property_name)
7
+ properties(repository.name)[property_name]
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module Query
4
+ def self.included(base)
5
+ base.class_eval <<-RUBY, __FILE__, __LINE__ + 1
6
+ # FIXME: figure out a cleaner approach than AMC
7
+ alias_method :assert_valid_fields_without_operator, :assert_valid_fields
8
+ alias_method :assert_valid_fields, :assert_valid_fields_with_operator
9
+ RUBY
10
+ end
11
+
12
+ def assert_valid_fields_with_operator(fields, unique)
13
+ operators, fields = fields.partition { |f| f.is_a?(DataMapper::Query::Operator) }
14
+
15
+ operators.each do |operator|
16
+ target = operator.target
17
+
18
+ unless target == :all || @properties.include?(target)
19
+ raise ArgumentError, "+options[:fields]+ entry #{target.inspect} does not map to a property in #{model}"
20
+ end
21
+ end
22
+
23
+ assert_valid_fields_without_operator(fields, unique)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module Repository
4
+ def aggregate(query)
5
+ if query.valid?
6
+ adapter.aggregate(query)
7
+ else
8
+ []
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ module SymbolOperators
4
+ def count
5
+ DataMapper::Query::Operator.new(self, :count)
6
+ end
7
+
8
+ def min
9
+ DataMapper::Query::Operator.new(self, :min)
10
+ end
11
+
12
+ def max
13
+ DataMapper::Query::Operator.new(self, :max)
14
+ end
15
+
16
+ def avg
17
+ DataMapper::Query::Operator.new(self, :avg)
18
+ end
19
+
20
+ def sum
21
+ DataMapper::Query::Operator.new(self, :sum)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ module DataMapper
2
+ module Aggregates
3
+ VERSION = '1.3.0.beta'.freeze
4
+ end
5
+ end
@@ -0,0 +1,57 @@
1
+ require 'dm-core'
2
+
3
+ require 'dm-aggregates/aggregate_functions'
4
+ require 'dm-aggregates/collection'
5
+ require 'dm-aggregates/core_ext/symbol'
6
+ require 'dm-aggregates/model'
7
+ require 'dm-aggregates/query'
8
+ require 'dm-aggregates/repository'
9
+
10
+ module DataMapper
11
+ module Aggregates
12
+ def self.include_aggregate_api
13
+ %i(Repository Model Collection Query).each do |name|
14
+ DataMapper.const_get(name).send(:include, const_get(name))
15
+ end
16
+ Adapters::AbstractAdapter.descendants.each do |adapter_class|
17
+ Adapters.include_aggregate_api(DataMapper::Inflector.demodulize(adapter_class.name))
18
+ end
19
+ end
20
+ end
21
+
22
+ module Adapters
23
+ def self.include_aggregate_api(const_name)
24
+ require aggregate_extensions(const_name)
25
+ if Aggregates.const_defined?(const_name)
26
+ adapter = const_get(const_name)
27
+ adapter.send(:include, aggregate_module(const_name))
28
+ end
29
+ rescue LoadError
30
+ # Silently ignore the fact that no adapter extensions could be required
31
+ # This means that the adapter in use doesn't support aggregates
32
+ end
33
+
34
+ def self.aggregate_module(const_name)
35
+ Aggregates.const_get(const_name)
36
+ end
37
+
38
+ class << self
39
+ # @api private
40
+ private def aggregate_extensions(const_name)
41
+ name = adapter_name(const_name)
42
+ name = 'do' if name == 'dataobjects'
43
+ "dm-aggregates/adapters/dm-#{name}-adapter"
44
+ end
45
+ end
46
+
47
+ extendable do
48
+ # @api private
49
+ def const_added(const_name)
50
+ include_aggregate_api(const_name)
51
+ super
52
+ end
53
+ end
54
+ end
55
+
56
+ Aggregates.include_aggregate_api
57
+ end
@@ -0,0 +1,18 @@
1
+ require 'rspec'
2
+
3
+ require_relative '../isolated/require_spec'
4
+ require 'dm-core/spec/setup'
5
+
6
+ # To really test this behavior, this spec needs to be run in isolation and not
7
+ # as part of the typical rake spec run, which requires dm-aggregates upfront
8
+
9
+ if %w(postgres mysql sqlite oracle sqlserver).include?(ENV['ADAPTER'])
10
+ describe "require 'dm-aggregates after calling DataMapper.setup" do
11
+ before(:all) do
12
+ @adapter = DataMapper::Spec.adapter
13
+ require 'dm-aggregates'
14
+ end
15
+
16
+ it_behaves_like "require 'dm-aggregates'"
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ require 'rspec'
2
+
3
+ require_relative 'require_spec'
4
+ require 'dm-core/spec/setup'
5
+
6
+ # To really test this behavior, this spec needs to be run in isolation and not
7
+ # as part of the typical rake spec run, which requires dm-aggregates upfront
8
+
9
+ if %w(postgres mysql sqlite oracle sqlserver).include?(ENV['ADAPTER'])
10
+ describe "require 'dm-aggregates' before calling DataMapper.setup" do
11
+ before(:all) do
12
+ require 'dm-aggregates'
13
+ @adapter = DataMapper::Spec.adapter
14
+ end
15
+
16
+ it_behaves_like "require 'dm-aggregates'"
17
+ end
18
+ end
@@ -0,0 +1,11 @@
1
+ shared_examples "require 'dm-aggregates'" do
2
+ %w(Repository Model Collection Query).each do |name|
3
+ it "includes the aggregate api in DataMapper::#{name}" do
4
+ expect(DataMapper.const_get(name) < DataMapper::Aggregates.const_get(name)).to be(true)
5
+ end
6
+ end
7
+
8
+ it 'includes the aggregate api into the adapter' do
9
+ expect(@adapter.respond_to?(:aggregate)).to be(true)
10
+ end
11
+ end
@@ -0,0 +1,128 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe DataMapper::Collection do
4
+ supported_by :sqlite, :mysql, :postgres do
5
+ let(:dragons) { Dragon.all }
6
+ let(:countries) { Country.all }
7
+
8
+ it_behaves_like 'It Has Setup Resources'
9
+ it_behaves_like 'An Aggregatable Class'
10
+
11
+ describe 'ignore invalid query' do
12
+ let(:dragons) { Dragon.all.all(id: []) }
13
+
14
+ %i(size count).each do |method|
15
+ describe "##{method}" do
16
+ subject { dragons.send(method) }
17
+
18
+ it { is_expected.to eq 0 }
19
+ end
20
+ end
21
+
22
+ describe '#min' do
23
+ subject { dragons.min(:id) }
24
+
25
+ it { is_expected.to be_nil }
26
+ end
27
+
28
+ describe '#max' do
29
+ subject { dragons.max(:id) }
30
+
31
+ it { is_expected.to be_nil }
32
+ end
33
+
34
+ describe '#avg' do
35
+ subject { dragons.avg(:id) }
36
+
37
+ it { is_expected.to be_nil }
38
+ end
39
+
40
+ describe '#sum' do
41
+ subject { dragons.sum(:id) }
42
+
43
+ it { is_expected.to be_nil }
44
+ end
45
+
46
+ describe '#aggregate' do
47
+ subject { dragons.aggregate(:id) }
48
+
49
+ it { is_expected.to eq [] }
50
+ end
51
+ end
52
+
53
+ context 'with collections created with Set operations' do
54
+ let(:collection) { dragons.all(name: 'George') | dragons.all(name: 'Puff') }
55
+
56
+ describe '#size' do
57
+ subject { collection.size }
58
+
59
+ it { is_expected.to eq 2 }
60
+ end
61
+
62
+ describe '#count' do
63
+ subject { collection.count }
64
+
65
+ it { is_expected.to eq 2 }
66
+ end
67
+
68
+ describe '#min' do
69
+ subject { collection.min(:toes_on_claw) }
70
+
71
+ it { is_expected.to eq 3 }
72
+ end
73
+
74
+ describe '#max' do
75
+ subject { collection.max(:toes_on_claw) }
76
+
77
+ it { is_expected.to eq 4 }
78
+ end
79
+
80
+ describe '#avg' do
81
+ subject { collection.avg(:toes_on_claw) }
82
+
83
+ it { is_expected.to eq 3.5 }
84
+ end
85
+
86
+ describe '#sum' do
87
+ subject { collection.sum(:toes_on_claw) }
88
+
89
+ it { is_expected.to eq 7 }
90
+ end
91
+
92
+ describe '#aggregate' do
93
+ subject { collection.aggregate(:all.count, :name.count, :toes_on_claw.min, :toes_on_claw.max, :toes_on_claw.avg, :toes_on_claw.sum) }
94
+
95
+ it { is_expected.to eq [2, 2, 3, 4, 3.5, 7] }
96
+ end
97
+ end
98
+
99
+ context 'with a collection limited to 1 result' do
100
+ let(:dragons) { Dragon.all(limit: 1) }
101
+
102
+ describe '#size' do
103
+ subject { dragons.size }
104
+
105
+ it { is_expected.to eq 1 }
106
+ end
107
+
108
+ describe '#count' do
109
+ subject { dragons.count }
110
+
111
+ it {
112
+ pending 'TODO: make count apply to the limited collection. Currently limit applies after the count'
113
+ is_expected.to eq 1
114
+ }
115
+ end
116
+ end
117
+
118
+ context 'with the order reversed by the grouping field' do
119
+ subject { dragons.aggregate(:birth_at, :all.count) }
120
+
121
+ let(:dragons) { Dragon.all(order: [:birth_at.desc]) }
122
+
123
+ it 'displays the results in reverse order' do
124
+ is_expected.to eq Dragon.aggregate(:birth_at, :all.count).reverse
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,11 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe DataMapper::Model do
4
+ supported_by :sqlite, :mysql, :postgres do
5
+ let(:dragons) { Dragon }
6
+ let(:countries) { Country }
7
+
8
+ it_behaves_like 'It Has Setup Resources'
9
+ it_behaves_like 'An Aggregatable Class'
10
+ end
11
+ end