sleek 0.0.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.
- 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
|