aggrobot 0.0.2 → 0.1.0
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.
- 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
|