├── .ruby-version ├── spec ├── dummy │ ├── log │ │ └── .keep │ ├── app │ │ ├── mailers │ │ │ └── .keep │ │ ├── models │ │ │ ├── .keep │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── post.rb │ │ │ ├── author.rb │ │ │ ├── comment.rb │ │ │ ├── post_report.rb │ │ │ └── data_builder.rb │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── javascripts │ │ │ │ └── application.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── application_controller.rb │ │ │ └── site_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ └── views │ │ │ ├── layouts │ │ │ └── application.html.erb │ │ │ └── site │ │ │ └── report.html.erb │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── db │ │ ├── seeds.rb │ │ ├── migrate │ │ │ └── 20150714202319_add_dummy_models.rb │ │ └── schema.rb │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ ├── rails │ │ └── setup │ ├── config.ru │ ├── config │ │ ├── initializers │ │ │ ├── cookies_serializer.rb │ │ │ ├── session_store.rb │ │ │ ├── mime_types.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── assets.rb │ │ │ ├── wrap_parameters.rb │ │ │ └── inflections.rb │ │ ├── environment.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── locales │ │ │ └── en.yml │ │ ├── secrets.yml │ │ ├── application.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── test.rb │ │ │ └── production.rb │ │ └── routes.rb │ ├── Rakefile │ └── README.rdoc ├── factories │ └── factories.rb ├── spec_helper.rb ├── repor │ ├── dimensions │ │ ├── bin_dimension │ │ │ ├── bin_table_spec.rb │ │ │ └── bin_spec.rb │ │ ├── category_dimension_spec.rb │ │ ├── number_dimension_spec.rb │ │ ├── bin_dimension_spec.rb │ │ ├── time_dimension_spec.rb │ │ └── base_dimension_spec.rb │ ├── serializers │ │ ├── table_serializer_spec.rb │ │ └── highcharts_serializer_spec.rb │ ├── aggregator_spec.rb │ └── report_spec.rb └── acceptance │ └── data_spec.rb ├── lib ├── repor │ ├── version.rb │ ├── invalid_params_error.rb │ ├── aggregators │ │ ├── avg_aggregator.rb │ │ ├── max_aggregator.rb │ │ ├── min_aggregator.rb │ │ ├── sum_aggregator.rb │ │ ├── count_aggregator.rb │ │ ├── array_aggregator.rb │ │ └── base_aggregator.rb │ ├── serializers │ │ ├── csv_serializer.rb │ │ ├── table_serializer.rb │ │ ├── base_serializer.rb │ │ ├── form_field_serializer.rb │ │ └── highcharts_serializer.rb │ ├── dimensions │ │ ├── category_dimension.rb │ │ ├── number_dimension.rb │ │ ├── bin_dimension │ │ │ ├── bin_table.rb │ │ │ └── bin.rb │ │ ├── time_dimension.rb │ │ ├── base_dimension.rb │ │ └── bin_dimension.rb │ └── report.rb ├── tasks │ └── repor_tasks.rake └── repor.rb ├── .gitignore ├── .travis.yml ├── Rakefile ├── Gemfile ├── MIT-LICENSE ├── repor.gemspec ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.3 2 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/db/seeds.rb: -------------------------------------------------------------------------------- 1 | DataBuilder.build! 2 | -------------------------------------------------------------------------------- /lib/repor/version.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /lib/repor/invalid_params_error.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | class InvalidParamsError < ArgumentError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ActiveRecord::Base 2 | belongs_to :author 3 | has_many :comments 4 | end 5 | -------------------------------------------------------------------------------- /lib/tasks/repor_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :repor do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/author.rb: -------------------------------------------------------------------------------- 1 | class Author < ActiveRecord::Base 2 | has_many :posts 3 | has_many :comments 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment < ActiveRecord::Base 2 | belongs_to :author 3 | belongs_to :post 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | spec/dummy/db/*.sqlite3 5 | spec/dummy/db/*.sqlite3-journal 6 | spec/dummy/log/*.log 7 | spec/dummy/tmp/ 8 | spec/dummy/.sass-cache 9 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_dummy_session' 4 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | script: 2 | - cd spec/dummy && RAILS_ENV=test bundle exec rake db:create db:schema:load && cd ../.. 3 | - bundle exec rspec 4 | language: ruby 5 | rvm: 6 | - 2.2 7 | env: 8 | - DB=mysql 9 | - DB=postgres 10 | - DB=sqlite 11 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /lib/repor/aggregators/avg_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class AvgAggregator < BaseAggregator 4 | def aggregation(groups) 5 | groups.select("AVG(#{expression}) AS #{sql_value_name}") 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/repor/aggregators/max_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class MaxAggregator < BaseAggregator 4 | def aggregation(groups) 5 | groups.select("MAX(#{expression}) AS #{sql_value_name}") 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/repor/aggregators/min_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class MinAggregator < BaseAggregator 4 | def aggregation(groups) 5 | groups.select("MIN(#{expression}) AS #{sql_value_name}") 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/repor/aggregators/sum_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class SumAggregator < BaseAggregator 4 | def aggregation(groups) 5 | groups.select("SUM(#{expression}) AS #{sql_value_name}") 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 6 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /lib/repor/aggregators/count_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class CountAggregator < BaseAggregator 4 | def aggregation(groups) 5 | groups.select("COUNT(DISTINCT #{report.table_name}.id) AS #{sql_value_name}") 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/site_controller.rb: -------------------------------------------------------------------------------- 1 | class SiteController < ApplicationController 2 | def report 3 | @report = PostReport.new(params.fetch(:post_report, {})) 4 | @csv = Repor::Serializers::CsvSerializer.new(@report) 5 | 6 | respond_to do |format| 7 | format.html 8 | format.csv { send_data(csv.csv_text, filename: csv.filename) } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/repor/aggregators/array_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class ArrayAggregator < BaseAggregator 4 | def aggregation(groups) 5 | unless Repor.database_type == :postgres 6 | fail InvalidParamsError, "array agg is only supported in Postgres" 7 | end 8 | 9 | groups.select("ARRAY_AGG(#{expression}) AS #{sql_value_name}") 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/factories/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :author do 3 | name { Faker::Name.name } 4 | end 5 | 6 | [:post, :comment].each do |type| 7 | factory type do 8 | transient { author nil } 9 | 10 | before(:create) do |record, evaluator| 11 | if evaluator.author 12 | author = Author.find_or_create_by(name: evaluator.author) 13 | record.author_id = author.id 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/repor/serializers/csv_serializer.rb: -------------------------------------------------------------------------------- 1 | require 'csv' 2 | 3 | module Repor 4 | module Serializers 5 | class CsvSerializer < TableSerializer 6 | def csv_text 7 | CSV.generate do |csv| 8 | csv << headers 9 | each_row { |row| csv << row } 10 | end 11 | end 12 | 13 | def save(filename = self.filename) 14 | File.open(filename, 'w') { |f| f.write data } 15 | end 16 | 17 | def filename 18 | "#{caption.parameterize}.csv" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: &sqlite 2 | adapter: sqlite3 3 | 4 | mysql: &mysql 5 | adapter: mysql2 6 | 7 | postgres: &postgres 8 | adapter: postgresql 9 | 10 | defaults: &defaults 11 | pool: 5 12 | timeout: 5000 13 | host: localhost 14 | <<: *<%= ENV['DB'] || 'postgres' %> 15 | 16 | development: 17 | database: <%= ENV['DB'] == 'sqlite' ? 'db/development.sqlite3' : 'repor_development' %> 18 | <<: *defaults 19 | 20 | test: 21 | database: <%= ENV['DB'] == 'sqlite' ? 'db/test.sqlite3' : 'repor_test' %> 22 | <<: *defaults 23 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | require 'rdoc/task' 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = 'rdoc' 11 | rdoc.title = 'Repor' 12 | rdoc.options << '--line-numbers' 13 | rdoc.rdoc_files.include('README.rdoc') 14 | rdoc.rdoc_files.include('lib/**/*.rb') 15 | end 16 | 17 | Bundler::GemHelper.install_tasks 18 | 19 | require 'rspec/core/rake_task' 20 | 21 | RSpec::Core::RakeTask.new(:spec) 22 | 23 | task :default => :spec 24 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/README.rdoc: -------------------------------------------------------------------------------- 1 | == README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | 26 | 27 | Please feel free to use a different markup language if you do not plan to run 28 | rake doc:app. 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Declare your gem's dependencies in repor.gemspec. 4 | # Bundler will treat runtime dependencies like base dependencies, and 5 | # development dependencies will be added by default to the :development group. 6 | gemspec 7 | 8 | # Declare any dependencies that are still in development here instead of in 9 | # your gemspec. These might include edge Rails or gems from your path or 10 | # Git. Remember to move these dependencies to your gemspec before releasing 11 | # your gem to rubygems.org. 12 | 13 | # To use a debugger 14 | # gem 'byebug', group: [:development, :test] 15 | 16 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 7 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 8 | 9 | 10 | <%= csrf_meta_tags %> 11 | 12 | 13 | 14 | <%= yield %> 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20150714202319_add_dummy_models.rb: -------------------------------------------------------------------------------- 1 | class AddDummyModels < ActiveRecord::Migration 2 | def change 3 | create_table :posts, force: true do |t| 4 | t.timestamps 5 | t.string :title 6 | t.integer :author_id 7 | t.integer :likes, null: false, default: 0 8 | end 9 | 10 | create_table :comments, force: true do |t| 11 | t.timestamps 12 | t.integer :post_id 13 | t.integer :author_id 14 | t.integer :likes, null: false, default: 0 15 | end 16 | 17 | create_table :authors, force: true do |t| 18 | t.timestamps 19 | t.string :name 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/repor/serializers/table_serializer.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Serializers 3 | class TableSerializer < BaseSerializer 4 | def headers 5 | report.groupers.map(&method(:human_dimension_label)) + [human_aggregator_label(report.aggregator)] 6 | end 7 | 8 | def each_row 9 | return to_enum(__method__) unless block_given? 10 | 11 | report.flat_data.each do |xes, y| 12 | yield report.groupers.zip(xes).map { |d, v| human_dimension_value_label(d, v) } + [human_aggregator_value_label(report.aggregator, y)] 13 | end 14 | end 15 | 16 | def caption 17 | axis_summary 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] ||= "test" 3 | 4 | require File.expand_path("../../spec/dummy/config/environment.rb", __FILE__) 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../../spec/dummy/db/migrate", __FILE__)] 6 | require "rails/test_help" 7 | 8 | require 'rspec/rails' 9 | require 'factory_girl_rails' 10 | require 'pry' 11 | 12 | # Load support files 13 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 14 | 15 | ActiveRecord::Migration.check_pending! if defined?(ActiveRecord::Migration) 16 | 17 | RSpec.configure do |config| 18 | config.use_transactional_fixtures = true 19 | config.include FactoryGirl::Syntax::Methods 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/dummy/app/models/post_report.rb: -------------------------------------------------------------------------------- 1 | class PostReport < Repor::Report 2 | report_on :Post 3 | count_aggregator :count 4 | sum_aggregator :total_likes, expression: 'posts.likes' 5 | min_aggregator :min_likes, expression: 'posts.likes' 6 | min_aggregator :min_created_at, expression: 'posts.created_at' 7 | max_aggregator :max_likes, expression: 'posts.likes' 8 | max_aggregator :max_created_at, expression: 'posts.created_at' 9 | avg_aggregator :avg_likes, expression: 'posts.likes' 10 | array_aggregator :post_ids, expression: 'posts.id' 11 | category_dimension :author, expression: 'authors.name', relation: ->(r) { r.joins( 12 | "LEFT OUTER JOIN authors ON authors.id = posts.author_id" 13 | ) } 14 | number_dimension :likes 15 | time_dimension :created_at 16 | end 17 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /lib/repor/dimensions/category_dimension.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class CategoryDimension < BaseDimension 4 | def filter(relation) 5 | values = filter_values 6 | query = "#{expression} IN (?)" 7 | query = "#{expression} IS NULL OR #{query}" if values.include?(nil) 8 | relation.where(query, values.compact) 9 | end 10 | 11 | def group(relation) 12 | order relation.select("#{expression} AS #{sql_value_name}").group(sql_value_name) 13 | end 14 | 15 | def group_values 16 | if filtering? 17 | filter_values 18 | else 19 | i = report.groupers.index(self) 20 | report.raw_data.map { |x, _y| x[i] }.uniq 21 | end 22 | end 23 | 24 | def all_values 25 | relate(report.base_relation). 26 | pluck("DISTINCT #{expression}"). 27 | map(&method(:sanitize_sql_value)) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: e9d1dadd7da70b2ad66f1ffc7a2a035a005105d365117baea034b96ac2d6efe0aa308b7895646311cbcf67f9a68fcc13ca4b4025e2ded24d32fd991b72b09a7c 15 | 16 | test: 17 | secret_key_base: 54514b1da835ea5d5e14a55d1eba636c5b72453780282f514995c43337e49789b6df3caa0f9ba5006fb8f71c496dcb0ea82c0fd2706de5749da175652ac44b4b 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Andrew Ross 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | Bundler.require(*Rails.groups) 6 | require "repor" 7 | 8 | module Dummy 9 | class Application < Rails::Application 10 | # Settings in config/environments/* take precedence over those specified here. 11 | # Application configuration should go into files in config/initializers 12 | # -- all .rb files in that directory are automatically loaded. 13 | 14 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 15 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 16 | # config.time_zone = 'Central Time (US & Canada)' 17 | 18 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 19 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 20 | # config.i18n.default_locale = :de 21 | 22 | # Do not swallow errors in after_commit/after_rollback callbacks. 23 | config.active_record.raise_in_transactional_callbacks = true 24 | end 25 | end 26 | 27 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | body { margin: 2em; } 18 | fieldset { display: inline; } 19 | h2 { margin: 0; } 20 | form { margin-top: 1em; margin-bottom: 2em; } 21 | table { width: 100%; } 22 | select { margin-top: 2px; margin-bottom: 1px; } 23 | .repor-axis-fields { margin-bottom: 1em; } 24 | .repor-dimension-fields--number-dimension input { width: 5em; } 25 | .repor-dimension-fields--time-dimension input { width: 10em; } 26 | .repor-dimension-fields--time-dimension input:nth-child(4) { width: 5em; } 27 | -------------------------------------------------------------------------------- /repor.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | # Maintain your gem's version: 4 | require "repor/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "repor" 9 | s.version = Repor::VERSION 10 | s.authors = ["Andrew Ross"] 11 | s.email = ["andrewslavinross@gmail.com"] 12 | s.homepage = "http://github.com/asross/repor" 13 | s.summary = "Rails data aggregation framework" 14 | s.description = "Flexible but opinionated framework for defining and running reports on Rails models backed by SQL databases." 15 | s.license = "MIT" 16 | 17 | s.files = Dir["lib/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 18 | s.test_files = Dir["spec/**/*"] 19 | 20 | s.add_dependency "rails", "~> 4" 21 | 22 | s.add_development_dependency "pg", "~> 0.18" 23 | s.add_development_dependency "sqlite3", "~> 1.3" 24 | s.add_development_dependency "mysql2", "~> 0.3" 25 | s.add_development_dependency "rspec-rails", "~> 3" 26 | s.add_development_dependency "factory_girl_rails", "~> 4.5" 27 | s.add_development_dependency "database_cleaner", "~> 1.4" 28 | s.add_development_dependency "pry", "~> 0.10" 29 | s.add_development_dependency "faker", "~> 1.6" 30 | end 31 | -------------------------------------------------------------------------------- /lib/repor/dimensions/number_dimension.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class NumberDimension < BinDimension 4 | def validate_params! 5 | super 6 | 7 | if params.key?(:bin_width) 8 | unless Repor.numeric?(params[:bin_width]) 9 | invalid_param!(:bin_width, "must be numeric") 10 | end 11 | 12 | unless params[:bin_width].to_f > 0 13 | invalid_param!(:bin_width, "must be greater than 0") 14 | end 15 | end 16 | end 17 | 18 | def bin_width 19 | if params.key?(:bin_width) 20 | params[:bin_width].to_f 21 | elsif domain == 0 22 | 1 23 | elsif params.key?(:bin_count) 24 | domain / params[:bin_count].to_f 25 | else 26 | default_bin_width 27 | end 28 | end 29 | 30 | private 31 | 32 | def default_bin_width 33 | domain / default_bin_count.to_f 34 | end 35 | 36 | def default_bin_count 37 | 10 38 | end 39 | 40 | class Bin < BinDimension::Bin 41 | def parses?(value) 42 | Repor.numeric?(value) 43 | end 44 | 45 | def parse(value) 46 | value.to_f 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/repor/dimensions/bin_dimension/bin_table.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class BinDimension 4 | class BinTable < Array 5 | def initialize(values) 6 | super(values.compact) 7 | end 8 | 9 | def filter(relation, expr) 10 | relation.where(any_contain(expr)) 11 | end 12 | 13 | def group(relation, expr, value_name) 14 | name = "#{value_name}_bin_table" 15 | 16 | bin_join = <<-SQL 17 | INNER JOIN ( 18 | #{rows.join(" UNION\n ")} 19 | ) AS #{name} ON ( 20 | CASE 21 | WHEN #{name}.min IS NULL AND #{name}.max IS NULL THEN (#{expr} IS NULL) 22 | WHEN #{name}.min IS NULL THEN (#{expr} < #{name}.max) 23 | WHEN #{name}.max IS NULL THEN (#{expr} >= #{name}.min) 24 | ELSE ((#{expr} >= #{name}.min) AND (#{expr} < #{name}.max)) 25 | END 26 | ) 27 | SQL 28 | 29 | selection = "#{name}.bin_text AS #{value_name}" 30 | 31 | relation. 32 | joins(bin_join). 33 | select(selection). 34 | group(value_name) 35 | end 36 | 37 | def rows 38 | map(&:row_sql) 39 | end 40 | 41 | def any_contain(expr) 42 | map { |bin| bin.contains_sql(expr) }.join(' OR ') 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/repor.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | def self.database_type 3 | database_adapter_name = ActiveRecord::Base.connection_config[:adapter] 4 | case database_adapter_name 5 | when /postgres/ then :postgres 6 | when /mysql/ then :mysql 7 | when /sqlite/ then :sqlite 8 | else 9 | raise "unsupported database #{database_adapter_name}" 10 | end 11 | end 12 | 13 | def self.numeric?(value) 14 | value.is_a?(Numeric) || value.is_a?(String) && value =~ /\A\d+(?:\.\d+)?\z/ 15 | end 16 | end 17 | 18 | require 'repor/invalid_params_error' 19 | 20 | require 'repor/aggregators/base_aggregator' 21 | require 'repor/aggregators/count_aggregator' 22 | require 'repor/aggregators/avg_aggregator' 23 | require 'repor/aggregators/sum_aggregator' 24 | require 'repor/aggregators/min_aggregator' 25 | require 'repor/aggregators/max_aggregator' 26 | require 'repor/aggregators/array_aggregator' 27 | 28 | require 'repor/dimensions/base_dimension' 29 | require 'repor/dimensions/bin_dimension' 30 | require 'repor/dimensions/bin_dimension/bin' 31 | require 'repor/dimensions/bin_dimension/bin_table' 32 | require 'repor/dimensions/time_dimension' 33 | require 'repor/dimensions/number_dimension' 34 | require 'repor/dimensions/category_dimension' 35 | 36 | require 'repor/serializers/base_serializer' 37 | require 'repor/serializers/table_serializer' 38 | require 'repor/serializers/csv_serializer' 39 | require 'repor/serializers/form_field_serializer' 40 | require 'repor/serializers/highcharts_serializer' 41 | 42 | require 'repor/report' 43 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20150714202319) do 15 | 16 | create_table "authors", force: :cascade do |t| 17 | t.datetime "created_at" 18 | t.datetime "updated_at" 19 | t.string "name" 20 | end 21 | 22 | create_table "comments", force: :cascade do |t| 23 | t.datetime "created_at" 24 | t.datetime "updated_at" 25 | t.integer "post_id" 26 | t.integer "author_id" 27 | t.integer "likes", default: 0, null: false 28 | end 29 | 30 | create_table "posts", force: :cascade do |t| 31 | t.datetime "created_at" 32 | t.datetime "updated_at" 33 | t.string "title" 34 | t.integer "author_id" 35 | t.integer "likes", default: 0, null: false 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 31 | # yet still be able to expire them through the digest params. 32 | config.assets.digest = true 33 | 34 | # Adds additional error checking when serving assets at runtime. 35 | # Checks for improperly declared sprockets dependencies. 36 | # Raises helpful error messages. 37 | config.assets.raise_runtime_errors = true 38 | 39 | # Raises error for missing translations 40 | # config.action_view.raise_on_missing_translations = true 41 | end 42 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root 'site#report' 3 | # The priority is based upon order of creation: first created -> highest priority. 4 | # See how all your routes lay out with "rake routes". 5 | 6 | # You can have the root of your site routed with "root" 7 | # root 'welcome#index' 8 | 9 | # Example of regular route: 10 | # get 'products/:id' => 'catalog#view' 11 | 12 | # Example of named route that can be invoked with purchase_url(id: product.id) 13 | # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase 14 | 15 | # Example resource route (maps HTTP verbs to controller actions automatically): 16 | # resources :products 17 | 18 | # Example resource route with options: 19 | # resources :products do 20 | # member do 21 | # get 'short' 22 | # post 'toggle' 23 | # end 24 | # 25 | # collection do 26 | # get 'sold' 27 | # end 28 | # end 29 | 30 | # Example resource route with sub-resources: 31 | # resources :products do 32 | # resources :comments, :sales 33 | # resource :seller 34 | # end 35 | 36 | # Example resource route with more complex sub-resources: 37 | # resources :products do 38 | # resources :comments 39 | # resources :sales do 40 | # get 'recent', on: :collection 41 | # end 42 | # end 43 | 44 | # Example resource route with concerns: 45 | # concern :toggleable do 46 | # post 'toggle' 47 | # end 48 | # resources :posts, concerns: :toggleable 49 | # resources :photos, concerns: :toggleable 50 | 51 | # Example resource route within a namespace: 52 | # namespace :admin do 53 | # # Directs /admin/products/* to Admin::ProductsController 54 | # # (app/controllers/admin/products_controller.rb) 55 | # resources :products 56 | # end 57 | end 58 | -------------------------------------------------------------------------------- /spec/repor/dimensions/bin_dimension/bin_table_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::BinDimension::BinTable do 4 | let(:bin_class) { Repor::Dimensions::BinDimension::Bin } 5 | 6 | describe '#filter' do 7 | it 'ORs together predicates across bins' do 8 | table = described_class.new([ 9 | bin_class.new(nil, nil), 10 | bin_class.new(0, nil), 11 | bin_class.new(nil, 10), 12 | bin_class.new(3, 5) 13 | ]) 14 | 15 | sql = table.filter(Post, 'x').to_sql 16 | 17 | expect(sql).to include "WHERE (x IS NULL OR x >= 0 OR x < 10 OR (x >= 3 AND x < 5))" 18 | end 19 | end 20 | 21 | describe '#group' do 22 | it 'joins to a union of bin rows, then groups by the range' do 23 | table = described_class.new([ 24 | bin_class.new(nil, nil), 25 | bin_class.new(0, nil), 26 | bin_class.new(nil, 10), 27 | bin_class.new(3, 5) 28 | ]) 29 | 30 | sql = table.group(Post, 'likes', 'likes').to_sql 31 | 32 | expect(sql).to start_with "SELECT likes_bin_table.bin_text AS likes" 33 | 34 | if Repor.database_type == :mysql 35 | expect(sql).to include "SELECT NULL AS min, NULL AS max, ',' AS bin_text" 36 | expect(sql).to include "SELECT 0 AS min, NULL AS max, '0,' AS bin_text" 37 | expect(sql).to include "SELECT NULL AS min, 10 AS max, ',10' AS bin_text" 38 | expect(sql).to include "SELECT 3 AS min, 5 AS max, '3,5' AS bin_text" 39 | else 40 | expect(sql).to include "SELECT NULL AS min, NULL AS max, CAST(',' AS text) AS bin_text" 41 | expect(sql).to include "SELECT 0 AS min, NULL AS max, CAST('0,' AS text) AS bin_text" 42 | expect(sql).to include "SELECT NULL AS min, 10 AS max, CAST(',10' AS text) AS bin_text" 43 | expect(sql).to include "SELECT 3 AS min, 5 AS max, CAST('3,5' AS text) AS bin_text" 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Randomize the order test cases are executed. 35 | config.active_support.test_order = :random 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /lib/repor/aggregators/base_aggregator.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Aggregators 3 | class BaseAggregator 4 | attr_reader :name, :report, :opts 5 | 6 | def initialize(name, report, opts={}) 7 | @name = name 8 | @report = report 9 | @opts = opts 10 | end 11 | 12 | # This is the method called by Repor::Report. It should return a hash of 13 | # array keys (of grouper values) mapped to aggregation values. 14 | def aggregate(groups) 15 | query = aggregation(relate(groups)) 16 | result = ActiveRecord::Base.connection.select_all(query) 17 | result.cast_values.each_with_object(Hash.new(default_y_value)) do |values, h| 18 | row = result.columns.zip(values).to_h 19 | h[x_value_of(row)] = y_value_of(row) 20 | end 21 | end 22 | 23 | private 24 | 25 | # This is the method any aggregator must implement. It should return a 26 | # relation with the aggregator value SELECTed as the `sql_value_name`. 27 | def aggregation(groups) 28 | raise NotImplementedError 29 | end 30 | 31 | def sql_value_name 32 | "_report_aggregator_#{name}" 33 | end 34 | 35 | def x_value_of(row) 36 | report.groupers.map { |g| g.extract_sql_value(row) } 37 | end 38 | 39 | def y_value_of(row) 40 | row[sql_value_name] 41 | end 42 | 43 | def relate(groups) 44 | relation.call(groups) 45 | end 46 | 47 | def relation 48 | opts.fetch(:relation, ->(r) { r }) 49 | end 50 | 51 | def expression 52 | opts.fetch(:expression, "#{report.table_name}.#{name}") 53 | end 54 | 55 | # What value should be returned if there are no results for a certain key? 56 | # For count, that's clearly 0; for min/max, that may be less clear. 57 | def default_y_value 58 | opts.fetch(:default_value, 0) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/repor/serializers/table_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Serializers::TableSerializer do 4 | let(:report_class) do 5 | Class.new(Repor::Report) do 6 | report_on :Post 7 | number_dimension :likes 8 | time_dimension :created_at 9 | category_dimension :title 10 | count_aggregator :post_count 11 | end 12 | end 13 | 14 | let(:report) do 15 | report_class.new( 16 | aggregator: :post_count, 17 | groupers: %i[created_at likes title], 18 | dimensions: { 19 | created_at: { bin_width: '1 day' }, 20 | likes: { bin_width: 1 } 21 | } 22 | ) 23 | end 24 | 25 | let(:table) do 26 | Repor::Serializers::TableSerializer.new(report) 27 | end 28 | 29 | before do 30 | create(:post, created_at: '2016-01-01', likes: 2, title: 'A') 31 | create(:post, created_at: '2016-01-01', likes: 2, title: 'A') 32 | create(:post, created_at: '2016-01-01', likes: 1, title: 'B') 33 | create(:post, created_at: '2016-01-02', likes: 1, title: 'A') 34 | end 35 | 36 | describe '#headers' do 37 | it 'is a formatted list of groupers and the aggregator' do 38 | expect(table.headers).to eq ['Created at', 'Likes', 'Title', 'Post count'] 39 | end 40 | end 41 | 42 | describe '#caption' do 43 | it 'is a summary of the axes and the total record count' do 44 | expect(table.caption).to eq 'Post count by Created at, Likes, and Title for 4 Posts' 45 | end 46 | end 47 | 48 | describe '#each_row' do 49 | it 'iterates through arrays of formatted grouper values and the aggregator value' do 50 | expect(table.each_row.to_a).to eq [ 51 | ['2016-01-01', '[1.0, 2.0)', 'A', 0], 52 | ['2016-01-01', '[1.0, 2.0)', 'B', 1], 53 | ['2016-01-01', '[2.0, 3.0)', 'A', 2], 54 | ['2016-01-01', '[2.0, 3.0)', 'B', 0], 55 | ['2016-01-02', '[1.0, 2.0)', 'A', 1], 56 | ['2016-01-02', '[1.0, 2.0)', 'B', 0], 57 | ['2016-01-02', '[2.0, 3.0)', 'A', 0], 58 | ['2016-01-02', '[2.0, 3.0)', 'B', 0] 59 | ] 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/repor/aggregator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Aggregators do 4 | let(:report_class) do 5 | Class.new(Repor::Report) do 6 | report_on :Post 7 | category_dimension :author, expression: 'authors.name', relation: ->(r) { r.joins(:author) } 8 | count_aggregator :count 9 | sum_aggregator :total_likes, expression: 'posts.likes' 10 | avg_aggregator :mean_likes, expression: 'posts.likes' 11 | min_aggregator :min_likes, expression: 'posts.likes' 12 | max_aggregator :max_likes, expression: 'posts.likes' 13 | array_aggregator :post_ids, expression: 'posts.id' 14 | end 15 | end 16 | 17 | before do 18 | @p1 = create(:post, likes: 3, author: 'Alice') 19 | @p2 = create(:post, likes: 2, author: 'Alice') 20 | @p3 = create(:post, likes: 4, author: 'Bob') 21 | @p4 = create(:post, likes: 1, author: 'Bob') 22 | @p5 = create(:post, likes: 5, author: 'Bob') 23 | @p6 = create(:post, likes: 10, author: 'Chester') 24 | end 25 | 26 | def data_for(aggregator_name) 27 | report = report_class.new(aggregator: aggregator_name, groupers: [:author]) 28 | report.raw_data 29 | end 30 | 31 | specify 'array' do 32 | if Repor.database_type == :postgres 33 | expect(data_for(:post_ids)).to eq( 34 | %w(Alice) => [@p1.id, @p2.id], 35 | %w(Bob) => [@p3.id, @p4.id, @p5.id], 36 | %w(Chester) => [@p6.id] 37 | ) 38 | else 39 | expect { data_for(:post_ids) }.to raise_error(Repor::InvalidParamsError) 40 | end 41 | end 42 | 43 | specify 'max' do 44 | expect(data_for(:max_likes)).to eq %w(Alice) => 3, %w(Bob) => 5, %w(Chester) => 10 45 | end 46 | 47 | specify 'min' do 48 | expect(data_for(:min_likes)).to eq %w(Alice) => 2, %w(Bob) => 1, %w(Chester) => 10 49 | end 50 | 51 | specify 'avg' do 52 | d = data_for(:mean_likes) 53 | expect(d[%w(Alice)]).to eq 2.5 54 | expect(d[%w(Bob)].round(2)).to eq 3.33 55 | expect(d[%w(Chester)]).to eq 10 56 | end 57 | 58 | specify 'sum' do 59 | expect(data_for(:total_likes)).to eq %w(Alice) => 5, %w(Bob) => 10, %w(Chester) => 10 60 | end 61 | 62 | specify 'count' do 63 | expect(data_for(:count)).to eq %w(Alice) => 2, %w(Bob) => 3, %w(Chester) => 1 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/repor/dimensions/category_dimension_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::CategoryDimension do 4 | def author_dimension(report) 5 | described_class.new(:author, report, expression: 'authors.name', relation: ->(r) { r.joins( 6 | "LEFT OUTER JOIN authors ON authors.id = posts.author_id") }) 7 | end 8 | 9 | describe '#filter' do 10 | it 'filters to rows matching at least one value' do 11 | p1 = create(:post, author: 'Alice') 12 | p2 = create(:post, author: 'Bob') 13 | p3 = create(:post, author: nil) 14 | 15 | def filter_by(author_values) 16 | report = OpenStruct.new( 17 | table_name: 'posts', 18 | params: { dimensions: { author: { only: author_values } } } 19 | ) 20 | dimension = author_dimension(report) 21 | dimension.filter(dimension.relate(Post)) 22 | end 23 | 24 | expect(filter_by(['Alice'])).to eq [p1] 25 | expect(filter_by([nil])).to eq [p3] 26 | expect(filter_by(['Alice', nil])).to eq [p1, p3] 27 | expect(filter_by(['Alice', 'Bob'])).to eq [p1, p2] 28 | expect(filter_by([])).to eq [] 29 | end 30 | end 31 | 32 | describe '#group' do 33 | it 'groups the relation by the exact value of the SQL expression' do 34 | p1 = create(:post, author: 'Alice') 35 | p2 = create(:post, author: 'Alice') 36 | p3 = create(:post, author: nil) 37 | p4 = create(:post, author: 'Bob') 38 | p5 = create(:post, author: 'Bob') 39 | p6 = create(:post, author: 'Bob') 40 | 41 | report = OpenStruct.new(table_name: 'posts', params: {}) 42 | dimension = author_dimension(report) 43 | 44 | results = dimension.group(dimension.relate(Post)).select("COUNT(*) AS count").map do |r| 45 | r.attributes.values_at(dimension.send(:sql_value_name), 'count') 46 | end 47 | 48 | expect(results).to eq [[nil, 1], ['Alice', 2], ['Bob', 3]] 49 | end 50 | end 51 | 52 | describe '#group_values' do 53 | it 'echoes filter_values if filtering' do 54 | dimension = author_dimension(OpenStruct.new(params: { 55 | dimensions: { author: { only: ['foo', 'bar'] } } 56 | })) 57 | expect(dimension.group_values).to eq %w(foo bar) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/repor/dimensions/bin_dimension/bin_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::BinDimension::Bin do 4 | describe '.from_hash' do 5 | it 'builds a bin from a hash or nil' do 6 | bin = described_class.from_hash(min: 1, max: 2) 7 | expect(bin.min).to eq 1 8 | expect(bin.max).to eq 2 9 | 10 | bin = described_class.from_hash(nil) 11 | expect(bin.min).to eq nil 12 | expect(bin.max).to eq nil 13 | end 14 | end 15 | 16 | describe '.from_sql' do 17 | it 'builds a bin from a bin text string' do 18 | bin = described_class.from_sql("1,2") 19 | expect(bin.min).to eq '1' 20 | expect(bin.max).to eq '2' 21 | 22 | bin = described_class.from_sql("1,") 23 | expect(bin.min).to eq '1' 24 | expect(bin.max).to eq nil 25 | 26 | bin = described_class.from_sql(",2") 27 | expect(bin.min).to eq nil 28 | expect(bin.max).to eq '2' 29 | 30 | bin = described_class.from_sql(",") 31 | expect(bin.min).to eq nil 32 | expect(bin.max).to eq nil 33 | end 34 | end 35 | 36 | describe '#contains_sql' do 37 | it 'returns SQL checking if expr is in the bin' do 38 | bin = described_class.new(1, 2) 39 | expect(bin.contains_sql('foo')).to eq "(foo >= 1 AND foo < 2)" 40 | 41 | bin = described_class.new(1, nil) 42 | expect(bin.contains_sql('foo')).to eq "foo >= 1" 43 | 44 | bin = described_class.new(nil, 2) 45 | expect(bin.contains_sql('foo')).to eq "foo < 2" 46 | 47 | bin = described_class.new(nil, nil) 48 | expect(bin.contains_sql('foo')).to eq "foo IS NULL" 49 | end 50 | end 51 | 52 | describe '#to_json' do 53 | it 'reexpresses the bin as a hash' do 54 | bin = described_class.new(1, 2) 55 | json = { a: bin }.to_json 56 | expect(JSON.parse(json)).to eq('a' => { 'min' => 1, 'max' => 2 }) 57 | end 58 | end 59 | 60 | describe 'hashing' do 61 | it 'works with hashes' do 62 | bin1 = described_class.new(1, 2) 63 | bin2 = described_class.new(1, 2) 64 | bin3 = { min: 1, max: 2 } 65 | 66 | h = { bin3 => 'foo' } 67 | expect(h[bin1]).to eq 'foo' 68 | expect(h[bin2]).to eq 'foo' 69 | expect(h[bin3]).to eq 'foo' 70 | end 71 | 72 | it 'works with nil' do 73 | bin1 = described_class.new(nil, nil) 74 | bin2 = described_class.new(nil, nil) 75 | bin3 = nil 76 | 77 | h = { bin3 => 'foo' } 78 | expect(h[bin1]).to eq 'foo' 79 | expect(h[bin2]).to eq 'foo' 80 | expect(h[bin3]).to eq 'foo' 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/repor/dimensions/number_dimension_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::NumberDimension do 4 | def new_dimension(dimension_params = {}, report_params = {}, opts = {}) 5 | report_params[:dimensions] = { foo: dimension_params } 6 | Repor::Dimensions::NumberDimension.new( 7 | :foo, 8 | OpenStruct.new(params: report_params), 9 | opts 10 | ) 11 | end 12 | 13 | def expect_error(&block) 14 | expect { yield }.to raise_error(Repor::InvalidParamsError) 15 | end 16 | 17 | describe 'param validation' do 18 | it 'yells unless :bin_width is numeric' do 19 | expect_error { new_dimension(bin_width: '') } 20 | expect_error { new_dimension(bin_width: '49er') } 21 | expect_error { new_dimension(bin_width: { seconds: 1 }) } 22 | expect(new_dimension(bin_width: 10.5).bin_width).to eq 10.5 23 | expect(new_dimension(bin_width: '10').bin_width).to eq 10.0 24 | end 25 | end 26 | 27 | describe '#bin_width' do 28 | it 'reads from params' do 29 | dimension = new_dimension(bin_width: 7) 30 | expect(dimension.bin_width).to eq 7 31 | end 32 | 33 | it 'can divide the domain into :bin_count bins' do 34 | dimension = new_dimension(bin_count: 5, only: { min: 0, max: 5 }) 35 | expect(dimension.bin_width).to eq 1 36 | allow(dimension).to receive(:data_contains_nil?).and_return(false) 37 | expect(dimension.group_values).to eq [ 38 | { min: 0, max: 1 }, 39 | { min: 1, max: 2 }, 40 | { min: 2, max: 3 }, 41 | { min: 3, max: 4 }, 42 | { min: 4, max: 5 } 43 | ] 44 | end 45 | 46 | it 'can include nils if they are present in the data' do 47 | dimension = new_dimension(bin_count: 3, only: { min: 0, max: 3 }) 48 | allow(dimension).to receive(:data_contains_nil?).and_return(true) 49 | expect(dimension.group_values).to eq [ 50 | { min: nil, max: nil }, 51 | { min: 0, max: 1 }, 52 | { min: 1, max: 2 }, 53 | { min: 2, max: 3 } 54 | ] 55 | 56 | dimension = new_dimension(bin_count: 3, only: { min: 0, max: 3 }, nulls_last: true) 57 | allow(dimension).to receive(:data_contains_nil?).and_return(true) 58 | expect(dimension.group_values).to eq [ 59 | { min: 0, max: 1 }, 60 | { min: 1, max: 2 }, 61 | { min: 2, max: 3 }, 62 | { min: nil, max: nil } 63 | ] 64 | end 65 | 66 | it 'defaults to 10 equal bins' do 67 | dimension = new_dimension(only: { min: 0, max: 5 }) 68 | expect(dimension.bin_width).to eq 0.5 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/dummy/app/views/site/report.html.erb: -------------------------------------------------------------------------------- 1 |

Report Serializer Demo

2 | 3 |
4 |
5 |

Form

6 | <%= form_tag request.fullpath, method: 'get', id: 'report-form', autocomplete: 'off' do %> 7 | <%= Repor::Serializers::FormFieldSerializer.new(@report).html_fields %>
8 |
9 |
10 | <%= submit_tag 'Run report', class: 'pure-button pure-button-primary' %> 11 | <% end %> 12 | 13 |

CSV


14 | <%= link_to 'Download CSV', request.query_parameters.merge(format: 'csv'), class: 'pure-button pure-button-primary' %> 15 |
16 | 17 |
18 |

Chart

19 |
20 |
21 |
22 | 23 | <% table = Repor::Serializers::TableSerializer.new(@report) %> 24 |
25 |
26 |

Table

27 | 28 | 29 | 30 | 31 | <% table.headers.each do |th| %><% end %> 32 | 33 | 34 | <% table.each_row do |row| %> 35 | <% row.each do |td| %><% end %> 36 | <% end %> 37 | 38 |
<%= table.caption %>
<%= th %>
<%= td %>
39 |
40 |
41 | 42 | <% chart = Repor::Serializers::HighchartsSerializer.new(@report) %> 43 | 74 | -------------------------------------------------------------------------------- /spec/repor/dimensions/bin_dimension_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::BinDimension do 4 | def new_dimension(dimension_params = {}, report_params = {}, opts = {}) 5 | report_params[:dimensions] = { foo: dimension_params } 6 | Repor::Dimensions::BinDimension.new(:foo, 7 | OpenStruct.new(params: report_params), 8 | opts 9 | ) 10 | end 11 | 12 | def expect_error(&block) 13 | expect { yield }.to raise_error(Repor::InvalidParamsError) 14 | end 15 | 16 | describe 'param validation' do 17 | it 'yells unless :bin_count is numeric' do 18 | expect_error { new_dimension(bin_count: 'hey') } 19 | expect_error { new_dimension(bin_count: nil) } 20 | new_dimension(bin_count: 5) 21 | new_dimension(bin_count: 1.24) 22 | end 23 | end 24 | 25 | describe '#min/max' do 26 | it 'finds the extremes in filter_values' do 27 | dimension = new_dimension(only: [{ min: 1, max: 3 }, { min: -3 }, { min: 17, max: 40 }]) 28 | expect(dimension.min).to eq -3 29 | expect(dimension.max).to eq 40 30 | end 31 | 32 | it 'falls back to the smallest value in the data' do 33 | dimension = Repor::Dimensions::BinDimension.new(:likes, 34 | OpenStruct.new(records: Post, params: {}), 35 | expression: 'posts.likes' 36 | ) 37 | expect(dimension.min).to be_nil 38 | expect(dimension.max).to be_nil 39 | create(:post, likes: 3) 40 | create(:post, likes: 10) 41 | create(:post, likes: 1) 42 | expect(dimension.min).to eq 1 43 | expect(dimension.max).to eq 10 44 | end 45 | end 46 | 47 | describe '#group_values' do 48 | it 'defaults to dividing the domain into bins of bin_width' do 49 | dimension = new_dimension(only: { min: 0, max: 3 }) 50 | allow(dimension).to receive(:bin_width).and_return(1) 51 | allow(dimension).to receive(:data_contains_nil?).and_return(false) 52 | expect(dimension.group_values).to eq [ 53 | { min: 0, max: 1 }, 54 | { min: 1, max: 2 }, 55 | { min: 2, max: 3 } 56 | ] 57 | end 58 | 59 | it 'is inclusive of max if data-driven' do 60 | dimension = new_dimension(only: { min: 0 }) 61 | allow(dimension.report).to receive(:records).and_return(Post) 62 | allow(dimension).to receive(:expression).and_return('posts.likes') 63 | allow(dimension).to receive(:bin_width).and_return(1) 64 | create(:post, likes: 2) 65 | expect(dimension.group_values).to eq [ 66 | { min: 0, max: 1 }, 67 | { min: 1, max: 2 }, 68 | { min: 2, max: 3 } 69 | ] 70 | end 71 | 72 | it 'can be customized' do 73 | dimension = new_dimension(bins: { min: 0, max: 1 }) 74 | expect(dimension.group_values).to eq [{ min: 0, max: 1 }] 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/repor/dimensions/time_dimension_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::TimeDimension do 4 | def new_dimension(dimension_params = {}, report_params = {}, opts = {}) 5 | report_params[:dimensions] = { foo: dimension_params } 6 | Repor::Dimensions::TimeDimension.new( 7 | :foo, 8 | OpenStruct.new(params: report_params), 9 | opts 10 | ) 11 | end 12 | 13 | def expect_error(&block) 14 | expect { yield }.to raise_error(Repor::InvalidParamsError) 15 | end 16 | 17 | describe 'param validation' do 18 | it 'yells unless :bin_width is a duration hash' do 19 | expect_error { new_dimension(bin_width: '') } 20 | expect_error { new_dimension(bin_width: 5) } 21 | expect_error { new_dimension(bin_width: { seconds: 'hey' }) } 22 | expect_error { new_dimension(bin_width: { seconds: 1, chickens: 0 }) } 23 | new_dimension(bin_width: { seconds: 1, minutes: 2 }) 24 | new_dimension(bin_width: { weeks: 12, years: 7 }) 25 | end 26 | 27 | it 'yells unless :bins and :only values are times' do 28 | expect_error { new_dimension(bins: { min: 'hey' }) } 29 | expect_error { new_dimension(only: { min: 'hey' }) } 30 | expect_error { new_dimension(only: [{ min: '2015-01-01', max: '2015-01-10' }, { min: 'chicken' }]) } 31 | new_dimension(bins: { min: '2015-01-01', max: '2015-01-10' }) 32 | new_dimension(only: { min: '2015-01-01', max: '2015-01-10' }) 33 | new_dimension(only: [nil, { min: '2015-01-01', max: '2015-01-10' }, { max: '2015-02-10' }]) 34 | end 35 | end 36 | 37 | describe '#bin_width' do 38 | it 'can translate a duration hash into an ActiveSupport::Duration' do 39 | dimension = new_dimension(bin_width: { seconds: 10, minutes: 1 }) 40 | expect(dimension.bin_width).to eq 70.seconds 41 | dimension = new_dimension(bin_width: { days: 8, weeks: 1 }) 42 | expect(dimension.bin_width).to eq 15.days 43 | end 44 | 45 | it 'can divide the domain into :bin_count bins' do 46 | dimension = new_dimension(bin_count: 10, only: [{ min: '2015-01-01' }, { max: '2015-01-11' }]) 47 | allow(dimension).to receive(:data_contains_nil?).and_return(false) 48 | expect(dimension.bin_width).to eq 1.day 49 | expect(dimension.group_values.map(&:min).map(&:day)).to eq (1..10).to_a 50 | end 51 | 52 | it 'defaults to a sensical, standard duration' do 53 | dimension = new_dimension(only: [{ min: '2015-01-01' }, { max: '2015-01-02' }]) 54 | expect(dimension.bin_width).to eq 1.hour 55 | dimension = new_dimension(only: [{ min: '2015-01-01' }, { max: '2015-01-11' }]) 56 | expect(dimension.bin_width).to eq 1.day 57 | dimension = new_dimension(only: [{ min: '2015-01-01' }, { max: '2015-02-11' }]) 58 | expect(dimension.bin_width).to eq 1.week 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/repor/dimensions/time_dimension.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class TimeDimension < BinDimension 4 | STEPS = %i(seconds minutes hours days weeks months years) 5 | BIN_STEPS = (STEPS - [:seconds]).map { |s| s.to_s.singularize } 6 | DURATION_PATTERN = /\A\d+ (?:#{STEPS.map{|s| "#{s}?" }.join('|')})\z/ 7 | 8 | def validate_params! 9 | super 10 | 11 | if params.key?(:bin_width) && !valid_duration?(params[:bin_width]) 12 | invalid_param!(:bin_width, "must be a hash of one of #{STEPS} to an integer") 13 | end 14 | end 15 | 16 | def bin_width 17 | @bin_width ||= if params.key?(:bin_width) 18 | custom_bin_width 19 | elsif params.key?(:bin_count) && domain > 0 20 | (domain / params[:bin_count].to_f).seconds 21 | else 22 | default_bin_width 23 | end 24 | end 25 | 26 | def bin_start 27 | # ensure that each autogenerated bin represents a correctly aligned 28 | # day/week/month/year 29 | bin_start = super 30 | if bin_start.nil? 31 | nil 32 | elsif step = BIN_STEPS.detect { |step| bin_width == 1.send(step) } 33 | bin_start.send(:"beginning_of_#{step}") 34 | else 35 | bin_start 36 | end 37 | end 38 | 39 | private 40 | 41 | def custom_bin_width 42 | if params[:bin_width].is_a?(Hash) 43 | params[:bin_width].map { |step, n| n.send(step) }.sum 44 | elsif params[:bin_width].is_a?(String) 45 | n, step = params[:bin_width].split.map(&:strip) 46 | n.to_i.send(step) 47 | end 48 | end 49 | 50 | def valid_duration?(d) 51 | case d 52 | when Hash 53 | d.all? { |step, n| step.to_sym.in?(STEPS) && n.is_a?(Fixnum) } 54 | when String 55 | d =~ DURATION_PATTERN 56 | else 57 | false 58 | end 59 | end 60 | 61 | def default_bin_width 62 | case domain 63 | when 0 then 1.day 64 | when 0..1.minute then 1.second 65 | when 0..2.hours then 1.minute 66 | when 0..2.days then 1.hour 67 | when 0..2.weeks then 1.day 68 | when 0..2.months then 1.week 69 | when 0..2.years then 1.month 70 | else 1.year 71 | end 72 | end 73 | 74 | class Bin < BinDimension::Bin 75 | def parse(value) 76 | Time.zone.parse(value.to_s.gsub('"', '')) 77 | end 78 | 79 | def cast(value) 80 | case Repor.database_type 81 | when :postgres 82 | "CAST(#{super} AS timestamp with time zone)" 83 | when :sqlite 84 | "DATETIME(#{super})" 85 | else 86 | "CAST(#{super} AS DATETIME)" 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/dummy/app/models/data_builder.rb: -------------------------------------------------------------------------------- 1 | class DataBuilder 2 | def self.gaussian(mean, stddev) 3 | theta = 2 * Math::PI * Random.new.rand 4 | rho = Math.sqrt(-2 * Math.log(1 - Random.new.rand)) 5 | scale = stddev * rho 6 | return [0, mean + scale * Math.cos(theta)].max 7 | end 8 | 9 | def self.random_title 10 | if rand < 0.5 11 | "#{Faker::Hacker.ingverb} #{Faker::Hacker.adjective} #{Faker::Hacker.noun}" 12 | else 13 | Faker::Book.title 14 | end 15 | end 16 | 17 | def self.build! 18 | Post.destroy_all 19 | Comment.destroy_all 20 | Author.destroy_all 21 | 22 | authors = ([ 23 | "Shay Sides", 24 | "Teodoro Rainey", 25 | "Norman Hanley", 26 | "Raleigh Townes", 27 | "Samatha Doan", 28 | "Valeria Seward", 29 | "Jewel Cervantes", 30 | "Fallon Clapp", 31 | "Kenna Marlow", 32 | "Maurine Butterfield", 33 | "Teresa Gonzales", 34 | "Becky Silva", 35 | "Frank Robertson", 36 | "Alex Hamilton", 37 | "Emilio Powell", 38 | "Jerry Zimmerman", 39 | ] + 20.times.map { Faker::Name.name }).map do |name| 40 | Author.create!(name: name) 41 | end 42 | 43 | titles = [ 44 | "The 17 Cutest Ways To Eat A Burrito Of The Post-Y2K Era", 45 | "22 Problems Only Cover Bands Will Understand", 46 | "The 26 Most Beloved Things Of The '80s", 47 | "The 18 Greatest Facts Of 2013", 48 | "39 Real Estate Moguls Who Absolutely Nailed It In 2013", 49 | "34 Painful Truths Only NFL Linebackers Will Understand", 50 | "The 43 Most Important Punctuation Marks In South America", 51 | "The 25 Most Picturesque HBO Shows Of The Last 10 Years", 52 | "The 45 Best Oprah-Grams From The Ocean Floor", 53 | "20 Tweets That Look Like Miley Cyrus", 54 | "The 44 iPhone Apps That Look Like Channing Tatum", 55 | "The 14 Most Wanted Truths Of All Time", 56 | "The 37 Most Courageous Horses Of The '90s" 57 | ] + 1000.times.map { random_title } 58 | 59 | author_likeability = authors.each_with_object({}) do |author, h| 60 | average_likes = gaussian(10, 5) 61 | stddev_likes = gaussian(10, 2.5) 62 | h[author] = [average_likes, stddev_likes] 63 | end 64 | 65 | likeability_for = Hash.new { |h, author| 66 | h[author] = Hash.new { |hh, title| 67 | l = author_likeability[author] 68 | hh[title] = [l[0] * (1+rand), l[1]] 69 | } 70 | } 71 | 72 | titles.each do |title| 73 | if rand < 0.5 74 | author = authors.sample 75 | else 76 | author_index = gaussian(authors.length/2, authors.length/4).to_i 77 | author = authors[author_index % authors.length] 78 | end 79 | 80 | post = Post.create!( 81 | title: title, 82 | created_at: gaussian(100, 40).days.ago, 83 | likes: gaussian(*likeability_for[author][title]).to_i, 84 | author: author 85 | ) 86 | 87 | gaussian(8, 4).to_i.times do 88 | Comment.create!(post: post, likes: gaussian(5, 2).to_i, author: authors.sample, created_at: post.created_at + gaussian(10, 5).days) 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/repor/dimensions/base_dimension.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class BaseDimension 4 | attr_reader :name, :report, :opts 5 | 6 | def initialize(name, report, opts={}) 7 | @name = name 8 | @report = report 9 | @opts = opts 10 | validate_params! 11 | end 12 | 13 | def expression 14 | opts.fetch(:expression, "#{report.table_name}.#{name}") 15 | end 16 | 17 | # Do any joins/selects necessary to filter or group the relation. 18 | def relate(relation) 19 | opts.fetch(:relation, ->(r) { r }).call(relation) 20 | end 21 | 22 | # Filter the relation based on any constraints in the params 23 | def filter(relation) 24 | raise NotImplementedError 25 | end 26 | 27 | # Group the relation by the expression -- ensure this is ordered, too. 28 | def group(relation) 29 | raise NotImplementedError 30 | end 31 | 32 | # Return an ordered array of all values that should appear in 33 | # `Report#data` 34 | def group_values 35 | raise NotImplementedError 36 | end 37 | 38 | # Given a single (hashified) row of the SQL result, return the Ruby 39 | # object representing this dimension's value 40 | def extract_sql_value(row) 41 | sanitize_sql_value(row[sql_value_name]) 42 | end 43 | 44 | def filter_values 45 | array_param(:only).uniq 46 | end 47 | 48 | # Return whether the report should filter by this dimension 49 | def filtering? 50 | filter_values.present? 51 | end 52 | 53 | def grouping? 54 | report.groupers.include?(self) 55 | end 56 | 57 | def order_expression 58 | sql_value_name 59 | end 60 | 61 | def order(relation) 62 | relation.order("#{order_expression} #{sort_order} #{null_order}") 63 | end 64 | 65 | def sort_desc? 66 | dimension_or_root_param(:sort_desc) 67 | end 68 | 69 | def sort_order 70 | sort_desc?? 'DESC' : 'ASC' 71 | end 72 | 73 | def nulls_last? 74 | value = dimension_or_root_param(:nulls_last) 75 | value = !value if sort_desc? 76 | value 77 | end 78 | 79 | def null_order 80 | return unless Repor.database_type == :postgres 81 | nulls_last?? 'NULLS LAST' : 'NULLS FIRST' 82 | end 83 | 84 | def params 85 | report.params.fetch(:dimensions, {})[name].presence || {} 86 | end 87 | 88 | private 89 | 90 | def validate_params! 91 | end 92 | 93 | def invalid_param!(param_key, message) 94 | raise InvalidParamsError, "Invalid value for params[:dimensions]" \ 95 | "[:#{name}][:#{param_key}]: #{message}" 96 | end 97 | 98 | def sql_value_name 99 | "_repor_dimension_#{name}" 100 | end 101 | 102 | def sanitize_sql_value(value) 103 | value 104 | end 105 | 106 | def dimension_or_root_param(key) 107 | params.fetch(key, report.params[key]) 108 | end 109 | 110 | def array_param(key) 111 | return [] unless params.key?(key) 112 | return [nil] if params[key].nil? 113 | Array.wrap(params[key]) 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/repor/dimensions/base_dimension_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Dimensions::BaseDimension do 4 | def new_dimension(dimension_params = {}, report_params = {}, opts = {}) 5 | report_params[:dimensions] = { foo: dimension_params } 6 | Repor::Dimensions::BaseDimension.new( 7 | :foo, 8 | OpenStruct.new(params: report_params), 9 | opts 10 | ) 11 | end 12 | 13 | describe '#filter_values' do 14 | it 'accepts one' do 15 | dimension = new_dimension(only: 'bar') 16 | expect(dimension.filter_values).to eq %w(bar) 17 | end 18 | 19 | it 'accepts many' do 20 | dimension = new_dimension(only: %w(bar baz)) 21 | expect(dimension.filter_values).to eq %w(bar baz) 22 | end 23 | 24 | it 'determines #filtering?' do 25 | dimension = new_dimension(only: %w(bar baz)) 26 | expect(dimension).to be_filtering 27 | 28 | dimension = new_dimension 29 | expect(dimension).not_to be_filtering 30 | end 31 | end 32 | 33 | describe '#sort_order' do 34 | it 'can be desc/asc, falls back to root, defaults to asc' do 35 | dimension = new_dimension 36 | expect(dimension.sort_order).to eq 'ASC' 37 | 38 | dimension = new_dimension(sort_desc: true) 39 | expect(dimension.sort_order).to eq 'DESC' 40 | 41 | dimension = new_dimension(sort_desc: false) 42 | expect(dimension.sort_order).to eq 'ASC' 43 | 44 | dimension = new_dimension({}, sort_desc: true) 45 | expect(dimension.sort_order).to eq 'DESC' 46 | 47 | dimension = new_dimension({}, sort_desc: false) 48 | expect(dimension.sort_order).to eq 'ASC' 49 | end 50 | end 51 | 52 | describe '#null_order' do 53 | it 'can be first/last, falls back to root, defaults to first (only if postgres)' do 54 | if Repor.database_type == :postgres 55 | dimension = new_dimension 56 | expect(dimension.null_order).to eq 'NULLS FIRST' 57 | 58 | dimension = new_dimension(nulls_last: true) 59 | expect(dimension.null_order).to eq 'NULLS LAST' 60 | 61 | dimension = new_dimension(nulls_last: false) 62 | expect(dimension.null_order).to eq 'NULLS FIRST' 63 | 64 | dimension = new_dimension({}, nulls_last: true) 65 | expect(dimension.null_order).to eq 'NULLS LAST' 66 | 67 | dimension = new_dimension({}, nulls_last: false) 68 | expect(dimension.null_order).to eq 'NULLS FIRST' 69 | else 70 | dimension = new_dimension 71 | expect(dimension.null_order).to be_blank 72 | end 73 | end 74 | end 75 | 76 | describe '#relate' do 77 | it 'defaults to the identity function' do 78 | dimension = new_dimension 79 | expect(dimension.relate(5)).to eq 5 80 | end 81 | 82 | it 'can be overridden, e.g. for joins' do 83 | dimension = new_dimension({}, {}, relation: ->(r) { r + 5 }) 84 | expect(dimension.relate(5)).to eq 10 85 | end 86 | end 87 | 88 | describe '#expression' do 89 | it 'defaults to treating name as a column of the report klass table' do 90 | dimension = Repor::Dimensions::BaseDimension.new( 91 | :bar, 92 | OpenStruct.new(table_name: 'foo') 93 | ) 94 | expect(dimension.expression).to eq('foo.bar') 95 | end 96 | 97 | it 'can be overridden' do 98 | dimension = new_dimension({}, {}, expression: 'baz.bat') 99 | expect(dimension.expression).to eq 'baz.bat' 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/repor/serializers/base_serializer.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Serializers 3 | class BaseSerializer 4 | include ActionView::Helpers::TextHelper 5 | 6 | attr_reader :report 7 | 8 | def initialize(report) 9 | @report = report 10 | end 11 | 12 | # Consider overriding many of these methods to use I18n with keys based 13 | # on the aggregator or dimension name. 14 | 15 | def human_aggregator_label(aggregator) 16 | aggregator.name.to_s.humanize 17 | end 18 | 19 | def human_dimension_label(dimension) 20 | dimension.name.to_s.humanize 21 | end 22 | 23 | def human_null_value_label(dimension) 24 | "No #{human_dimension_label(dimension)}" 25 | end 26 | 27 | def human_aggregator_value_label(aggregator, value) 28 | value 29 | end 30 | 31 | def human_dimension_value_label(dimension, value) 32 | return human_null_value_label(dimension) if value.nil? 33 | 34 | case dimension 35 | when Repor::Dimensions::CategoryDimension 36 | human_category_value_label(dimension, value) 37 | when Repor::Dimensions::NumberDimension 38 | human_number_value_label(dimension, value) 39 | when Repor::Dimensions::TimeDimension 40 | human_time_value_label(dimension, value) 41 | else 42 | value 43 | end 44 | end 45 | 46 | def human_category_value_label(dimension, value) 47 | value 48 | end 49 | 50 | def human_number_value_label(dimension, value) 51 | begin 52 | min, max = value.values_at(:min, :max) 53 | rescue 54 | min, max = value.min, value.max 55 | end 56 | if min && max 57 | "[#{min.round(2)}, #{max.round(2)})" 58 | elsif min 59 | ">= #{min.round(2)}" 60 | elsif max 61 | "< #{max.round(2)}" 62 | else 63 | human_null_value_label(dimension) 64 | end 65 | end 66 | 67 | def time_formats 68 | { 69 | minutes: '%F %k:%M', hours: '%F %k', days: '%F', 70 | weeks: 'week of %F', months: '%Y-%m', years: '%Y' 71 | } 72 | end 73 | 74 | def human_time_value_label(dimension, value) 75 | min, max = value.min, value.max 76 | if min && max 77 | time_formats.each do |step, format| 78 | return min.strftime(format) if max == min.advance(step => 1) 79 | end 80 | "#{min} to #{max}" 81 | elsif min 82 | "after #{min}" 83 | elsif max 84 | "before #{max}" 85 | else 86 | human_null_value_label(dimension) 87 | end 88 | end 89 | 90 | def record_type 91 | report.table_name.singularize.humanize 92 | end 93 | 94 | def axis_summary 95 | y = human_aggregator_label(report.aggregator) 96 | xes = report.groupers.map(&method(:human_dimension_label)) 97 | count = pluralize(report.records.count, record_type) 98 | "#{y} by #{xes.to_sentence} for #{count}" 99 | end 100 | 101 | def filter_summary 102 | report.filters.flat_map do |dimension| 103 | human_dimension_label(dimension) + " = " + dimension.filter_values.map do |value| 104 | human_dimension_value_label(dimension, value) 105 | end.to_sentence(last_word_connector: ', or ') 106 | end.join('; ') 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like 20 | # NGINX, varnish or squid. 21 | # config.action_dispatch.rack_cache = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present? 26 | 27 | # Compress JavaScripts and CSS. 28 | config.assets.js_compressor = :uglifier 29 | # config.assets.css_compressor = :sass 30 | 31 | # Do not fallback to assets pipeline if a precompiled asset is missed. 32 | config.assets.compile = false 33 | 34 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 35 | # yet still be able to expire them through the digest params. 36 | config.assets.digest = true 37 | 38 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 39 | 40 | # Specifies the header that your server uses for sending files. 41 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 42 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 43 | 44 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 45 | # config.force_ssl = true 46 | 47 | # Use the lowest log level to ensure availability of diagnostic information 48 | # when problems arise. 49 | config.log_level = :debug 50 | 51 | # Prepend all log lines with the following tags. 52 | # config.log_tags = [ :subdomain, :uuid ] 53 | 54 | # Use a different logger for distributed setups. 55 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 56 | 57 | # Use a different cache store in production. 58 | # config.cache_store = :mem_cache_store 59 | 60 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 61 | # config.action_controller.asset_host = 'http://assets.example.com' 62 | 63 | # Ignore bad email addresses and do not raise email delivery errors. 64 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 65 | # config.action_mailer.raise_delivery_errors = false 66 | 67 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 68 | # the I18n.default_locale when a translation cannot be found). 69 | config.i18n.fallbacks = true 70 | 71 | # Send deprecation notices to registered listeners. 72 | config.active_support.deprecation = :notify 73 | 74 | # Use default logging formatter so that PID and timestamp are not suppressed. 75 | config.log_formatter = ::Logger::Formatter.new 76 | 77 | # Do not dump schema after migrations. 78 | config.active_record.dump_schema_after_migration = false 79 | end 80 | -------------------------------------------------------------------------------- /lib/repor/dimensions/bin_dimension/bin.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class BinDimension 4 | class Bin 5 | def initialize(min, max) 6 | @min = min 7 | @max = max 8 | end 9 | 10 | def min 11 | @min && parse(@min) 12 | end 13 | 14 | def max 15 | @max && parse(@max) 16 | end 17 | 18 | def valid? 19 | (@min.nil? || parses?(@min)) && 20 | (@max.nil? || parses?(@max)) 21 | end 22 | 23 | def parses?(value) 24 | parse(value).present? rescue false 25 | end 26 | 27 | def parse(value) 28 | value 29 | end 30 | 31 | def quote(value) 32 | ActiveRecord::Base.connection.quote(value) 33 | end 34 | 35 | def cast(value) 36 | quote(value) 37 | end 38 | 39 | def bin_text 40 | "#{min},#{max}" 41 | end 42 | 43 | def cast_bin_text 44 | case Repor.database_type 45 | when :postgres, :sqlite 46 | "CAST(#{quote(bin_text)} AS text)" 47 | else 48 | quote(bin_text) 49 | end 50 | end 51 | 52 | def row_sql 53 | "SELECT #{cast(min)} AS min, #{cast(max)} AS max, #{cast_bin_text} AS bin_text" 54 | end 55 | 56 | def contains_sql(expr) 57 | if min && max 58 | "(#{expr} >= #{quote(min)} AND #{expr} < #{quote(max)})" 59 | elsif max 60 | "#{expr} < #{quote(max)}" 61 | elsif min 62 | "#{expr} >= #{quote(min)}" 63 | else 64 | "#{expr} IS NULL" 65 | end 66 | end 67 | 68 | def self.from_sql(value) 69 | case value 70 | when /^([^,]+),(.+)$/ then new($1, $2) 71 | when /^([^,]+),$/ then new($1, nil) 72 | when /^,(.+)$/ then new(nil, $1) 73 | when ',' then new(nil, nil) 74 | else 75 | raise "Unexpected SQL bin format #{value}" 76 | end 77 | end 78 | 79 | def self.from_hash(h) 80 | # Returns either a bin or nil, depending on whether 81 | # the input is valid. 82 | return new(nil, nil) if h.nil? 83 | return unless h.is_a?(Hash) 84 | min, max = h.symbolize_keys.values_at(:min, :max) 85 | return if min.blank? && max.blank? 86 | new(min.presence, max.presence) 87 | end 88 | 89 | def as_json(*) 90 | return @as_json if instance_variable_defined?(:@as_json) 91 | @as_json = if min && max 92 | { min: min, max: max } 93 | elsif min 94 | { min: min } 95 | elsif max 96 | { max: max } 97 | else 98 | nil 99 | end 100 | end 101 | 102 | def [](key) 103 | return min if key.to_s == 'min' 104 | return max if key.to_s == 'max' 105 | end 106 | 107 | def has_key?(key) 108 | key.to_s == 'min' || key.to_s == 'max' 109 | end 110 | 111 | alias key? has_key? 112 | 113 | def values_at(*keys) 114 | keys.map { |k| self[key] } 115 | end 116 | 117 | def inspect 118 | "" 119 | end 120 | 121 | def hash 122 | as_json.hash 123 | end 124 | 125 | def ==(other) 126 | if other.nil? 127 | min.nil? && max.nil? 128 | else 129 | min == other[:min] && max == other[:max] 130 | end 131 | end 132 | 133 | alias eql? == 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/repor/serializers/highcharts_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Serializers::HighchartsSerializer do 4 | let(:report_class) do 5 | Class.new(Repor::Report) do 6 | report_on :Post 7 | number_dimension :likes 8 | time_dimension :created_at 9 | category_dimension :title 10 | count_aggregator :post_count 11 | end 12 | end 13 | 14 | let(:chart) do 15 | Repor::Serializers::HighchartsSerializer.new(report) 16 | end 17 | 18 | before do 19 | create(:post, created_at: '2016-01-01', likes: 2, title: 'A') 20 | create(:post, created_at: '2016-01-01', likes: 2, title: 'A') 21 | create(:post, created_at: '2016-01-01', likes: 1, title: 'B') 22 | create(:post, created_at: '2016-01-02', likes: 1, title: 'A') 23 | end 24 | 25 | def y_values(series) 26 | series[:data].map { |d| d[:y] } 27 | end 28 | 29 | def filters(series) 30 | series[:data].map { |d| d[:filters] } 31 | end 32 | 33 | describe '#series' do 34 | context 'with one grouper' do 35 | let(:report) do 36 | report_class.new(aggregator: :post_count, groupers: %i[title]) 37 | end 38 | 39 | it 'returns one series of the y values (with filters)' do 40 | expect(chart.series.count).to eq 1 41 | expect(y_values(chart.series[0])).to eq [3, 1] 42 | expect(filters(chart.series[0])).to eq [{ title: 'A' }, { title: 'B' }] 43 | end 44 | end 45 | 46 | context 'with two groupers' do 47 | let(:report) do 48 | report_class.new( 49 | aggregator: :post_count, 50 | groupers: %i[title likes], 51 | dimensions: { likes: { bin_width: 1 } } 52 | ) 53 | end 54 | 55 | it 'returns one series for each x_2 value' do 56 | expect(chart.series.count).to eq 2 57 | expect(y_values(chart.series[0])).to eq [1, 1] 58 | expect(filters(chart.series[0])).to eq [ 59 | { title: 'A', likes: { min: 1, max: 2 } }, 60 | { title: 'B', likes: { min: 1, max: 2 } } 61 | ] 62 | expect(y_values(chart.series[1])).to eq [2, 0] 63 | expect(filters(chart.series[1])).to eq [ 64 | { title: 'A', likes: { min: 2, max: 3 } }, 65 | { title: 'B', likes: { min: 2, max: 3 } } 66 | ] 67 | end 68 | end 69 | 70 | context 'with three groupers' do 71 | let(:report) do 72 | report_class.new( 73 | aggregator: :post_count, 74 | groupers: %i[title likes created_at], 75 | dimensions: { 76 | likes: { bin_width: 1 }, 77 | created_at: { bin_width: '1 day' } 78 | } 79 | ) 80 | end 81 | 82 | it 'returns stacks for each x_3 of groups for each x_2' do 83 | expect(chart.series.count).to eq 4 84 | 85 | expect(chart.series[0][:stack]).to eq '2016-01-01' 86 | expect(chart.series[1][:stack]).to eq '2016-01-01' 87 | expect(chart.series[2][:stack]).to eq '2016-01-02' 88 | expect(chart.series[3][:stack]).to eq '2016-01-02' 89 | 90 | expect(chart.series[0][:id]).to eq '[1.0, 2.0)' 91 | expect(chart.series[1][:id]).to eq '[2.0, 3.0)' 92 | expect(chart.series[2][:linkedTo]).to eq '[1.0, 2.0)' 93 | expect(chart.series[3][:linkedTo]).to eq '[2.0, 3.0)' 94 | 95 | colors = chart.series.map { |s| s[:color] } 96 | expect(colors.all?(&:present?)).to be true 97 | expect(colors[0]).to eq colors[2] 98 | expect(colors[1]).to eq colors[3] 99 | expect(colors[0]).not_to eq colors[1] 100 | 101 | expect(y_values(chart.series[0])).to eq [0, 1] 102 | 103 | jan1 = Time.zone.parse('2016-01-01') 104 | jan2 = Time.zone.parse('2016-01-02') 105 | 106 | expect(filters(chart.series[0])).to eq [ 107 | { title: 'A', likes: { min: 1.0, max: 2.0 }, created_at: { min: jan1, max: jan2 } }, 108 | { title: 'B', likes: { min: 1.0, max: 2.0 }, created_at: { min: jan1, max: jan2 } } 109 | ] 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/repor/dimensions/bin_dimension.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Dimensions 3 | class BinDimension < BaseDimension 4 | def max_bins 5 | 2000 6 | end 7 | 8 | def min 9 | @min ||= filter_min || report.records.minimum(expression) 10 | end 11 | 12 | def max 13 | @max ||= filter_max || report.records.maximum(expression) 14 | end 15 | 16 | def filter_min 17 | filter_values_for(:min).min 18 | end 19 | 20 | def filter_max 21 | filter_values_for(:max).max 22 | end 23 | 24 | def domain 25 | return 0 if min.nil? || max.nil? 26 | max - min 27 | end 28 | 29 | def group_values 30 | @group_values ||= to_bins(array_param(:bins).presence || autopopulate_bins) 31 | end 32 | 33 | def filter_values 34 | @filter_values ||= to_bins(super) 35 | end 36 | 37 | def filter(relation) 38 | filter_values.filter(relation, expression) 39 | end 40 | 41 | def group(relation) 42 | group_values.group(relation, expression, sql_value_name) 43 | end 44 | 45 | def validate_params! 46 | super 47 | 48 | if params.key?(:bin_count) 49 | unless Repor.numeric?(params[:bin_count]) 50 | invalid_param!(:bin_count, "must be numeric") 51 | end 52 | 53 | unless params[:bin_count].to_i > 0 54 | invalid_param!(:bin_count, "must be greater than 0") 55 | end 56 | 57 | unless params[:bin_count].to_i <= max_bins 58 | invalid_param!(:bin_count, "must be less than #{max_bins}") 59 | end 60 | end 61 | 62 | if array_param(:bins).present? 63 | unless group_values.all?(&:valid?) 64 | invalid_param!(:bins, "must be hashes with min/max keys and valid values, or nil") 65 | end 66 | end 67 | 68 | if array_param(:only).present? 69 | unless filter_values.all?(&:valid?) 70 | invalid_param!(:only, "must be hashes with min/max keys and valid values, or nil") 71 | end 72 | end 73 | end 74 | 75 | def bin_width 76 | raise NotImplementedError 77 | end 78 | 79 | def bin_start 80 | self.min 81 | end 82 | 83 | private 84 | 85 | def filter_values_for(key) 86 | filter_values.each_with_object([]) do |filter, values| 87 | if value = filter.send(key) 88 | values << value 89 | end 90 | end 91 | end 92 | 93 | def bin_table_class 94 | self.class.const_get(:BinTable) 95 | end 96 | 97 | def bin_class 98 | self.class.const_get(:Bin) 99 | end 100 | 101 | def to_bins(bins) 102 | bin_table_class.new(bins.map(&method(:to_bin))) 103 | end 104 | 105 | def to_bin(bin) 106 | bin_class.from_hash(bin) 107 | end 108 | 109 | def sanitize_sql_value(value) 110 | bin_class.from_sql(value) 111 | end 112 | 113 | def data_contains_nil? 114 | report.records.where("#{expression} IS NULL").exists? 115 | end 116 | 117 | def autopopulate_bins 118 | iters = 0 119 | bins = [] 120 | bin_edge = self.bin_start 121 | return bins if bin_edge.blank? || max.blank? 122 | approx_count = (max - bin_edge)/(bin_width) 123 | if approx_count > max_bins 124 | invalid_param!(:bin_width, "is too small for the domain; would generate #{approx_count} bins") 125 | end 126 | 127 | loop do 128 | break if bin_edge > max 129 | break if bin_edge == max && filter_values_for(:max).present? 130 | bin = { min: bin_edge, max: bin_edge + bin_width } 131 | bins << bin 132 | bin_edge = bin[:max] 133 | iters += 1 134 | raise "too many bins, likely an internal error" if iters > max_bins 135 | end 136 | 137 | bins.reverse! if sort_desc? 138 | 139 | if data_contains_nil? 140 | nulls_last?? bins.push(nil) : bins.unshift(nil) 141 | end 142 | 143 | bins 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | repor (0.1.0) 5 | rails (~> 4) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actionmailer (4.2.3) 11 | actionpack (= 4.2.3) 12 | actionview (= 4.2.3) 13 | activejob (= 4.2.3) 14 | mail (~> 2.5, >= 2.5.4) 15 | rails-dom-testing (~> 1.0, >= 1.0.5) 16 | actionpack (4.2.3) 17 | actionview (= 4.2.3) 18 | activesupport (= 4.2.3) 19 | rack (~> 1.6) 20 | rack-test (~> 0.6.2) 21 | rails-dom-testing (~> 1.0, >= 1.0.5) 22 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 23 | actionview (4.2.3) 24 | activesupport (= 4.2.3) 25 | builder (~> 3.1) 26 | erubis (~> 2.7.0) 27 | rails-dom-testing (~> 1.0, >= 1.0.5) 28 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 29 | activejob (4.2.3) 30 | activesupport (= 4.2.3) 31 | globalid (>= 0.3.0) 32 | activemodel (4.2.3) 33 | activesupport (= 4.2.3) 34 | builder (~> 3.1) 35 | activerecord (4.2.3) 36 | activemodel (= 4.2.3) 37 | activesupport (= 4.2.3) 38 | arel (~> 6.0) 39 | activesupport (4.2.3) 40 | i18n (~> 0.7) 41 | json (~> 1.7, >= 1.7.7) 42 | minitest (~> 5.1) 43 | thread_safe (~> 0.3, >= 0.3.4) 44 | tzinfo (~> 1.1) 45 | arel (6.0.3) 46 | builder (3.2.2) 47 | coderay (1.1.0) 48 | concurrent-ruby (1.0.1) 49 | database_cleaner (1.4.1) 50 | diff-lcs (1.2.5) 51 | erubis (2.7.0) 52 | factory_girl (4.5.0) 53 | activesupport (>= 3.0.0) 54 | factory_girl_rails (4.5.0) 55 | factory_girl (~> 4.5.0) 56 | railties (>= 3.0.0) 57 | faker (1.6.3) 58 | i18n (~> 0.5) 59 | globalid (0.3.6) 60 | activesupport (>= 4.1.0) 61 | i18n (0.7.0) 62 | json (1.8.3) 63 | loofah (2.0.2) 64 | nokogiri (>= 1.5.9) 65 | mail (2.6.4) 66 | mime-types (>= 1.16, < 4) 67 | method_source (0.8.2) 68 | mime-types (3.0) 69 | mime-types-data (~> 3.2015) 70 | mime-types-data (3.2016.0221) 71 | mini_portile (0.6.2) 72 | minitest (5.7.0) 73 | mysql2 (0.3.20) 74 | nokogiri (1.6.6.2) 75 | mini_portile (~> 0.6.0) 76 | pg (0.18.4) 77 | pry (0.10.1) 78 | coderay (~> 1.1.0) 79 | method_source (~> 0.8.1) 80 | slop (~> 3.4) 81 | rack (1.6.4) 82 | rack-test (0.6.3) 83 | rack (>= 1.0) 84 | rails (4.2.3) 85 | actionmailer (= 4.2.3) 86 | actionpack (= 4.2.3) 87 | actionview (= 4.2.3) 88 | activejob (= 4.2.3) 89 | activemodel (= 4.2.3) 90 | activerecord (= 4.2.3) 91 | activesupport (= 4.2.3) 92 | bundler (>= 1.3.0, < 2.0) 93 | railties (= 4.2.3) 94 | sprockets-rails 95 | rails-deprecated_sanitizer (1.0.3) 96 | activesupport (>= 4.2.0.alpha) 97 | rails-dom-testing (1.0.6) 98 | activesupport (>= 4.2.0.beta, < 5.0) 99 | nokogiri (~> 1.6.0) 100 | rails-deprecated_sanitizer (>= 1.0.1) 101 | rails-html-sanitizer (1.0.2) 102 | loofah (~> 2.0) 103 | railties (4.2.3) 104 | actionpack (= 4.2.3) 105 | activesupport (= 4.2.3) 106 | rake (>= 0.8.7) 107 | thor (>= 0.18.1, < 2.0) 108 | rake (10.4.2) 109 | rspec-core (3.3.1) 110 | rspec-support (~> 3.3.0) 111 | rspec-expectations (3.3.0) 112 | diff-lcs (>= 1.2.0, < 2.0) 113 | rspec-support (~> 3.3.0) 114 | rspec-mocks (3.3.1) 115 | diff-lcs (>= 1.2.0, < 2.0) 116 | rspec-support (~> 3.3.0) 117 | rspec-rails (3.3.2) 118 | actionpack (>= 3.0, < 4.3) 119 | activesupport (>= 3.0, < 4.3) 120 | railties (>= 3.0, < 4.3) 121 | rspec-core (~> 3.3.0) 122 | rspec-expectations (~> 3.3.0) 123 | rspec-mocks (~> 3.3.0) 124 | rspec-support (~> 3.3.0) 125 | rspec-support (3.3.0) 126 | slop (3.6.0) 127 | sprockets (3.5.2) 128 | concurrent-ruby (~> 1.0) 129 | rack (> 1, < 3) 130 | sprockets-rails (3.0.4) 131 | actionpack (>= 4.0) 132 | activesupport (>= 4.0) 133 | sprockets (>= 3.0.0) 134 | sqlite3 (1.3.11) 135 | thor (0.19.1) 136 | thread_safe (0.3.5) 137 | tzinfo (1.2.2) 138 | thread_safe (~> 0.1) 139 | 140 | PLATFORMS 141 | ruby 142 | 143 | DEPENDENCIES 144 | database_cleaner (~> 1.4) 145 | factory_girl_rails (~> 4.5) 146 | faker (~> 1.6) 147 | mysql2 (~> 0.3) 148 | pg (~> 0.18) 149 | pry (~> 0.10) 150 | repor! 151 | rspec-rails (~> 3) 152 | sqlite3 (~> 1.3) 153 | 154 | BUNDLED WITH 155 | 1.11.2 156 | -------------------------------------------------------------------------------- /lib/repor/serializers/form_field_serializer.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Serializers 3 | class FormFieldSerializer < BaseSerializer 4 | include ActionView::Helpers::FormTagHelper 5 | include ActionView::Helpers::FormOptionsHelper 6 | 7 | def html_fields 8 | "
9 | #{axis_fields} 10 | #{dimension_fields.join} 11 |
".html_safe 12 | end 13 | 14 | def aggregator_options 15 | @agg_opts ||= report.aggregators.map { |name, agg| [human_aggregator_label(agg), name] } 16 | end 17 | 18 | def dimension_options 19 | @dim_opts ||= report.dimensions.map { |name, dim| [human_dimension_label(dim), name] } 20 | end 21 | 22 | def aggregator_field 23 | select_tag("#{prefix}[aggregator]", 24 | options_for_select(aggregator_options, report.aggregator_name)) 25 | end 26 | 27 | def primary_grouper_field 28 | select_tag("#{prefix}[groupers][0]", 29 | options_for_select(dimension_options, report.grouper_names[0])) 30 | end 31 | 32 | def secondary_grouper_field 33 | select_tag("#{prefix}[groupers][1]", 34 | options_for_select([[nil, nil]]+dimension_options, report.grouper_names[1])) 35 | end 36 | 37 | def axis_fields 38 | "
39 | Show me #{aggregator_field} 40 | by #{primary_grouper_field} 41 | and #{secondary_grouper_field} 42 | for 43 |
".html_safe 44 | end 45 | 46 | def dimension_fields 47 | report.dimensions.map { |name, dimension| field_for(dimension) }.compact 48 | end 49 | 50 | def field_for(dimension) 51 | case dimension 52 | when Repor::Dimensions::CategoryDimension then category_dimension_field(dimension) 53 | when Repor::Dimensions::BinDimension then bin_dimension_field(dimension) 54 | end 55 | end 56 | 57 | def category_dimension_field(dimension) 58 | options = [[nil, nil]] 59 | 60 | dimension.all_values.each do |value| 61 | options << [human_dimension_value_label(dimension, value), value] 62 | end 63 | 64 | fields_for(dimension) do 65 | select_tag("#{prefix_for(dimension)}[only]", 66 | options_for_select(options, dimension.filter_values.first)) 67 | end 68 | end 69 | 70 | def bin_dimension_field(dimension) 71 | fields_for(dimension) do 72 | fields = "#{bin_min_field(dimension)} to #{bin_max_field(dimension)}" 73 | fields += " by #{bin_step_field(dimension)}" if dimension.grouping? 74 | fields 75 | end 76 | end 77 | 78 | def bin_min_field(dimension) 79 | text_field_tag("#{prefix_for(dimension)}[only][min]", 80 | dimension.filter_min, placeholder: bin_min_placeholder(dimension)) 81 | end 82 | 83 | def bin_max_field(dimension) 84 | text_field_tag("#{prefix_for(dimension)}[only][max]", 85 | dimension.filter_max, placeholder: bin_max_placeholder(dimension)) 86 | end 87 | 88 | def bin_step_field(dimension) 89 | text_field_tag("#{prefix_for(dimension)}[bin_width]", 90 | dimension.params[:bin_width], placeholder: bin_step_placeholder(dimension)) 91 | end 92 | 93 | def fields_for(dimension, &block) 94 | "
95 | #{human_dimension_label(dimension)} 96 | #{yield} 97 |
".html_safe 98 | end 99 | 100 | def wrapper_class 101 | "repor-fields repor-fields--#{css_class(report.class.name)}" 102 | end 103 | 104 | def axis_fields_class 105 | 'repor-axis-fields' 106 | end 107 | 108 | def dimension_fields_class(dimension) 109 | ['repor-dimension-fields', 110 | "repor-dimension-fields--#{css_class(dimension.name)}", 111 | "repor-dimension-fields--#{css_class(dimension.class.name)}"].join(' ') 112 | end 113 | 114 | def bin_max_placeholder(dimension); 'max'; end 115 | def bin_min_placeholder(dimension); 'min'; end 116 | def bin_step_placeholder(dimension) 117 | dimension.bin_width.inspect 118 | end 119 | 120 | def prefix 121 | report.class.name.underscore 122 | end 123 | 124 | def prefix_for(dimension) 125 | "#{prefix}[dimensions][#{dimension.name}]" 126 | end 127 | 128 | def css_class(s) 129 | s.to_s.demodulize.underscore.dasherize 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/acceptance/data_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'more complicated case' do 4 | let(:report_class) do 5 | Class.new(Repor::Report) do 6 | report_on :Post 7 | 8 | time_dimension :created_at 9 | number_dimension :likes 10 | category_dimension :author, expression: 'authors.name', relation: ->(r) { 11 | r.joins('LEFT OUTER JOIN authors ON authors.id = posts.author_id') 12 | } 13 | 14 | count_aggregator :count 15 | sum_aggregator :total_likes, expression: 'posts.likes' 16 | avg_aggregator :mean_likes, expression: 'posts.likes' 17 | min_aggregator :min_likes, expression: 'posts.likes' 18 | max_aggregator :max_likes, expression: 'posts.likes' 19 | end 20 | end 21 | 22 | def data_by(groupers, g_params = nil) 23 | groupers = Array.wrap(groupers) 24 | dimension_params = {} 25 | if g_params 26 | g_params = Array.wrap(g_params) 27 | groupers.zip(g_params).each do |grouper, params| 28 | dimension_params[grouper] = params 29 | end 30 | end 31 | 32 | report = report_class.new(groupers: groupers, dimensions: dimension_params) 33 | report.data 34 | end 35 | 36 | def expect_equal(h1, h2) 37 | expect(JSON.parse(h1.to_json)).to eq JSON.parse(h2.to_json) 38 | end 39 | 40 | before do 41 | @joyce = create(:author, name: 'James Joyce') 42 | @woolf = create(:author, name: 'Virginia Woolf') 43 | 44 | @oct1 = Time.zone.parse('2015-10-01') 45 | @nov1 = Time.zone.parse('2015-11-01') 46 | @dec1 = Time.zone.parse('2015-12-01') 47 | @oct = { min: @oct1, max: @nov1 } 48 | @nov = { min: @nov1, max: @dec1 } 49 | 50 | @p1 = create(:post, author: @joyce.name, created_at: @oct1, likes: 1) 51 | @p2 = create(:post, author: @joyce.name, created_at: @oct1, likes: 2) 52 | @p3 = create(:post, author: @joyce.name, created_at: @nov1, likes: 1) 53 | @p4 = create(:post, author: @joyce.name, likes: 3).tap do |p| 54 | p.update!(created_at: nil) 55 | end 56 | 57 | @p5 = create(:post, author: @woolf.name, created_at: @oct1, likes: 2) 58 | @p6 = create(:post, author: @woolf.name, created_at: @nov1, likes: 3) 59 | @p7 = create(:post, author: @woolf.name, likes: 3).tap do |p| 60 | p.update!(created_at: nil) 61 | end 62 | 63 | @p9 = create(:post, author: nil, created_at: @oct1, likes: 2) 64 | @p10 = create(:post, author: nil, created_at: @nov1, likes: 3) 65 | end 66 | 67 | specify 'basic grouping, 1 grouper, no filters' do 68 | expect_equal data_by(:author), [ 69 | { key: nil, value: 2 }, 70 | { key: 'James Joyce', value: 4 }, 71 | { key: 'Virginia Woolf', value: 3 } 72 | ] 73 | 74 | expect_equal data_by(:created_at, bin_width: '1 month'), [ 75 | { key: nil, value: 2 }, 76 | { key: @oct, value: 4 }, 77 | { key: @nov, value: 3 } 78 | ] 79 | 80 | expect_equal data_by(:likes, bin_width: 1), [ 81 | { key: { min: 1, max: 2 }, value: 2 }, 82 | { key: { min: 2, max: 3 }, value: 3 }, 83 | { key: { min: 3, max: 4 }, value: 4 } 84 | ] 85 | end 86 | 87 | specify 'basic grouping, >=2 groupers, no filters' do 88 | expect_equal data_by([:created_at, :author], bin_width: { months: 1 }), [ 89 | { key: nil, values: [ 90 | { key: nil, value: 0 }, 91 | { key: @oct, value: 1 }, 92 | { key: @nov, value: 1 }]}, 93 | { key: 'James Joyce', values: [ 94 | { key: nil, value: 1 }, 95 | { key: @oct, value: 2 }, 96 | { key: @nov, value: 1 }]}, 97 | { key: 'Virginia Woolf', values: [ 98 | { key: nil, value: 1 }, 99 | { key: @oct, value: 1 }, 100 | { key: @nov, value: 1 }]}] 101 | end 102 | 103 | specify 'sorting with nulls (1 grouper)' do 104 | expect_equal data_by(:author, sort_desc: true), [ 105 | { key: 'Virginia Woolf', value: 3 }, 106 | { key: 'James Joyce', value: 4 }, 107 | { key: nil, value: 2 } 108 | ] 109 | 110 | expect_equal data_by(:created_at, bin_width: '1 month', sort_desc: true), [ 111 | { key: @nov, value: 3 }, 112 | { key: @oct, value: 4 }, 113 | { key: nil, value: 2 } 114 | ] 115 | 116 | if Repor.database_type == :postgres 117 | expect_equal data_by(:author, nulls_last: true), [ 118 | { key: 'James Joyce', value: 4 }, 119 | { key: 'Virginia Woolf', value: 3 }, 120 | { key: nil, value: 2 } 121 | ] 122 | 123 | expect_equal data_by(:author, sort_desc: true, nulls_last: true), [ 124 | { key: nil, value: 2 }, 125 | { key: 'Virginia Woolf', value: 3 }, 126 | { key: 'James Joyce', value: 4 } 127 | ] 128 | 129 | expect_equal data_by(:created_at, bin_width: '1 month', nulls_last: true), [ 130 | { key: @oct, value: 4 }, 131 | { key: @nov, value: 3 }, 132 | { key: nil, value: 2 } 133 | ] 134 | 135 | expect_equal data_by(:created_at, bin_width: '1 month', sort_desc: true, nulls_last: true), [ 136 | { key: nil, value: 2 }, 137 | { key: @nov, value: 3 }, 138 | { key: @oct, value: 4 } 139 | ] 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/repor/serializers/highcharts_serializer.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | module Serializers 3 | class HighchartsSerializer < TableSerializer 4 | def colors 5 | ['#7cb5ec', '#434348', '#90ed7d', '#f7a35c', '#8085e9', 6 | '#f15c80', '#e4d354', '#2b908f', '#f45b5b', '#91e8e1'] 7 | end 8 | 9 | def color_hash 10 | # ensure we consistently assign the same color to the same dimension- 11 | # value pair 12 | @color_hash ||= Hash.new do |h, key| 13 | color_cycle = colors.cycle 14 | h[key] = Hash.new do |hh, value| 15 | hh[value] = color_cycle.next 16 | end 17 | end 18 | end 19 | 20 | def color_for(dimension, value) 21 | # override this if the values of a particular dimension can take on 22 | # meaningful colors 23 | color_hash[dimension.name][value] 24 | end 25 | 26 | def series 27 | case report.groupers.count 28 | when 3 29 | dim1, dim2, dim3 = report.groupers 30 | report.data.flat_map.with_index do |d3, i| 31 | d3[:values].map { |d2| { 32 | stack: human_dimension_value_label(dim3, d3[:key]), 33 | name: human_dimension_value_label(dim2, d2[:key]), 34 | (i == 0 ? :id : :linkedTo) => human_dimension_value_label(dim2, d2[:key]), 35 | color: color_for(dim2, d2[:key]), 36 | data: d2[:values].map { |d1| { 37 | y: d1[:value].to_f, 38 | tooltip: tooltip_for(dim1 => d1, dim2 => d2, dim3 => d3), 39 | filters: filters_for(dim1 => d1, dim2 => d2, dim3 => d3) 40 | }} 41 | }} 42 | end 43 | when 2 44 | dim1, dim2 = report.groupers 45 | report.data.map { |d2| { 46 | name: human_dimension_value_label(dim2, d2[:key]), 47 | color: color_for(dim2, d2[:key]), 48 | data: d2[:values].map { |d1| { 49 | y: d1[:value].to_f, 50 | tooltip: tooltip_for(dim1 => d1, dim2 => d2), 51 | filters: filters_for(dim1 => d1, dim2 => d2) 52 | }} 53 | }} 54 | when 1 55 | dim1 = report.groupers.first 56 | [{ 57 | name: human_aggregator_label(report.aggregator), 58 | data: report.data.map { |d1| { 59 | y: d1[:value].to_f, 60 | tooltip: tooltip_for(dim1 => d1), 61 | filters: filters_for(dim1 => d1) 62 | }} 63 | }] 64 | else 65 | raise Repor::InvalidParamsError, "report must have <= 3 groupers" 66 | end 67 | end 68 | 69 | def tooltip_for(xes) 70 | lines = [] 71 | xes.each do |dim, d| 72 | lines << [ 73 | human_dimension_label(dim), 74 | human_dimension_value_label(dim, d[:key]) 75 | ] 76 | end 77 | lines << [ 78 | human_aggregator_label(report.aggregator), 79 | human_aggregator_value_label(report.aggregator, xes[report.groupers.first][:value]) 80 | ] 81 | lines.map { |k, v| "#{k}: #{v}" }.join('
') 82 | end 83 | 84 | def filters_for(xes) 85 | xes.each_with_object({}) do |(dim, d), h| 86 | h[dim.name] = d[:key] 87 | end 88 | end 89 | 90 | def categories 91 | dimension = report.groupers.first 92 | dimension.group_values.map do |value| 93 | human_dimension_value_label(dimension, value) 94 | end 95 | end 96 | 97 | def chart_title 98 | axis_summary 99 | end 100 | 101 | def chart_subtitle 102 | filter_summary 103 | end 104 | 105 | def x_axis_title 106 | human_dimension_label(report.groupers.first) 107 | end 108 | 109 | def y_axis_title 110 | human_aggregator_label(report.aggregator) 111 | end 112 | 113 | def highcharts_options 114 | { 115 | chart: { 116 | type: 'column' 117 | }, 118 | colors: colors, 119 | title: { 120 | text: chart_title 121 | }, 122 | subtitle: { 123 | text: chart_subtitle 124 | }, 125 | series: series, 126 | xAxis: { 127 | categories: categories, 128 | title: { 129 | text: x_axis_title 130 | } 131 | }, 132 | yAxis: { 133 | allowDecimals: true, 134 | title: { 135 | text: y_axis_title 136 | }, 137 | stackLabels: { 138 | enabled: report.groupers.length >= 3, 139 | format: '{stack}', 140 | rotation: -45, 141 | textAlign: 'left' 142 | } 143 | }, 144 | legend: { 145 | enabled: report.groupers.length >= 2 146 | }, 147 | tooltip: {}, 148 | plotOptions: { 149 | series: { 150 | events: {} 151 | }, 152 | column: { 153 | stacking: ('normal' if report.groupers.length >= 2) 154 | } 155 | } 156 | } 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/repor/report_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Repor::Report do 4 | let(:report_class) do 5 | Class.new(Repor::Report) do 6 | report_on :Post 7 | count_aggregator :count 8 | sum_aggregator :likes 9 | number_dimension :likes 10 | category_dimension :author, expression: 'authors.name', relation: ->(r) { r.joins(:author) } 11 | time_dimension :created_at 12 | end 13 | end 14 | 15 | describe '.autoreport_on' do 16 | let(:report_class) do 17 | Class.new(Repor::Report) { autoreport_on :Post } 18 | end 19 | 20 | it 'infers dimensions from columns' do 21 | expect(report_class.dimensions.keys).to match_array %i[created_at updated_at title author likes] 22 | 23 | def expect_dim_type(type, name) 24 | expect(report_class.dimensions[name][:axis_class]).to eq type 25 | end 26 | 27 | expect_dim_type(Repor::Dimensions::TimeDimension, :created_at) 28 | expect_dim_type(Repor::Dimensions::TimeDimension, :updated_at) 29 | expect_dim_type(Repor::Dimensions::NumberDimension, :likes) 30 | expect_dim_type(Repor::Dimensions::CategoryDimension, :title) 31 | expect_dim_type(Repor::Dimensions::CategoryDimension, :author) 32 | expect(report_class.dimensions[:author][:opts][:expression]).to eq 'authors.name' 33 | end 34 | end 35 | 36 | describe 'data access' do 37 | it 'can be raw, flat, or nested' do 38 | create(:post, author: 'Timmy', created_at: '2016-01-01') 39 | create(:post, author: 'Timmy', created_at: '2016-01-12') 40 | create(:post, author: 'Tammy', created_at: '2016-01-15') 41 | create(:post, author: 'Tammy', created_at: '2016-03-01') 42 | 43 | report = report_class.new( 44 | groupers: %w(author created_at), 45 | dimensions: { created_at: { bin_width: { months: 1 } } } 46 | ) 47 | 48 | jan = { min: Time.zone.parse('2016-01-01'), max: Time.zone.parse('2016-02-01') } 49 | feb = { min: Time.zone.parse('2016-02-01'), max: Time.zone.parse('2016-03-01') } 50 | mar = { min: Time.zone.parse('2016-03-01'), max: Time.zone.parse('2016-04-01') } 51 | 52 | expect(report.raw_data).to eq( 53 | ['Tammy', jan] => 1, 54 | ['Tammy', mar] => 1, 55 | ['Timmy', jan] => 2 56 | ) 57 | 58 | expect(report.flat_data).to eq( 59 | ['Tammy', jan] => 1, ['Tammy', feb] => 0, ['Tammy', mar] => 1, 60 | ['Timmy', jan] => 2, ['Timmy', feb] => 0, ['Timmy', mar] => 0 61 | ) 62 | 63 | expect(report.nested_data).to eq [ 64 | { key: jan, values: [{ key: 'Tammy', value: 1 }, { key: 'Timmy', value: 2 }] }, 65 | { key: feb, values: [{ key: 'Tammy', value: 0 }, { key: 'Timmy', value: 0 }] }, 66 | { key: mar, values: [{ key: 'Tammy', value: 1 }, { key: 'Timmy', value: 0 }] } 67 | ] 68 | end 69 | end 70 | 71 | describe '#dimensions' do 72 | it 'is a curried hash' do 73 | expect(report_class.dimensions.keys).to eq [:likes, :author, :created_at] 74 | report = report_class.new 75 | expect(report.dimensions.keys).to eq [:likes, :author, :created_at] 76 | expect(report.dimensions[:likes]).to be_a Repor::Dimensions::NumberDimension 77 | expect(report.dimensions[:author]).to be_a Repor::Dimensions::CategoryDimension 78 | expect(report.dimensions[:created_at]).to be_a Repor::Dimensions::TimeDimension 79 | end 80 | end 81 | 82 | describe '#params' do 83 | it 'strips "" but preserves nil by default' do 84 | post1 = create(:post, author: 'Phil') 85 | post2 = create(:post, author: 'Phyllis') 86 | 87 | report = report_class.new(dimensions: { author: { only: '' } }) 88 | 89 | expect(report.params).to be_blank 90 | expect(report.dimensions[:author].filter_values).to be_blank 91 | expect(report.records).to eq [post1, post2] 92 | 93 | report = report_class.new(dimensions: { author: { only: [''] } }) 94 | 95 | expect(report.params).to be_blank 96 | expect(report.dimensions[:author].filter_values).to be_blank 97 | expect(report.records).to eq [post1, post2] 98 | 99 | report = report_class.new(dimensions: { author: { only: ['', 'Phil'] } }) 100 | 101 | expect(report.params).to be_present 102 | expect(report.dimensions[:author].filter_values).to eq ['Phil'] 103 | expect(report.records).to eq [post1] 104 | 105 | report = report_class.new(strip_blanks: false, dimensions: { author: { only: '' } }) 106 | 107 | expect(report.params).to be_present 108 | expect(report.dimensions[:author].filter_values).to eq [''] 109 | expect(report.records).to eq [] 110 | 111 | report = report_class.new(dimensions: { author: { only: nil } }) 112 | 113 | expect(report.params).to be_present 114 | expect(report.dimensions[:author].filter_values).to eq [nil] 115 | expect(report.records).to eq [] 116 | end 117 | end 118 | 119 | describe '#aggregators' do 120 | it 'is a curried hash' do 121 | expect(report_class.aggregators.keys).to eq [:count, :likes] 122 | report = report_class.new 123 | expect(report.aggregators.keys).to eq [:count, :likes] 124 | expect(report.aggregators[:count]).to be_a Repor::Aggregators::CountAggregator 125 | expect(report.aggregators[:likes]).to be_a Repor::Aggregators::SumAggregator 126 | end 127 | end 128 | 129 | describe '#groupers' do 130 | it 'defaults to the first' do 131 | report = report_class.new 132 | expect(report.groupers).to eq [report.dimensions[:likes]] 133 | end 134 | 135 | it 'can be set' do 136 | report = report_class.new(groupers: 'created_at') 137 | expect(report.groupers).to eq [report.dimensions[:created_at]] 138 | report = report_class.new(groupers: %w(created_at author)) 139 | expect(report.groupers).to eq [report.dimensions[:created_at], report.dimensions[:author]] 140 | end 141 | 142 | it 'must be valid' do 143 | expect { 144 | report_class.new(groupers: %w(chickens)) 145 | }.to raise_error(Repor::InvalidParamsError) 146 | end 147 | 148 | specify 'there must be at least one defined' do 149 | r = Class.new(Repor::Report) do 150 | report_on :Post 151 | count_aggregator :count 152 | end 153 | expect { r.new }.to raise_error /doesn't have any dimensions declared/ 154 | end 155 | end 156 | 157 | describe '#aggregator' do 158 | it 'defaults to the first' do 159 | report = report_class.new 160 | expect(report.aggregator).to eq report.aggregators[:count] 161 | end 162 | 163 | it 'can be set' do 164 | report = report_class.new(aggregator: 'likes') 165 | expect(report.aggregator).to eq report.aggregators[:likes] 166 | end 167 | 168 | it 'must be valid' do 169 | expect { 170 | report_class.new(aggregator: 'chicken') 171 | }.to raise_error(Repor::InvalidParamsError) 172 | end 173 | 174 | specify 'there must be at least one defined' do 175 | r = Class.new(Repor::Report) do 176 | report_on :Post 177 | time_dimension :created_at 178 | end 179 | expect { r.new }.to raise_error /doesn't have any aggregators declared/ 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/repor/report.rb: -------------------------------------------------------------------------------- 1 | module Repor 2 | class Report 3 | delegate :klass, to: :class 4 | 5 | attr_reader :params 6 | 7 | def initialize(params = {}) 8 | @params = params.deep_symbolize_keys.deep_dup 9 | deep_strip_blanks(@params) unless @params[:strip_blanks] == false 10 | validate_params! 11 | end 12 | 13 | def dimensions 14 | @dimensions ||= build_axes(self.class.dimensions) 15 | end 16 | 17 | def aggregators 18 | @aggregators ||= build_axes(self.class.aggregators) 19 | end 20 | 21 | def aggregator_name 22 | params.fetch(:aggregator, default_aggregator_name).to_sym 23 | end 24 | 25 | def aggregator 26 | @aggregator ||= aggregators[aggregator_name] 27 | end 28 | 29 | def grouper_names 30 | names = params.fetch(:groupers, default_grouper_names) 31 | names = names.is_a?(Hash) ? names.values : Array.wrap(names) 32 | names.map(&:to_sym) 33 | end 34 | 35 | def groupers 36 | @groupers ||= dimensions.values_at(*grouper_names) 37 | end 38 | 39 | def filters 40 | @filters ||= dimensions.values.select(&:filtering?) 41 | end 42 | 43 | def relators 44 | filters | groupers 45 | end 46 | 47 | def base_relation 48 | params.fetch(:relation, klass.all) 49 | end 50 | 51 | def table_name 52 | klass.table_name 53 | end 54 | 55 | def relation 56 | @relation ||= relators.reduce(base_relation) do |relation, dimension| 57 | dimension.relate(relation) 58 | end 59 | end 60 | 61 | def records 62 | @records ||= filters.reduce(relation) do |relation, dimension| 63 | dimension.filter(relation) 64 | end 65 | end 66 | 67 | def groups 68 | @groups ||= groupers.reduce(records) do |relation, dimension| 69 | dimension.group(relation) 70 | end 71 | end 72 | 73 | def raw_data 74 | @raw_data ||= aggregator.aggregate(groups) 75 | end 76 | 77 | def group_values 78 | @group_values ||= all_combinations_of(groupers.map(&:group_values)) 79 | end 80 | 81 | # flat hash of 82 | # { [x1, x2, x3] => y } 83 | def flat_data 84 | @flat_data ||= Hash[group_values.map { |x| [x, raw_data[x]] }] 85 | end 86 | 87 | # nested array of 88 | # [{ key: x3, values: [{ key: x2, values: [{ key: x1, value: y }] }] }] 89 | def nested_data 90 | @nested_data ||= nest_data 91 | end 92 | 93 | def data 94 | nested_data 95 | end 96 | 97 | class << self 98 | def dimensions 99 | @dimensions ||= {} 100 | end 101 | 102 | def dimension(name, dimension_class, opts = {}) 103 | dimensions[name.to_sym] = { axis_class: dimension_class, opts: opts } 104 | end 105 | 106 | def aggregators 107 | @aggregators ||= {} 108 | end 109 | 110 | def aggregator(name, aggregator_class, opts = {}) 111 | aggregators[name.to_sym] = { axis_class: aggregator_class, opts: opts } 112 | end 113 | 114 | %w(category number time).each do |type| 115 | class_eval <<-DIM_HELPERS, __FILE__, __LINE__ 116 | def #{type}_dimension(name, opts = {}) 117 | dimension(name, Dimensions::#{type.classify}Dimension, opts) 118 | end 119 | DIM_HELPERS 120 | end 121 | 122 | %w(count sum avg min max array).each do |type| 123 | class_eval <<-AGG_HELPERS, __FILE__, __LINE__ 124 | def #{type}_aggregator(name, opts = {}) 125 | aggregator(name, Aggregators::#{type.classify}Aggregator, opts) 126 | end 127 | AGG_HELPERS 128 | end 129 | 130 | def default_class 131 | self.name.demodulize.sub(/Report$/, '').constantize 132 | end 133 | 134 | def klass 135 | @klass ||= default_class 136 | rescue NameError 137 | raise NameError, "must specify a class to report on, e.g. `report_on Post`" 138 | end 139 | 140 | def report_on(class_or_name) 141 | @klass = class_or_name.to_s.constantize 142 | end 143 | 144 | # ensure subclasses gain any aggregators or dimensions defined on their parents 145 | def inherited(subclass) 146 | instance_values.each do |ivar, ival| 147 | subclass.instance_variable_set(:"@#{ivar}", ival.dup) 148 | end 149 | end 150 | 151 | # autoreporting will automatically define dimensions based on columns 152 | def autoreport_on(class_or_name) 153 | report_on class_or_name 154 | klass.columns.each(&method(:autoreport_column)) 155 | count_aggregator :count if aggregators.blank? 156 | end 157 | 158 | # can override this method to skip or change certain column declarations 159 | def autoreport_column(column) 160 | return if column.name == 'id' 161 | belongs_to_ref = klass.reflections.find { |_, a| a.foreign_key == column.name } 162 | if belongs_to_ref 163 | name, ref = belongs_to_ref 164 | name_col = (ref.klass.column_names & autoreport_association_name_columns(ref)).first 165 | if name_col 166 | name_expr = "#{ref.klass.table_name}.#{name_col}" 167 | category_dimension name, expression: name_expr, relation: ->(r) { r.joins(name) } 168 | else 169 | category_dimension column.name 170 | end 171 | elsif column.cast_type.type == :datetime 172 | time_dimension column.name 173 | elsif column.cast_type.number? 174 | number_dimension column.name 175 | else 176 | category_dimension column.name 177 | end 178 | end 179 | 180 | # override this to change which columns of the association are used to 181 | # auto-label it 182 | def autoreport_association_name_columns(reflection) 183 | %w(name email title) 184 | end 185 | end 186 | 187 | private def build_axes(axes) 188 | axes.map { |name, h| [name, h[:axis_class].new(name, self, h[:opts])] }.to_h 189 | end 190 | 191 | private def all_combinations_of(values) 192 | values[0].product(*values[1..-1]) 193 | end 194 | 195 | private def nest_data(groupers=self.groupers, prefix=[]) 196 | head, rest = groupers.last, groupers[0..-2] 197 | head.group_values.map do |x| 198 | if rest.any? 199 | { key: x, values: nest_data(rest, [x]+prefix) } 200 | else 201 | { key: x, value: raw_data[([x]+prefix)] } 202 | end 203 | end 204 | end 205 | 206 | private def validate_params! 207 | incomplete_msg = "You must declare at least one aggregator and one " \ 208 | "dimension to initialize a report. See the README for more details." 209 | 210 | if aggregators.blank? 211 | raise Repor::InvalidParamsError, "#{self.class} doesn't have any " \ 212 | "aggregators declared! #{incomplete_msg}" 213 | end 214 | 215 | if dimensions.blank? 216 | raise Repor::InvalidParamsError, "#{self.class} doesn't have any " \ 217 | "dimensions declared! #{incomplete_msg}" 218 | end 219 | 220 | unless aggregator.present? 221 | invalid_param!(:aggregator, 222 | "#{aggregator_name} is not a valid aggregator (should be in #{aggregators.keys})") 223 | end 224 | 225 | unless groupers.all?(&:present?) 226 | invalid_param!(:groupers, 227 | "one of #{grouper_names} is not a valid dimension (should all be in #{dimensions.keys})") 228 | end 229 | end 230 | 231 | private def invalid_param!(param_key, message) 232 | raise InvalidParamsError, "Invalid value for params[:#{param_key}]: #{message}" 233 | end 234 | 235 | private def default_aggregator_name 236 | aggregators.keys.first 237 | end 238 | 239 | private def default_grouper_names 240 | [dimensions.keys.first] 241 | end 242 | 243 | private def strippable_blank?(value) 244 | case value 245 | when String, Array, Hash then value.blank? 246 | else false 247 | end 248 | end 249 | 250 | private def deep_strip_blanks(hash, depth = 0) 251 | raise "very deep hash or, more likely, internal error" if depth > 100 252 | hash.delete_if do |key, value| 253 | strippable_blank?( 254 | case value 255 | when Hash then deep_strip_blanks(value, depth + 1) 256 | when Array then value.reject!(&method(:strippable_blank?)) 257 | else value 258 | end 259 | ) 260 | end 261 | end 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Note: this gem is no longer maintained, see [this awesome fork by `chaunce`](https://github.com/chaunce/active_reporter) instead! 2 | 3 | # Repor 4 | 5 | `repor` is a framework for aggregating data about 6 | [Rails](http://rubyonrails.org) models backed by 7 | [PostgreSQL](http://www.postgresql.org), [MySQL](https://www.mysql.com), or 8 | [SQLite](https://www.sqlite.org) databases. It's designed to be flexible 9 | enough to accommodate many use cases, but opinionated enough to avoid the need 10 | for boilerplate. 11 | 12 | 13 | 14 | 15 | 16 | - [Basic usage](#basic-usage) 17 | - [Building reports](#building-reports) 18 | - [Defining reports](#defining-reports) 19 | - [Base relation](#base-relation) 20 | - [Dimensions (x-axes)](#dimensions-x-axes) 21 | - [Filtering by dimensions](#filtering-by-dimensions) 22 | - [Grouping by dimensions](#grouping-by-dimensions) 23 | - [Customizing dimensions](#customizing-dimensions) 24 | - [Aggregators (y-axes)](#aggregators-y-axes) 25 | - [Customizing aggregators](#customizing-aggregators) 26 | - [Serializing reports](#serializing-reports) 27 | - [Contributing](#contributing) 28 | - [License](#license) 29 | 30 | 31 | 32 | ## Basic usage 33 | 34 | Here are some examples of how to define, run, and serialize a `Repor::Report`: 35 | 36 | ```ruby 37 | class PostReport < Repor::Report 38 | report_on :Post 39 | 40 | category_dimension :author, relation: ->(r) { r.joins(:author) }, 41 | expression: 'users.name' 42 | number_dimension :likes 43 | time_dimension :created_at 44 | 45 | count_aggregator :number_of_posts 46 | sum_aggregator :total_likes, expression: 'posts.likes' 47 | array_aggregator :post_ids, expression: 'posts.id' 48 | end 49 | 50 | # show me # published posts from 2014-2015 with at least 4 likes, by author 51 | 52 | report = PostReport.new( 53 | relation: Post.published, 54 | groupers: [:author], 55 | aggregator: :number_of_posts, 56 | dimensions: { 57 | likes: { 58 | only: { min: 4 } 59 | }, 60 | created_at: { 61 | only: { min: '2014', max: '2015' } 62 | } 63 | } 64 | ) 65 | 66 | puts report.data 67 | 68 | # => [ 69 | # { key: 'James Joyce', value: 10 }, 70 | # { key: 'Margaret Atwood', value: 4 } 71 | # { key: 'Toni Morrison', value: 5 } 72 | # ] 73 | 74 | # show me likes on specific authors' posts by author and year, from 1985-1987 75 | 76 | report = PostReport.new( 77 | groupers: [:author, :created_at], 78 | aggregator: :total_likes, 79 | dimensions: { 80 | created_at: { 81 | only: { min: '1985', max: '1987' }, 82 | bin_width: 'year' 83 | }, 84 | author: { 85 | only: ['Edith Wharton', 'James Baldwin'] 86 | } 87 | } 88 | ) 89 | 90 | puts report.data 91 | 92 | # => [{ 93 | # key: { min: Tue, 01 Jan 1985 00:00:00 UTC +00:00, 94 | # max: Wed, 01 Jan 1986 00:00:00 UTC +00:00 }, 95 | # values: [ 96 | # { key: 'Edith Wharton', value: 35 }, 97 | # { key: 'James Baldwin', value: 13 } 98 | # ] 99 | # }, { 100 | # key: { min: Wed, 01 Jan 1986 00:00:00 UTC +00:00, 101 | # max: Thu, 01 Jan 1987 00:00:00 UTC +00:00 }, 102 | # values: [ 103 | # { key: 'Edith Wharton', value: 0 }, 104 | # { key: 'James Baldwin', value: 0 } 105 | # ] 106 | # }, { 107 | # key: { min: Thu, 01 Jan 1987 00:00:00 UTC +00:00, 108 | # max: Fri, 01 Jan 1988 00:00:00 UTC +00:00 }, 109 | # values: [ 110 | # { key: 'Edith Wharton', value: 0 }, 111 | # { key: 'James Baldwin', value: 19 } 112 | # ] 113 | # }] 114 | 115 | csv_serializer = Repor::Serializers::CsvSerializer.new(report) 116 | puts csv_serializer.csv_text 117 | 118 | # => csv text string 119 | 120 | chart_serializer = Repor::Serializers::HighchartsSerializer.new(report) 121 | puts chart_serializer.highcharts_options 122 | 123 | # => highcharts options hash 124 | ``` 125 | 126 | To define a report, you declare dimensions (which represent attributes of your 127 | data) and aggregators (which represent quantities you want to measure). To 128 | run a report, you instantiate it with one aggregator and at least one dimension, 129 | then inspect its `data`. You can also wrap it in a serializer to get results in 130 | useful formats. 131 | 132 | ## Building reports 133 | 134 | Just call `ReportClass.new(params)`, where `params` is a hash with these keys: 135 | 136 | - `aggregator` (required) is the name of the aggregator to aggregate by 137 | - `groupers` (required) is a list of the names of the dimension(s) to group by 138 | - `relation` (optional) provides an initial scope for the data 139 | - `dimensions` (optional) holds dimension-specific filter or grouping options 140 | 141 | See below for more details about dimension-specific parameters. 142 | 143 | ## Defining reports 144 | 145 | ### Base relation 146 | 147 | A `Repor::Report` either needs to know what `ActiveRecord` class it is reporting 148 | on, or it needs to know a `table_name` and a `base_relation`. 149 | 150 | You can specify an `ActiveRecord` class by calling the `report_on` class method 151 | with a class or class name, or if you prefer, you can override the other two as 152 | instance methods. 153 | 154 | By default, it will try to infer an `ActiveRecord` class from the report class 155 | name by dropping `/Report$/` and constantizing. 156 | 157 | ```ruby 158 | class PostReport < Repor::Report 159 | end 160 | 161 | PostReport.new.table_name 162 | # => 'posts' 163 | 164 | PostReport.new.base_relation 165 | # => Post.all 166 | 167 | class PostStructuralReport < Repor::Report 168 | report_on :Post 169 | 170 | def base_relation 171 | super.where(author: 'Foucault') 172 | end 173 | end 174 | 175 | PostStructuralReport.new.table_name 176 | # => 'posts' 177 | 178 | PostStructuralReport.new.base_relation 179 | # => Post.where(author: 'Foucault') 180 | ``` 181 | 182 | Finally, you can also use `autoreport_on` if you'd like to automatically infer 183 | dimensions from your columns and associations. `autoreport_on` will try to map 184 | most columns to dimensions, and if the column in question is for a `belongs_to` 185 | association, will even try to join and report on the association's name: 186 | 187 | ```ruby 188 | class PostReport < Repor::Report 189 | autoreport_on Post 190 | end 191 | 192 | PostReport.new.dimensions.keys 193 | # => %i[:created_at, :updated_at, :likes, :title, :author] 194 | 195 | PostReport.new.dimensions[:author].expression 196 | # => 'users.name' 197 | ``` 198 | 199 | Autoreport behavior can be customized by overriding certain methods; see the 200 | `Repor::Report` code for more information. 201 | 202 | ### Dimensions (x-axes) 203 | 204 | You define dimensions on your `Repor::Report` to represent attributes of your 205 | data you're interested in. Dimensions objects can filter or group your relation 206 | by a SQL expression, and accept/return simple Ruby values of various types. 207 | 208 | There are several built-in types of dimensions: 209 | - `CategoryDimension` 210 | - Groups/filters the relation by the discrete values of the `expression` 211 | - `NumberDimension` 212 | - Groups/filters the relation by binning a continuous numeric `expression` 213 | - `TimeDimension` 214 | - Like number dimensions, but the bins are increments of time 215 | 216 | You define dimensions in your report class like this: 217 | 218 | ```ruby 219 | class PostReport < Repor::Report 220 | category_dimension :status 221 | number_dimension :author_rating, expression: 'users.rating', 222 | relation: ->(r) { r.joins(:author) } 223 | time_dimension :publication_date, expression: 'posts.published_at' 224 | end 225 | ``` 226 | 227 | The SQL expression a dimension uses defaults to: 228 | ```ruby 229 | "#{report.table_name}.#{dimension.name}" 230 | ``` 231 | 232 | but this can be overridden by passing an `expression` option. Additionally, if 233 | the filtering or grouping requires joins or other SQL operations, a custom 234 | `relation` proc can be passed, which will be called beforehand. 235 | 236 | #### Filtering by dimensions 237 | 238 | All dimensions can be filtered to one or more values by passing in 239 | `params[:dimensions][][:only]`. 240 | 241 | `CategoryDimension#only` should be passed the exact values you'd like to filter 242 | to (or what will map to them after connection adapter quoting). 243 | 244 | `NumberDimension` and `TimeDimension` are "bin" dimensions, and their `only`s 245 | should be passed one or more bin ranges. Bin ranges should be hashes of at 246 | least one of `min` and `max`, or they should just be `nil` to explicitly select 247 | rows for which `expression` is null. Bin range filtering is `min`-inclusive but 248 | `max`-exclusive. For `NumberDimension`, the bin values should be numbers or 249 | strings of digits. For `TimeDimension`, the bin values should be dates/times or 250 | `Time.zone.parse`-able strings. 251 | 252 | #### Grouping by dimensions 253 | 254 | To group by a dimension, pass its `name` to `params[:groupers]`. 255 | 256 | For bin dimensions (`NumberDimension` and `TimeDimension`), where the values 257 | being grouped by are ranges of numbers or times, you can specify additional 258 | options to control the width and distribution of those bins. In particular, 259 | you can pass values to: 260 | 261 | - `params[:dimensions][][:bins]`, 262 | - `params[:dimensions][][:bin_count]`, or 263 | - `params[:dimensions][][:bin_width]` 264 | 265 | `bins` is the most general option; you can use it to divide the full domain of 266 | the data into non-uniform, overlapping, and even null bin ranges. It should be 267 | passed an array of the same min/max hashes or `nil` used in filtering. 268 | 269 | `bin_count` will divide the domain of the data into a fixed number of bins. It 270 | should be passed a positive integer. 271 | 272 | `bin_width` will tile the domain with bins of a fixed width. It should be 273 | passed a positive number for `NumberDimension`s and a "duration" for 274 | `TimeDimension`s. Durations can either be strings of a number followed by a time 275 | increment (minutes, hours, days, weeks, months, years), or they can be hashes 276 | suitable for use with 277 | [`ActiveSupport::TimeWithZone#advance`](http://apidock.com/rails/ActiveSupport/TimeWithZone/advance). 278 | E.g.: 279 | 280 | ``` 281 | params[:dimensions][