aggrobot 0.0.2 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +8 -8
- data/Gemfile +3 -0
- data/README.md +11 -1
- data/aggrobot.gemspec +10 -2
- data/aggrobot_test +0 -0
- data/asmemory +0 -0
- data/lib/aggrobot.rb +7 -3
- data/lib/aggrobot/aggregator.rb +18 -5
- data/lib/aggrobot/aggrobot.rb +24 -13
- data/lib/aggrobot/errors.rb +6 -0
- data/lib/aggrobot/query_planner.rb +8 -19
- data/lib/aggrobot/query_planner/agg.rb +28 -0
- data/lib/aggrobot/query_planner/bucketed_groups_query_planner.rb +18 -15
- data/lib/aggrobot/query_planner/default_query_planner.rb +27 -8
- data/lib/aggrobot/query_planner/group_limit_query_planner.rb +30 -10
- data/lib/aggrobot/query_planner/parameters_validator.rb +33 -0
- data/lib/aggrobot/sql_functions.rb +23 -50
- data/lib/aggrobot/sql_functions/common.rb +65 -0
- data/lib/aggrobot/sql_functions/mysql.rb +6 -0
- data/lib/aggrobot/sql_functions/pgsql.rb +6 -0
- data/lib/aggrobot/sql_functions/sqlite.rb +6 -0
- data/lib/aggrobot/version.rb +1 -1
- data/pbcopy +1 -0
- data/spec/factories.rb +0 -0
- data/spec/factories/users.rb +4 -0
- data/spec/spec_helper.rb +49 -7
- data/spec/support/factory_robot/distribution_evaluator.rb +44 -0
- data/spec/support/factory_robot/factory_robot.rb +108 -0
- data/spec/support/user.rb +2 -0
- data/spec/unit/aggrobot/query_planners/bucketed_groups_query_planner_spec.rb +42 -55
- data/spec/unit/aggrobot/query_planners/default_query_planner_spec.rb +45 -21
- data/spec/unit/aggrobot/query_planners/group_limit_query_planner_spec.rb +69 -70
- data/spec/unit/aggrobot/sql_functions_spec.rb +20 -19
- metadata +97 -7
@@ -3,21 +3,30 @@ module Aggrobot
|
|
3
3
|
class GroupLimitQueryPlanner < DefaultQueryPlanner
|
4
4
|
|
5
5
|
def initialize(collection, group, opts)
|
6
|
-
|
6
|
+
required_params = [:limit_to, :sort_by]
|
7
|
+
optional_params = [:always_include, :other_group, :order]
|
8
|
+
validate_options(opts, required_params, optional_params)
|
7
9
|
raise_error 'limit_to has to be a number' unless opts[:limit_to].is_a?(Fixnum)
|
8
10
|
super(collection, group)
|
9
11
|
@query_map = {}
|
10
12
|
process_top_groups_options(opts)
|
11
13
|
end
|
12
14
|
|
13
|
-
def sub_query(
|
14
|
-
|
15
|
+
def sub_query(group_value)
|
16
|
+
group_value == @other_group ? @collection.where.not(@top_groups_conditions) : @collection.where(group_condition(group_value))
|
15
17
|
end
|
16
18
|
|
17
19
|
def query_results(extra_cols = [])
|
18
20
|
return [] if collection_is_none?
|
19
|
-
|
20
|
-
|
21
|
+
top_group_results = if @group.is_a? Array
|
22
|
+
columns = @group + [SQLFunctions.count] + extra_cols
|
23
|
+
results_query.where(@top_groups_conditions).pluck(*columns).collect do |result_row|
|
24
|
+
[result_row[0..(@group.count - 1)]] + result_row[@group.count..-1]
|
25
|
+
end
|
26
|
+
else
|
27
|
+
columns = [@group, SQLFunctions.count] + extra_cols
|
28
|
+
results_query.where(@top_groups_conditions).pluck(*columns)
|
29
|
+
end
|
21
30
|
top_group_results + other_group_results(columns)
|
22
31
|
end
|
23
32
|
|
@@ -25,7 +34,7 @@ module Aggrobot
|
|
25
34
|
|
26
35
|
def other_group_results(columns)
|
27
36
|
if @other_group
|
28
|
-
columns[0] =
|
37
|
+
columns[0] = SQLFunctions.sanitize(@other_group)
|
29
38
|
@collection.where.not(@top_groups_conditions).group(columns[0]).pluck(*columns)
|
30
39
|
else
|
31
40
|
[]
|
@@ -36,21 +45,32 @@ module Aggrobot
|
|
36
45
|
@results_query ||= @collection.group(@group)
|
37
46
|
end
|
38
47
|
|
39
|
-
def
|
40
|
-
@collection.group(@group).order("#{opts[:sort_by]} #{opts[:order]}").limit(opts[:limit_to]).pluck(
|
48
|
+
def get_top_groups(opts)
|
49
|
+
@collection.group(@group).order("#{opts[:sort_by]} #{opts[:order]}").limit(opts[:limit_to]).pluck(*@group)
|
41
50
|
end
|
42
51
|
|
43
52
|
def process_top_groups_options(opts)
|
44
53
|
opts[:order] ||= 'desc'
|
45
|
-
top_groups =
|
54
|
+
top_groups = get_top_groups(opts)
|
46
55
|
if opts[:always_include] && !top_groups.include?(opts[:always_include])
|
47
56
|
top_groups.pop
|
48
57
|
top_groups << opts[:always_include]
|
49
58
|
end
|
50
|
-
|
59
|
+
calculate_top_groups_conditions(top_groups)
|
51
60
|
@other_group = opts[:other_group]
|
52
61
|
end
|
53
62
|
|
63
|
+
def calculate_top_groups_conditions(top_groups)
|
64
|
+
if @group.is_a?(Array)
|
65
|
+
@top_groups_conditions = {}
|
66
|
+
@group.each_with_index do |group, idx|
|
67
|
+
@top_groups_conditions[group] = top_groups.collect{|v| v[idx]}
|
68
|
+
end
|
69
|
+
else
|
70
|
+
@top_groups_conditions = {@group => top_groups}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
54
74
|
end
|
55
75
|
end
|
56
76
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Aggrobot
|
2
|
+
module QueryPlanner
|
3
|
+
module ParametersValidator
|
4
|
+
def validate_and_extract_relation(collection)
|
5
|
+
if !collection.is_a?(ActiveRecord::Relation) && (collection < ActiveRecord::Base rescue false)
|
6
|
+
collection.unscoped
|
7
|
+
elsif collection.is_a?(ActiveRecord::Relation)
|
8
|
+
collection
|
9
|
+
else
|
10
|
+
raise ArgumentError.new 'Only ActiveRecord Models and Relations can be used in Aggrobot'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def validate_options(opts, required_parameters, optional_parameters)
|
15
|
+
params = opts.keys
|
16
|
+
# raise errors for required parameters
|
17
|
+
raise_opts_error(opts, required_parameters, optional_parameters) unless (required_parameters - params).empty?
|
18
|
+
# raise errors if any extra arguments given
|
19
|
+
raise_opts_error(opts, required_parameters, optional_parameters) unless (params - required_parameters - optional_parameters).empty?
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def raise_opts_error(opts, required_parameters, optional_parameters)
|
25
|
+
raise ArgumentError, <<-ERR
|
26
|
+
Wrong arguments given - #{opts}
|
27
|
+
Required parameters are #{required_parameters}
|
28
|
+
Optional parameters are #{optional_parameters}
|
29
|
+
ERR
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -1,54 +1,27 @@
|
|
1
|
-
|
2
|
-
module SqlFunctions
|
3
|
-
|
4
|
-
extend self
|
5
|
-
|
6
|
-
def sanitize(attr)
|
7
|
-
"'#{attr}'"
|
8
|
-
end
|
1
|
+
require_relative './sql_functions/common'
|
9
2
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
end
|
33
|
-
|
34
|
-
def avg(attr, rounding = ROUNDING_DIGITS)
|
35
|
-
"ROUND(AVG(#{attr}), #{rounding})"
|
36
|
-
end
|
37
|
-
|
38
|
-
def group_collect(attr)
|
39
|
-
"GROUP_CONCAT(DISTINCT #{attr})"
|
40
|
-
end
|
41
|
-
|
42
|
-
def percent(total, attr = count, rounding = ROUNDING_DIGITS)
|
43
|
-
total == 0 ? "0" : "ROUND((#{attr}*100.0)/#{total}, #{rounding})"
|
44
|
-
end
|
45
|
-
|
46
|
-
def multiply(attr, multiplier, rounding = ROUNDING_DIGITS)
|
47
|
-
"ROUND(#{attr}*#{multiplier}, #{rounding})"
|
48
|
-
end
|
49
|
-
|
50
|
-
def divide(attr, divider, rounding = ROUNDING_DIGITS)
|
51
|
-
"ROUND(#{attr}/#{divider}, #{rounding})"
|
3
|
+
module Aggrobot
|
4
|
+
module SQLFunctions
|
5
|
+
|
6
|
+
autoload :MySQL, File.expand_path('../sql_functions/mysql', __FILE__)
|
7
|
+
autoload :PgSQL, File.expand_path('../sql_functions/pgsql', __FILE__)
|
8
|
+
autoload :SQLite, File.expand_path('../sql_functions/sqlite', __FILE__)
|
9
|
+
|
10
|
+
POSTGRES_ADAPTER_NAME = 'PostgreSQL'
|
11
|
+
SQLITE_ADAPTER_NAME = 'SQLite'
|
12
|
+
MYSQL_ADAPTER_NAME = 'Mysql2'
|
13
|
+
|
14
|
+
def self.setup(precision = 2, adapter = ActiveRecord::Base.connection.adapter_name)
|
15
|
+
extend Common
|
16
|
+
self.precision = precision
|
17
|
+
adapter_module = case adapter
|
18
|
+
when POSTGRES_ADAPTER_NAME then PgSQL
|
19
|
+
when MYSQL_ADAPTER_NAME then MySQL
|
20
|
+
when SQLITE_ADAPTER_NAME then SQLite
|
21
|
+
else
|
22
|
+
raise Exception.new "Database adaptor not supported: #{ActiveRecord::Base.connection.adapter_name}"
|
23
|
+
end
|
24
|
+
extend adapter_module
|
52
25
|
end
|
53
26
|
|
54
27
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module Aggrobot
|
2
|
+
module SQLFunctions
|
3
|
+
module Common
|
4
|
+
delegate :sanitize, to: ActiveRecord::Base
|
5
|
+
mattr_accessor :precision
|
6
|
+
|
7
|
+
def desc(attr)
|
8
|
+
"#{attr} desc"
|
9
|
+
end
|
10
|
+
|
11
|
+
def asc(attr)
|
12
|
+
"#{attr} asc"
|
13
|
+
end
|
14
|
+
|
15
|
+
def count(attr = '*')
|
16
|
+
"COUNT(#{attr})"
|
17
|
+
end
|
18
|
+
|
19
|
+
def unique_count(attr = '*')
|
20
|
+
"COUNT(DISTINCT #{attr})"
|
21
|
+
end
|
22
|
+
|
23
|
+
def max(attr)
|
24
|
+
"MAX(#{attr})"
|
25
|
+
end
|
26
|
+
|
27
|
+
def min(attr)
|
28
|
+
"MIN(#{attr})"
|
29
|
+
end
|
30
|
+
|
31
|
+
def sum(attr = count)
|
32
|
+
"SUM(#{attr})"
|
33
|
+
end
|
34
|
+
|
35
|
+
# returns ROUNDED average of attr, with precision(ROUNDING DIGITS)
|
36
|
+
def avg(attr, rounding = self.precision)
|
37
|
+
"ROUND(AVG(#{attr}), #{rounding})"
|
38
|
+
end
|
39
|
+
|
40
|
+
alias average avg
|
41
|
+
|
42
|
+
# GROUP_CONCAT: A SQL function which returns a concatenated string
|
43
|
+
# group_collect returns concatenated string of distinct attr
|
44
|
+
def group_collect(attr)
|
45
|
+
"GROUP_CONCAT(DISTINCT #{attr})"
|
46
|
+
end
|
47
|
+
|
48
|
+
# returns percentage based on ROUND SQL function, with precision(ROUNDING DIGITS)
|
49
|
+
def percent(total, attr = count, rounding = self.precision)
|
50
|
+
total == 0 ? "0" : "ROUND((#{attr}*100.0)/#{total}, #{rounding})"
|
51
|
+
end
|
52
|
+
|
53
|
+
# returns ROUND of multipliers, with precision(self.precision)
|
54
|
+
def multiply(attr, multiplier, rounding = self.precision)
|
55
|
+
"ROUND(#{attr}*#{multiplier}, #{rounding})"
|
56
|
+
end
|
57
|
+
|
58
|
+
# returns ROUND of attr/divider, with precision(self.precision)
|
59
|
+
def divide(attr, divider, rounding = self.precision)
|
60
|
+
"ROUND(#{attr}/#{divider}, #{rounding})"
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
data/lib/aggrobot/version.rb
CHANGED
data/pbcopy
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
a
|
data/spec/factories.rb
ADDED
File without changes
|
data/spec/spec_helper.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# Notice there is a .rspec file in the root folder. It defines rspec arguments
|
2
2
|
|
3
|
+
# Set Rails environment as test
|
4
|
+
ENV['RAILS_ENV'] = 'test'
|
5
|
+
|
3
6
|
# Ruby 1.9 uses simplecov. The ENV['COVERAGE'] is set when rake coverage is run in ruby 1.9
|
4
7
|
if ENV['COVERAGE']
|
5
8
|
require 'simplecov'
|
@@ -7,12 +10,11 @@ if ENV['COVERAGE']
|
|
7
10
|
# Remove the spec folder from coverage. By default all code files are included. For more config options see
|
8
11
|
# https://github.com/colszowka/simplecov
|
9
12
|
add_filter File.expand_path('../../spec/', __FILE__)
|
10
|
-
coverage_dir(ENV['COVERAGE'] == 'integration' ? 'coverage/integration' :'coverage/unit')
|
11
|
-
|
13
|
+
coverage_dir(ENV['COVERAGE'] == 'integration' ? 'coverage/integration' : 'coverage/unit')
|
12
14
|
end
|
13
15
|
end
|
14
16
|
|
15
|
-
# Modify load path so you can require '
|
17
|
+
# Modify load path so you can require 'aggrobot' directly.
|
16
18
|
$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__))
|
17
19
|
|
18
20
|
require 'rubygems'
|
@@ -24,20 +26,60 @@ require 'bundler/setup'
|
|
24
26
|
Bundler.require(:default, :test)
|
25
27
|
|
26
28
|
require 'aggrobot'
|
27
|
-
|
29
|
+
require 'database_cleaner'
|
28
30
|
# Requires supporting ruby files with custom matchers and macros, etc,
|
29
31
|
# in spec/support/ and its subdirectories.
|
30
32
|
Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f }
|
31
33
|
|
32
|
-
|
33
|
-
|
34
|
-
|
34
|
+
begin
|
35
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3',
|
36
|
+
:database => ':memory:',
|
37
|
+
:min_messages => 'warning')
|
38
|
+
connection = ActiveRecord::Base.connection
|
39
|
+
connection.execute("SELECT 1")
|
40
|
+
rescue
|
41
|
+
at_exit do
|
42
|
+
puts "-" * 80
|
43
|
+
puts "Unable to connect to database. Make sure you the the necessary libraries for sqlite installed"
|
44
|
+
puts "-" * 80
|
45
|
+
end
|
46
|
+
raise $!
|
47
|
+
end
|
35
48
|
|
36
49
|
RSpec.configure do |config|
|
37
50
|
config.treat_symbols_as_metadata_keys_with_true_values = true
|
38
51
|
config.run_all_when_everything_filtered = true
|
39
52
|
config.filter_run :focus
|
40
53
|
|
54
|
+
config.include FactoryGirl::Syntax::Methods
|
55
|
+
|
56
|
+
connection = ActiveRecord::Base.connection
|
57
|
+
|
58
|
+
config.after(:all) do
|
59
|
+
User.delete_all
|
60
|
+
end
|
61
|
+
|
62
|
+
config.before(:suite) do
|
63
|
+
begin
|
64
|
+
|
65
|
+
connection.create_table :users do |t|
|
66
|
+
t.text :name
|
67
|
+
t.text :country
|
68
|
+
t.integer :age
|
69
|
+
t.text :gender
|
70
|
+
t.date :dob
|
71
|
+
t.integer :score
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
Aggrobot::SQLFunctions.setup
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
config.after(:suite) do
|
80
|
+
#connection.drop_table :users
|
81
|
+
end
|
82
|
+
|
41
83
|
# Run specs in random order to surface order dependencies. If you find an
|
42
84
|
# order dependency and want to debug it, you can fix the order by providing
|
43
85
|
# the seed, which is printed after each run.
|
@@ -0,0 +1,44 @@
|
|
1
|
+
class DistributionEvaluator
|
2
|
+
attr_reader :count, :value
|
3
|
+
|
4
|
+
def initialize(value = nil, &block)
|
5
|
+
@count = 0
|
6
|
+
@value = value || block
|
7
|
+
@evaluator_name = evaluator_name(@value)
|
8
|
+
end
|
9
|
+
|
10
|
+
def next_value
|
11
|
+
current_val = send @evaluator_name
|
12
|
+
@count += 1
|
13
|
+
current_val
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def evaluator_name(value)
|
19
|
+
case
|
20
|
+
when value.is_a?(Array)
|
21
|
+
:array_dist_value
|
22
|
+
when value.is_a?(Hash)
|
23
|
+
:hash_dist_value
|
24
|
+
when value.respond_to?(:call)
|
25
|
+
:block_dist_value
|
26
|
+
else
|
27
|
+
:value
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def array_dist_value
|
32
|
+
value[@count % value.size]
|
33
|
+
end
|
34
|
+
|
35
|
+
def block_dist_value
|
36
|
+
value.call(@count)
|
37
|
+
end
|
38
|
+
|
39
|
+
def hash_dist_value
|
40
|
+
idx = value[:rest] ? @count : @count % value.keys.last
|
41
|
+
matching_value = value.find{|k, _| k.is_a?(Integer) && k > idx }
|
42
|
+
(matching_value && matching_value.last) || value[:rest]
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require_relative './distribution_evaluator'
|
2
|
+
require 'rails'
|
3
|
+
require 'factory_girl'
|
4
|
+
|
5
|
+
class FactoryRobot
|
6
|
+
FactoryGirl.find_definitions
|
7
|
+
attr_reader :factory_name
|
8
|
+
attr_writer :distribution, :count
|
9
|
+
|
10
|
+
def self.start(factory, opts = {}, &block)
|
11
|
+
original_block_context = eval "self", block.binding
|
12
|
+
raise 'Block required for create factory method' unless block_given?
|
13
|
+
factory = new(factory, original_block_context, opts)
|
14
|
+
factory.instance_eval &block
|
15
|
+
factory.finalize
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(factory, caller_context, opts = {} )
|
19
|
+
@factory_name = factory
|
20
|
+
@factory = FactoryGirl.factory_by_name(@factory_name)
|
21
|
+
raise 'Factory not found for #{@factory_name}' unless @factory
|
22
|
+
@caller_context = caller_context
|
23
|
+
@distributions = {}
|
24
|
+
@foreach_blocks = []
|
25
|
+
create_factory_methods
|
26
|
+
opts = {
|
27
|
+
count: 1
|
28
|
+
}.merge(opts)
|
29
|
+
opts.each do |k,v|
|
30
|
+
send(k, v)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def method_missing(method, *args, &block)
|
35
|
+
@caller_context.send method, *args, &block
|
36
|
+
end
|
37
|
+
|
38
|
+
def finalize
|
39
|
+
records = []
|
40
|
+
@count.times do
|
41
|
+
record = FactoryGirl.create(factory_name)
|
42
|
+
distribute_values(record)
|
43
|
+
execute_foreach_blocks(record)
|
44
|
+
records << record
|
45
|
+
record.save
|
46
|
+
end
|
47
|
+
records
|
48
|
+
end
|
49
|
+
|
50
|
+
def evaluate(*args, &block)
|
51
|
+
instance_exec(*args, &block)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def create(*args, &block)
|
57
|
+
self.class.start(*args, &block)
|
58
|
+
end
|
59
|
+
|
60
|
+
def create_factory_methods
|
61
|
+
factory_cols.each do |method_name|
|
62
|
+
method_name = method_name.to_s
|
63
|
+
class_eval <<-__CODE__, __FILE__, __LINE__
|
64
|
+
def #{method_name}(val = nil)
|
65
|
+
if val
|
66
|
+
distribute :#{method_name}, val
|
67
|
+
else
|
68
|
+
@distributions[:#{method_name}] && @distributions[:#{method_name}][:src]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
__CODE__
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def count(val)
|
76
|
+
@count = val
|
77
|
+
end
|
78
|
+
|
79
|
+
def each_record(&block)
|
80
|
+
raise 'Block required for foreach method' unless block_given?
|
81
|
+
@foreach_blocks << block
|
82
|
+
end
|
83
|
+
|
84
|
+
def distribute(attr, vals = nil, &block)
|
85
|
+
raise 'Values (array/hash/object) or block required for attribute #{attr}' if vals.nil? && !block_given?
|
86
|
+
@distributions[attr] = {:src => vals, :value => block_given? ? DistributionEvaluator.new(vals, &block) : DistributionEvaluator.new(vals)}
|
87
|
+
end
|
88
|
+
|
89
|
+
def factory_cols
|
90
|
+
cols = @factory.build_class.column_names.clone
|
91
|
+
cols.delete(@factory.build_class.primary_key)
|
92
|
+
cols.concat(@factory.build_class.reflect_on_all_associations.collect(&:name))
|
93
|
+
end
|
94
|
+
|
95
|
+
def execute_foreach_blocks(record)
|
96
|
+
if @foreach_blocks
|
97
|
+
@foreach_blocks.each do |block|
|
98
|
+
instance_exec(record, &block)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def distribute_values(record)
|
104
|
+
@distributions.each do |attr, distribution|
|
105
|
+
record.send("#{attr}=", distribution[:value].next_value)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|