sleek 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/.rspec +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +256 -0
- data/Rakefile +6 -0
- data/lib/sleek/base.rb +52 -0
- data/lib/sleek/core_ext/range.rb +44 -0
- data/lib/sleek/core_ext/time.rb +2 -0
- data/lib/sleek/event.rb +37 -0
- data/lib/sleek/filter.rb +24 -0
- data/lib/sleek/interval.rb +41 -0
- data/lib/sleek/queries/average.rb +21 -0
- data/lib/sleek/queries/count.rb +17 -0
- data/lib/sleek/queries/count_unique.rb +21 -0
- data/lib/sleek/queries/maximum.rb +21 -0
- data/lib/sleek/queries/minimum.rb +21 -0
- data/lib/sleek/queries/query.rb +105 -0
- data/lib/sleek/queries/sum.rb +21 -0
- data/lib/sleek/queries/targetable.rb +13 -0
- data/lib/sleek/queries.rb +8 -0
- data/lib/sleek/query_collection.rb +25 -0
- data/lib/sleek/timeframe.rb +85 -0
- data/lib/sleek/version.rb +3 -0
- data/lib/sleek.rb +23 -0
- data/sleek.gemspec +28 -0
- data/sleek.png +0 -0
- data/spec/lib/sleek/base_spec.rb +48 -0
- data/spec/lib/sleek/event_spec.rb +21 -0
- data/spec/lib/sleek/filter_spec.rb +26 -0
- data/spec/lib/sleek/interval_spec.rb +24 -0
- data/spec/lib/sleek/queries/average_spec.rb +13 -0
- data/spec/lib/sleek/queries/count_spec.rb +13 -0
- data/spec/lib/sleek/queries/count_unique_spec.rb +15 -0
- data/spec/lib/sleek/queries/maximum_spec.rb +13 -0
- data/spec/lib/sleek/queries/minimum_spec.rb +13 -0
- data/spec/lib/sleek/queries/query_spec.rb +226 -0
- data/spec/lib/sleek/queries/sum_spec.rb +13 -0
- data/spec/lib/sleek/queries/targetable_spec.rb +29 -0
- data/spec/lib/sleek/query_collection_spec.rb +27 -0
- data/spec/lib/sleek/timeframe_spec.rb +86 -0
- data/spec/lib/sleek_spec.rb +10 -0
- data/spec/spec_helper.rb +17 -0
- metadata +203 -0
@@ -0,0 +1,105 @@
|
|
1
|
+
module Sleek
|
2
|
+
module Queries
|
3
|
+
class Query
|
4
|
+
attr_reader :namespace, :bucket, :options
|
5
|
+
|
6
|
+
# Internal: Initialize the query.
|
7
|
+
#
|
8
|
+
# namespace - the Symbol namespace name.
|
9
|
+
# bucket - the String bucket name.
|
10
|
+
# options - the optional Hash of options.
|
11
|
+
# :timeframe - the optional timeframe description.
|
12
|
+
# :interval - the optional interval description.
|
13
|
+
#
|
14
|
+
# Raises ArgumentError if passed options are invalid.
|
15
|
+
def initialize(namespace, bucket, options = {})
|
16
|
+
@namespace = namespace
|
17
|
+
@bucket = bucket
|
18
|
+
@options = options
|
19
|
+
|
20
|
+
raise ArgumentError, "options are invalid" unless valid_options?
|
21
|
+
end
|
22
|
+
|
23
|
+
# Internal: Get Mongoid::Criteria for events to perform the query.
|
24
|
+
#
|
25
|
+
# time_range - the optional range of Time objects.
|
26
|
+
def events(time_range = time_range)
|
27
|
+
evts = Event.where(namespace: namespace, bucket: bucket)
|
28
|
+
evts = evts.between("s.t" => time_range) if time_range
|
29
|
+
evts = apply_filters(evts) if filter?
|
30
|
+
evts
|
31
|
+
end
|
32
|
+
|
33
|
+
# Internal: Apply all the filters to the criteria.
|
34
|
+
def apply_filters(criteria)
|
35
|
+
filters.inject(criteria) { |crit, filter| filter.apply(crit) }
|
36
|
+
end
|
37
|
+
|
38
|
+
# Internal: Get filters.
|
39
|
+
def filters
|
40
|
+
filters = options[:filter]
|
41
|
+
|
42
|
+
if filters.is_a?(Array) && filters.size == 3 && filters.none? { |f| f.is_a?(Array) }
|
43
|
+
filters = [filters]
|
44
|
+
elsif !filters.is_a?(Array) || !filters.all? { |f| f.is_a?(Array) && f.size == 3 }
|
45
|
+
raise ArgumentError, "wrong filter - #{filters}"
|
46
|
+
end
|
47
|
+
|
48
|
+
filters.map { |f| Sleek::Filter.new(*f) }
|
49
|
+
end
|
50
|
+
|
51
|
+
# Internal: Get timeframe for the query.
|
52
|
+
#
|
53
|
+
# Returns nil if no timeframe was passed to initializer.
|
54
|
+
def timeframe
|
55
|
+
Sleek::Timeframe.new(options[:timeframe]) if timeframe?
|
56
|
+
end
|
57
|
+
|
58
|
+
# Internal: Get timeframe range.
|
59
|
+
def time_range
|
60
|
+
timeframe.try(:to_time_range)
|
61
|
+
end
|
62
|
+
|
63
|
+
def series
|
64
|
+
Sleek::Interval.new(options[:interval], timeframe).timeframes if series? && timeframe?
|
65
|
+
end
|
66
|
+
|
67
|
+
# Internal: Check if options include filter.
|
68
|
+
def filter?
|
69
|
+
options[:filter].present?
|
70
|
+
end
|
71
|
+
|
72
|
+
# Internal: Check if options include timeframe.
|
73
|
+
def timeframe?
|
74
|
+
options[:timeframe].present?
|
75
|
+
end
|
76
|
+
|
77
|
+
# Internal: Check if options include interval.
|
78
|
+
def series?
|
79
|
+
options[:interval].present?
|
80
|
+
end
|
81
|
+
|
82
|
+
# Internal: Run the query.
|
83
|
+
def run
|
84
|
+
if series?
|
85
|
+
series.map do |tf|
|
86
|
+
{ timeframe: tf, value: perform(events(tf.to_time_range)) }
|
87
|
+
end
|
88
|
+
else
|
89
|
+
perform(events)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Internal: Perform the query on a set of events.
|
94
|
+
def perform(events)
|
95
|
+
raise NotImplementedError
|
96
|
+
end
|
97
|
+
|
98
|
+
# Internal: Validate the options.
|
99
|
+
def valid_options?
|
100
|
+
options.is_a?(Hash) && (filter? ? options[:filter].is_a?(Array) : true) &&
|
101
|
+
(series? ? timeframe? && series : true)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Sleek
|
2
|
+
module Queries
|
3
|
+
# Internal: Sum query.
|
4
|
+
#
|
5
|
+
# Finds the average value for a given property.
|
6
|
+
#
|
7
|
+
# target_property - the String name of target property on event.
|
8
|
+
#
|
9
|
+
# Examples
|
10
|
+
#
|
11
|
+
# sleek.queries.sum(:purchases, target_property: "total")
|
12
|
+
# # => 2_072_70
|
13
|
+
class Sum < Query
|
14
|
+
include Targetable
|
15
|
+
|
16
|
+
def perform(events)
|
17
|
+
events.sum target_property
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
require 'sleek/queries/query'
|
2
|
+
require 'sleek/queries/targetable'
|
3
|
+
require 'sleek/queries/count'
|
4
|
+
require 'sleek/queries/count_unique'
|
5
|
+
require 'sleek/queries/minimum'
|
6
|
+
require 'sleek/queries/maximum'
|
7
|
+
require 'sleek/queries/average'
|
8
|
+
require 'sleek/queries/sum'
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Sleek
|
2
|
+
class QueryCollection
|
3
|
+
attr_reader :namespace
|
4
|
+
|
5
|
+
# Inernal: Initialize query collection.
|
6
|
+
#
|
7
|
+
# namespace - the Symbol namespace name.
|
8
|
+
def initialize(namespace)
|
9
|
+
@namespace = namespace
|
10
|
+
end
|
11
|
+
|
12
|
+
# Define method for each query
|
13
|
+
[:count, :count_unique, :minimum, :maximum, :sum, :average].each do |name|
|
14
|
+
klass = "Sleek::Queries::#{name.to_s.camelize}".constantize
|
15
|
+
|
16
|
+
define_method(name) do |bucket, options = {}|
|
17
|
+
klass.new(namespace, bucket, options).run
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def inspect
|
22
|
+
"#<Sleek::QueryCollection ns=#{namespace}>"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Sleek
|
2
|
+
class Timeframe
|
3
|
+
# Internal: Initialize timeframe.
|
4
|
+
#
|
5
|
+
# timeframe - either a range of Time objects, an array of two Time
|
6
|
+
# objects, or a special string.
|
7
|
+
#
|
8
|
+
# Possible special string format:
|
9
|
+
# (this|previous)_((\d+)_)?(minute|hour|day|week|month)s?
|
10
|
+
#
|
11
|
+
# Examples
|
12
|
+
#
|
13
|
+
# Timeframe.new "this_2_days"
|
14
|
+
#
|
15
|
+
# Timeframe.new "previous_hour"
|
16
|
+
def initialize(timeframe)
|
17
|
+
@timeframe = timeframe
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: Get timeframe start.
|
21
|
+
def start
|
22
|
+
to_time_range.begin
|
23
|
+
end
|
24
|
+
|
25
|
+
# Public: Get timeframe end.
|
26
|
+
def end
|
27
|
+
to_time_range.end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Internal: Convert Timeframe instance to a range of Time objects.
|
31
|
+
def to_time_range
|
32
|
+
@range ||= self.class.to_range(@timeframe)
|
33
|
+
end
|
34
|
+
|
35
|
+
def inspect
|
36
|
+
"#<Sleek::Timeframe #{to_time_range}>"
|
37
|
+
end
|
38
|
+
|
39
|
+
class << self
|
40
|
+
# Internal: Transform the object passed to Timeframe initializer
|
41
|
+
# into a range of Time objects.
|
42
|
+
#
|
43
|
+
# timeframe - either a Range of Time objects, a two-element array
|
44
|
+
# of Time Objects, or a special string.
|
45
|
+
#
|
46
|
+
# Raises ArgumentError if passed object can't be processed.
|
47
|
+
def to_range(timeframe)
|
48
|
+
case timeframe
|
49
|
+
when Range
|
50
|
+
t = timeframe if timeframe.time_range?
|
51
|
+
when Array
|
52
|
+
t = timeframe.first..timeframe.last if timeframe.size == 2 && timeframe.count { |tf| tf.is_a?(Time) } == 2
|
53
|
+
when String, Symbol
|
54
|
+
t = parse(timeframe.to_s)
|
55
|
+
end
|
56
|
+
|
57
|
+
raise ArgumentError, "wrong timeframe" unless t
|
58
|
+
t
|
59
|
+
end
|
60
|
+
|
61
|
+
# Internal: Process timeframe string to make up a range.
|
62
|
+
#
|
63
|
+
# timeframe - the String matching
|
64
|
+
# (this|previous)_((\d+)_)?(minute|hour|day|week|month)s?
|
65
|
+
def parse(timeframe)
|
66
|
+
regexp = /(this|previous)_((\d+)_)?(minute|hour|day|week|month)s?/
|
67
|
+
_, category, _, number, interval = *timeframe.match(regexp)
|
68
|
+
|
69
|
+
unless category && interval
|
70
|
+
raise ArgumentError, "special timeframe string is malformed"
|
71
|
+
end
|
72
|
+
|
73
|
+
number ||= 1
|
74
|
+
number = number.to_i
|
75
|
+
|
76
|
+
end_point = Time.now.send("end_of_#{interval}").round
|
77
|
+
start_point = end_point - number.send(interval)
|
78
|
+
|
79
|
+
range = start_point..end_point
|
80
|
+
range = range - 1.send(interval) if category == 'previous'
|
81
|
+
range
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
data/lib/sleek.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'mongoid'
|
2
|
+
|
3
|
+
require 'sleek/core_ext/range'
|
4
|
+
require 'sleek/core_ext/time'
|
5
|
+
|
6
|
+
require 'sleek/version'
|
7
|
+
require 'sleek/timeframe'
|
8
|
+
require 'sleek/interval'
|
9
|
+
require 'sleek/filter'
|
10
|
+
require 'sleek/event'
|
11
|
+
require 'sleek/queries'
|
12
|
+
require 'sleek/query_collection'
|
13
|
+
require 'sleek/base'
|
14
|
+
|
15
|
+
module Sleek
|
16
|
+
def self.for_namespace(namespace)
|
17
|
+
Base.new namespace
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.[](namespace)
|
21
|
+
for_namespace(namespace)
|
22
|
+
end
|
23
|
+
end
|
data/sleek.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'sleek/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "sleek"
|
8
|
+
spec.version = Sleek::VERSION
|
9
|
+
spec.authors = ["Gosha Arinich"]
|
10
|
+
spec.email = ["me@goshakkk.name"]
|
11
|
+
spec.description = %q{Sleek is a library for doing analytics.}
|
12
|
+
spec.summary = %q{Sleek is a library for doing analytics.}
|
13
|
+
spec.homepage = "http://github.com/goshakkk/sleek"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency 'mongoid', '~> 3.1'
|
22
|
+
spec.add_runtime_dependency 'activesupport', '~> 3.2'
|
23
|
+
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
25
|
+
spec.add_development_dependency "rake"
|
26
|
+
spec.add_development_dependency 'rspec', '~> 2.13'
|
27
|
+
spec.add_development_dependency 'database_cleaner', '~> 0.9'
|
28
|
+
end
|
data/sleek.png
ADDED
Binary file
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Base do
|
4
|
+
subject(:sleek) { Sleek::Base.new(:default) }
|
5
|
+
|
6
|
+
describe "#initialize" do
|
7
|
+
it "sets the namespace" do
|
8
|
+
sleek = Sleek::Base.new(:my_namespace)
|
9
|
+
expect(sleek.namespace).to eq :my_namespace
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "#record" do
|
14
|
+
it "creates an event record" do
|
15
|
+
data = { name: 'John Doe', email: 'j@d.com' }
|
16
|
+
|
17
|
+
Sleek::Event.should_receive(:create_with_namespace).with(:default, "signups", data)
|
18
|
+
|
19
|
+
sleek.record("signups", data)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe "#queries" do
|
24
|
+
it "returns QueryCollection for current namespace" do
|
25
|
+
Sleek::QueryCollection.should_receive(:new).with(:default).and_call_original
|
26
|
+
qc = sleek.queries
|
27
|
+
expect(qc.namespace).to eq :default
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe "#delete_bucket" do
|
32
|
+
it "deletes everything from the bucket" do
|
33
|
+
events = stub('events')
|
34
|
+
sleek.should_receive(:events).with(:abc).and_return(events)
|
35
|
+
events.should_receive(:delete_all)
|
36
|
+
sleek.delete_bucket(:abc)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe "#delete_property" do
|
41
|
+
it "it deletes the property from all events in bucket" do
|
42
|
+
events = stub('events')
|
43
|
+
sleek.should_receive(:events).with(:abc).and_return(events)
|
44
|
+
events.should_receive(:unset).with("d.test")
|
45
|
+
sleek.delete_property(:abc, "test")
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Event do
|
4
|
+
describe ".create_with_namespace" do
|
5
|
+
it "creates event record" do
|
6
|
+
expect { Sleek::Event.create_with_namespace(:default, "signups", { name: 'John Doe', email: 'john@doe.com' }) }.to change { Sleek::Event.count }.by(1)
|
7
|
+
end
|
8
|
+
|
9
|
+
it "sets namespace and bucket" do
|
10
|
+
evt = Sleek::Event.create_with_namespace(:default, "signups", {})
|
11
|
+
expect(evt.namespace).to eq :default
|
12
|
+
expect(evt.bucket).to eq "signups"
|
13
|
+
end
|
14
|
+
|
15
|
+
it "sets the data" do
|
16
|
+
data = { name: 'John Doe', email: 'j@d.com' }
|
17
|
+
evt = Sleek::Event.create_with_namespace(:ns, "buc", data)
|
18
|
+
expect(evt.data).to eq data
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Filter do
|
4
|
+
describe "#initialize" do
|
5
|
+
context "when valid operator is passed" do
|
6
|
+
it "does not raise an exception" do
|
7
|
+
expect { Sleek::Filter.new(:test, :eq, 1) }.to_not raise_exception ArgumentError
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
context "when invalid operator is passed" do
|
12
|
+
it "raises an exception" do
|
13
|
+
expect { Sleek::Filter.new(:test, :lol, 1) }.to raise_exception ArgumentError, "unsupported operator - lol"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "#apply" do
|
19
|
+
it "appleis the filter to the criteria" do
|
20
|
+
filter = Sleek::Filter.new(:test, :eq, 1)
|
21
|
+
criteria = stub('criteria')
|
22
|
+
criteria.should_receive(:eq).with("d.test" => 1)
|
23
|
+
filter.apply(criteria)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Interval do
|
4
|
+
describe "#initialize" do
|
5
|
+
it "transforms interval description into value" do
|
6
|
+
Sleek::Interval.should_receive(:interval_value).with(:hourly)
|
7
|
+
Sleek::Interval.new(:hourly, Sleek::Timeframe.new(Time.now.all_day))
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
describe "#timeframes" do
|
12
|
+
it "slices timeframe into interval-long timeframes" do
|
13
|
+
bd = Time.now.beginning_of_day
|
14
|
+
interval = Sleek::Interval.new(:hourly, Sleek::Timeframe.new(Time.now.all_day))
|
15
|
+
expect(interval.timeframes.count).to eq 23
|
16
|
+
|
17
|
+
23.times do |i|
|
18
|
+
subtf = interval.timeframes[i]
|
19
|
+
expect(subtf.start).to eq bd + 1.hour * i
|
20
|
+
expect(subtf.end).to eq bd + 1.hour * (i + 1)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Queries::Average do
|
4
|
+
subject(:query) { Sleek::Queries::Average.new(:default, :purchases, target_property: "total") }
|
5
|
+
|
6
|
+
describe "#perform" do
|
7
|
+
it "counts the events" do
|
8
|
+
events = stub('events')
|
9
|
+
events.should_receive(:avg).with("d.total").and_return(49_35)
|
10
|
+
expect(query.perform(events)).to eq 49_35
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Queries::Count do
|
4
|
+
subject(:query) { Sleek::Queries::Count.new(:default, :purchases) }
|
5
|
+
|
6
|
+
describe "#perform" do
|
7
|
+
it "counts the events" do
|
8
|
+
events = stub('events')
|
9
|
+
events.should_receive(:count).and_return(42)
|
10
|
+
expect(query.perform(events)).to eq 42
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Queries::CountUnique do
|
4
|
+
subject(:query) { Sleek::Queries::CountUnique.new(:default, :purchases, target_property: "customer.id") }
|
5
|
+
|
6
|
+
describe "#perform" do
|
7
|
+
it "counts the events" do
|
8
|
+
events = stub('events')
|
9
|
+
distinct_events = stub('distinct_events')
|
10
|
+
events.should_receive(:distinct).with("d.customer.id").and_return(distinct_events)
|
11
|
+
distinct_events.should_receive(:count).and_return(4)
|
12
|
+
expect(query.perform(events)).to eq 4
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Queries::Maximum do
|
4
|
+
subject(:query) { Sleek::Queries::Maximum.new(:default, :purchases, target_property: "total") }
|
5
|
+
|
6
|
+
describe "#perform" do
|
7
|
+
it "counts the events" do
|
8
|
+
events = stub('events')
|
9
|
+
events.should_receive(:max).with("d.total").and_return(199_99)
|
10
|
+
expect(query.perform(events)).to eq 199_99
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Sleek::Queries::Minimum do
|
4
|
+
subject(:query) { Sleek::Queries::Minimum.new(:default, :purchases, target_property: "total") }
|
5
|
+
|
6
|
+
describe "#perform" do
|
7
|
+
it "counts the events" do
|
8
|
+
events = stub('events')
|
9
|
+
events.should_receive(:min).with("d.total").and_return(19_99)
|
10
|
+
expect(query.perform(events)).to eq 19_99
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|