sbf-dm-aggregates 1.3.0.beta

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.
@@ -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