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