aggrobot 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +8 -8
  2. data/Gemfile +3 -0
  3. data/README.md +11 -1
  4. data/aggrobot.gemspec +10 -2
  5. data/aggrobot_test +0 -0
  6. data/asmemory +0 -0
  7. data/lib/aggrobot.rb +7 -3
  8. data/lib/aggrobot/aggregator.rb +18 -5
  9. data/lib/aggrobot/aggrobot.rb +24 -13
  10. data/lib/aggrobot/errors.rb +6 -0
  11. data/lib/aggrobot/query_planner.rb +8 -19
  12. data/lib/aggrobot/query_planner/agg.rb +28 -0
  13. data/lib/aggrobot/query_planner/bucketed_groups_query_planner.rb +18 -15
  14. data/lib/aggrobot/query_planner/default_query_planner.rb +27 -8
  15. data/lib/aggrobot/query_planner/group_limit_query_planner.rb +30 -10
  16. data/lib/aggrobot/query_planner/parameters_validator.rb +33 -0
  17. data/lib/aggrobot/sql_functions.rb +23 -50
  18. data/lib/aggrobot/sql_functions/common.rb +65 -0
  19. data/lib/aggrobot/sql_functions/mysql.rb +6 -0
  20. data/lib/aggrobot/sql_functions/pgsql.rb +6 -0
  21. data/lib/aggrobot/sql_functions/sqlite.rb +6 -0
  22. data/lib/aggrobot/version.rb +1 -1
  23. data/pbcopy +1 -0
  24. data/spec/factories.rb +0 -0
  25. data/spec/factories/users.rb +4 -0
  26. data/spec/spec_helper.rb +49 -7
  27. data/spec/support/factory_robot/distribution_evaluator.rb +44 -0
  28. data/spec/support/factory_robot/factory_robot.rb +108 -0
  29. data/spec/support/user.rb +2 -0
  30. data/spec/unit/aggrobot/query_planners/bucketed_groups_query_planner_spec.rb +42 -55
  31. data/spec/unit/aggrobot/query_planners/default_query_planner_spec.rb +45 -21
  32. data/spec/unit/aggrobot/query_planners/group_limit_query_planner_spec.rb +69 -70
  33. data/spec/unit/aggrobot/sql_functions_spec.rb +20 -19
  34. metadata +97 -7
@@ -3,21 +3,30 @@ module Aggrobot
3
3
  class GroupLimitQueryPlanner < DefaultQueryPlanner
4
4
 
5
5
  def initialize(collection, group, opts)
6
- ParametersValidator.validate_options(opts, [:limit_to, :sort_by], [:always_include, :other_group, :order])
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(group_name)
14
- group_name == @other_group ? @collection.where.not(@top_groups_conditions) : @collection.where(@group => group_name)
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
- columns = [@group, SqlFunctions.count] + extra_cols
20
- top_group_results = results_query.where(@top_groups_conditions).pluck(*columns)
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] = SqlFunctions.sanitize(@other_group)
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 calculate_top_groups(opts)
40
- @collection.group(@group).order("#{opts[:sort_by]} #{opts[:order]}").limit(opts[:limit_to]).pluck(@group).flatten
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 = calculate_top_groups(opts)
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
- @top_groups_conditions = {@group => top_groups}
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
- module Aggrobot
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
- def desc(attr)
11
- "#{attr} desc"
12
- end
13
-
14
- def count(attr = '*')
15
- "COUNT(#{attr})"
16
- end
17
-
18
- def unique_count(attr = '*')
19
- "COUNT(DISTINCT #{attr})"
20
- end
21
-
22
- def max(attr)
23
- "MAX(#{attr})"
24
- end
25
-
26
- def min(attr)
27
- "MIN(#{attr})"
28
- end
29
-
30
- def sum(attr = count)
31
- "SUM(#{attr})"
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
@@ -0,0 +1,6 @@
1
+ module Aggrobot
2
+ module SQLFunctions
3
+ module MySQL
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Aggrobot
2
+ module SQLFunctions
3
+ module PgSQL
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Aggrobot
2
+ module SQLFunctions
3
+ module SQLite
4
+ end
5
+ end
6
+ end
@@ -1,3 +1,3 @@
1
1
  module Aggrobot
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  end
data/pbcopy ADDED
@@ -0,0 +1 @@
1
+ a
File without changes
@@ -0,0 +1,4 @@
1
+ FactoryGirl.define do
2
+ factory :users, :class => 'User' do
3
+ end
4
+ end
@@ -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 'mckinsey_external_ad' directly.
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
- # Set Rails environment as test
33
- ENV['RAILS_ENV'] = 'test'
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