diff --git a/.gitignore b/.gitignore index 49f3cd2..9433be1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .bundle .config .idea +.rspec_status .yardoc Gemfile.lock InstalledFiles diff --git a/.rspec b/.rspec index 4e1e0d2..b18b4cc 100644 --- a/.rspec +++ b/.rspec @@ -1 +1,3 @@ --color +--order random +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..9368567 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,42 @@ +AllCops: + TargetRubyVersion: 2.4 + +inherit_gem: + rubocop_defaults: .rubocop.yml + +inherit_from: .rubocop_todo.yml + +inherit_mode: + merge: + - Exclude + +Lint/UnusedMethodArgument: + Exclude: + - 'lib/compendium/abstract_chart_provider.rb' + +RSpec/ExampleLength: + Max: 10 + +Style/DateTime: + Exclude: + - 'spec/compendium/param_types/date_spec.rb' + +Style/FormatString: + EnforcedStyle: sprintf + +Style/FormatStringToken: + EnforcedStyle: unannotated + +Style/IfUnlessModifier: + Exclude: + - 'Gemfile' + +Style/MethodMissingSuper: + Exclude: + - 'lib/compendium/presenters/settings/query.rb' + +Style/RescueModifier: + Enabled: false + +Style/SymbolArray: + MinSize: 3 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..6e79135 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,33 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2018-08-14 15:01:45 -0400 using RuboCop version 0.58.2. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 16 +Metrics/AbcSize: + Max: 33 + +# Offense count: 3 +Metrics/CyclomaticComplexity: + Max: 7 + +# Offense count: 14 +# Configuration parameters: CountComments. +Metrics/MethodLength: + Max: 15 + +# Offense count: 2 +Metrics/PerceivedComplexity: + Max: 9 + +# Offense count: 6 +RSpec/AnyInstance: + Exclude: + - 'spec/compendium/dsl/query_spec.rb' + - 'spec/compendium/presenters/chart_spec.rb' + - 'spec/compendium/queries/collection_spec.rb' + - 'spec/compendium/queries/query_spec.rb' + - 'spec/compendium/report_spec.rb' diff --git a/.travis.yml b/.travis.yml index 75b762a..281e6d3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,8 @@ language: ruby cache: - bundler rvm: - - 2.2.9 - - 2.3.6 - - 2.4.3 - - 2.5.0 + - 2.2.10 + - 2.3.7 + - 2.4.4 + - 2.5.1 before_install: gem install bundler -v 1.16.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 412e82c..cea5c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## Future +* Removed support for Ruby < 2.2 +* Added support for Ruby 2.5 +* Changed `collect: :active_record` query option to `sql: true` +* Replaced `mount_compendium` with `mount Compendium::Engine => '/path'` + ## 1.2.2 * Allow report options to be hidden * Allow the collection in a CollectionQuery to be generated at report runtime using a proc diff --git a/Gemfile b/Gemfile index 550dd53..904b7a8 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,10 @@ source 'https://rubygems.org' # Specify your gem's dependencies in compendium.gemspec gemspec +group :development do + gem 'rubocop_defaults', git: 'https://github.com/dvandersluis/rubocop_defaults.git' +end + if RUBY_VERSION >= '2.4' gem 'json', github: 'flori/json', branch: 'v1.8' end diff --git a/README.md b/README.md index 648bc2e..31fe489 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ class MyReport < Compendium::Report end end - # Define a query which collects data by using AR directly - query :on_hand_inventory, collect: :active_record do |params| + # Define a query which doesn't execute SQL (for instance if you want to return AR models) + query :on_hand_inventory, execute_sql: false do |params| Items.where(in_stock: true) end @@ -84,7 +84,7 @@ If validation is set up on any options, calling `valid?` on the report will vali ```ruby class MyReport < Compendium::Report - options :starting_on, :date, validates: { presence: true } + option :starting_on, :date, validates: { presence: true } end r = MyReport.new @@ -144,7 +144,13 @@ end #### Collection Queries -Sometimes you'll want to run a collection over a collection of data; for this, you can use a **collection query**. A collection query will perform the same query for each element of a hash or array, or for each result of a query. A collection is specified via `collection: [...]`, `collection: { ... }` or `collection: query` (note not a symbol but an actual query object). +Sometimes you'll want to run the same query over a collection of data; for this, you can use a **collection query**. A collection query will perform the same query for each element of a hash or array, or for each result of a query. A collection is specified via `collection:`, where the value is an array, hash, proc, or `Queries::Query`. + +```ruby +query :popular_orders, collection: popular_items do |params, id| + Orders.where(item_id: id) +end +``` ### Tying into your Rails application diff --git a/Rakefile b/Rakefile index aeb05c0..87da91c 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,5 @@ -require "bundler/gem_tasks" +require 'bundler/gem_tasks' require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) -task :default => :spec \ No newline at end of file +task :default => :spec diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..ac3f5f7 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,12 @@ +# Upgrading Compendium + +## Upgrading to >= 2.0.0 + +### Queries +* The `collect` option has been replaced with `sql` (default `false`), in order to decouple queries from +active record. Replace `collect: :active_record` with `sql: false`. + +### Rails +#### Routes +* The `mount_compendium` routing monkey patch was removed in favour of actual routes. The actual routes + are slightly less configurable. Replace `mount_compendium` with `mount Compendium::Engine => '/path'` diff --git a/app/classes/compendium/presenters/base.rb b/app/classes/compendium/presenters/base.rb deleted file mode 100644 index 526cd22..0000000 --- a/app/classes/compendium/presenters/base.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Compendium::Presenters - class Base - def initialize(template, object) - @object = object - @template = template - end - - def to_s - "#<#{self.class.name}:0x00#{'%x' % (object_id << 1)}>" - end - - private - - def self.presents(name) - define_method(name) do - @object - end - end - - def method_missing(*args, &block) - return @template.send(*args, &block) if @template.respond_to?(args.first) - super - end - - def respond_to_missing?(*args) - return true if @template.respond_to?(*args) - super - end - end -end \ No newline at end of file diff --git a/app/classes/compendium/presenters/chart.rb b/app/classes/compendium/presenters/chart.rb deleted file mode 100644 index ddcbad3..0000000 --- a/app/classes/compendium/presenters/chart.rb +++ /dev/null @@ -1,84 +0,0 @@ -require 'compendium/presenters/query' -require 'active_support/core_ext/array/extract_options' - -module Compendium::Presenters - class Chart < Query - attr_reader :data, :params, :container, :chart_provider - attr_accessor :options - - def initialize(template, object, *args, &setup) - super(template, object) - - self.options = args.extract_options! - type, container = args - - if remote? - # If the query hasn't run yet, render a chart that loads its data remotely (ie. through AJAX) - # ie. if rendering a query from a report class directly - @data = query.url - @params = collect_params - else - @data = options[:index] ? results.records[options[:index]] : results - @data = @data.records if @data.is_a?(Compendium::ResultSet) - @data = @data[0...-1] if query.options[:totals] - end - - @container = container || query.name - - initialize_chart_provider(type, &setup) - end - - def render - chart_provider.render(@template, @container) - end - - # You can force the chart to render remote data, even if the query has already run by passing the remote: true option - def remote? - !query.ran? || options.fetch(:remote, false) - end - - private - - def provider - provider = Compendium.config.chart_provider - require "compendium/#{provider.downcase}" - provider.is_a?(Class) ? provider : Compendium::ChartProvider.const_get(provider) - end - - def initialize_chart_provider(type, &setup) - @chart_provider = provider.new(type, @data, @params, &setup) - end - - def collect_params - params = {} - params[:report] = options[:params] if options[:params] - - if remote? && protected_against_csrf? - # If we're loading remotely, and CSRF protection is enabled, - # automatically include the CSRF token in AJAX params - params.merge!(form_authenticity_param) - end - - params - end - - def protected_against_csrf? - @template.controller.send(:protect_against_forgery?) - end - - def form_authenticity_param - return {} unless protected_against_csrf? - { @template.controller.request_forgery_protection_token => @template.controller.send(:form_authenticity_token) } - end - - def method_missing(name, *args, &block) - return chart_provider.send(name, *args, &block) if chart_provider.respond_to?(name) - super - end - - def respond_to_missing?(name, include_private = false) - return true if chart_provider.respond_to?(name) - super - end - end -end diff --git a/app/classes/compendium/presenters/csv.rb b/app/classes/compendium/presenters/csv.rb deleted file mode 100644 index f344755..0000000 --- a/app/classes/compendium/presenters/csv.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'csv' - -module Compendium::Presenters - class CSV < Table - def initialize(object, &block) - super(nil, object, &block) - end - - def render - ::CSV.generate do |csv| - csv << headings.map{ |_, val| formatted_heading(val) } - - records.each do |row| - csv << row.map{ |key, val| formatted_value(key, val) } - end - - if has_totals_row? - totals[totals.keys.first] = translate(:total) - csv << totals.map do |key, val| - formatted_value(key, val) unless settings.skipped_total_cols.include?(key.to_sym) - end - end - end - end - - private - - def settings_class - Compendium::Presenters::Settings::Table - end - end -end diff --git a/app/classes/compendium/presenters/metric.rb b/app/classes/compendium/presenters/metric.rb deleted file mode 100644 index 815c335..0000000 --- a/app/classes/compendium/presenters/metric.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Compendium::Presenters - class Metric < Base - presents :metric - - delegate :name, :query, :description, :ran?, to: :metric - - def initialize(template, object, options = {}) - super(template, object) - @options = options - end - - def label - @options[:label] || t("#{query}.#{name}") - end - - def description - @options[:description] - end - - def result(number_format = '%0.1f', display_nil_as = :na) - if metric.result - sprintf(number_format, metric.result) - else - t(display_nil_as) - end - end - - def render - @template.render 'compendium/reports/metric', metric: self - end - end -end \ No newline at end of file diff --git a/app/classes/compendium/presenters/option.rb b/app/classes/compendium/presenters/option.rb deleted file mode 100644 index df93255..0000000 --- a/app/classes/compendium/presenters/option.rb +++ /dev/null @@ -1,112 +0,0 @@ -module Compendium::Presenters - class Option < Base - MISSING_CHOICES_ERROR = "choices must be specified" - - presents :option - delegate :hidden?, to: :option - - def name - t("options.#{option.name}", cascade: { offset: 2 }) - end - - def label(form) - if option.note? - key = option.note == true ? :"#{option.name}_note" : option.note - note = t("options.#{key}", cascade: { offset: 2 }) - end - - if option.note? && defined?(AccessibleTooltip) - title = t("options.#{option.name}_note_title", default: '', cascade: { offset: 2 }) - tooltip = accessible_tooltip(:help, label: name, title: title) { note } - return form.label option.name, tooltip - else - label = case option.type.to_sym - when :boolean, :radio - name - - else - form.label option.name, name - end - - out = ActiveSupport::SafeBuffer.new - out << content_tag(:span, label, class: 'option-label') - out << content_tag(:div, note, class: 'option-note') if option.note? - out - end - end - - def note - if option.note? - key = option.note === true ? :"#{option.name}_note" : option.note - content_tag(:div, t(key), class: 'option-note') - end - end - - def input(ctx, form) - out = ActiveSupport::SafeBuffer.new - - case option.type.to_sym - when :scalar - out << scalar_field(form) - - when :date - out << date_field(form) - - when :dropdown - raise ArgumentError, MISSING_CHOICES_ERROR unless option.choices - - choices = option.choices - choices = ctx.instance_exec(&choices) if choices.respond_to?(:call) - out << dropdown(form, choices, option.options) - - when :boolean, :radio - choices = if option.radio? - raise ArgumentError, MISSING_CHOICES_ERROR unless option.choices - option.choices - else - %w(true false) - end - - choices.each.with_index { |choice, index| out << radio_button(form, choice, index) } - end - - out - end - - def hidden_field(form) - form.hidden_field option.name - end - - private - - def date_field(form, include_time = false) - content_tag('div', class: 'option-date') do - if defined?(CalendarDateSelect) - form.calendar_date_select option.name, time: include_time, popup: 'force' - else - form.text_field option.name - end - end - end - - def scalar_field(form) - content_tag('div', class: 'option-scalar') do - form.text_field option.name - end - end - - def dropdown(form, choices = {}, options = {}) - content_tag('div', class: 'option-dropdown') do - form.select option.name, choices, options.symbolize_keys - end - end - - def radio_button(form, label, value) - content_tag('div', class: 'option-radio') do - div_content = ActiveSupport::SafeBuffer.new - div_content << form.radio_button(option.name, value) - div_content << form.label(option.name, t(label), value: value) - end - end - end -end diff --git a/app/classes/compendium/presenters/query.rb b/app/classes/compendium/presenters/query.rb deleted file mode 100644 index 9adfc0d..0000000 --- a/app/classes/compendium/presenters/query.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'compendium/presenters/base' -require 'compendium/presenters/settings/query' -require 'compendium/presenters/settings/table' - -module Compendium::Presenters - class Query < Base - presents :query - - def initialize(template, object) - super(template, object) - end - - def render - raise NotImplementedError - end - - private - - def results - query.results - end - - def settings_class - Settings.const_get(self.class.name.demodulize, false) rescue Settings::Query - end - end -end diff --git a/app/classes/compendium/presenters/settings/query.rb b/app/classes/compendium/presenters/settings/query.rb deleted file mode 100644 index 115943f..0000000 --- a/app/classes/compendium/presenters/settings/query.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Compendium::Presenters::Settings - class Query - attr_reader :query - - delegate :[], :fetch, to: :@settings - delegate :report, to: :query, allow_nil: true - - def initialize(query = nil) - @settings = {}.with_indifferent_access - @query = query - end - - def update(&block) - instance_exec(self, &block) - end - - def method_missing(name, *args, &block) - if block_given? - @settings[name] = block.call(*args) - elsif !args.empty? - @settings[name] = args.length == 1 ? args.first : args - elsif name.to_s.end_with?('?') - prefix = name.to_s.gsub(/\?\z/, '') - @settings.key?(prefix) - else - @settings[name] - end - end - end -end diff --git a/app/classes/compendium/presenters/settings/table.rb b/app/classes/compendium/presenters/settings/table.rb deleted file mode 100644 index 3ec5a61..0000000 --- a/app/classes/compendium/presenters/settings/table.rb +++ /dev/null @@ -1,51 +0,0 @@ -require 'compendium/presenters/settings/query' - -module Compendium::Presenters::Settings - class Table < Query - attr_reader :headings - - def initialize(*) - super - - @headings = {} - - # Set default values for settings - number_format '%0.2f' - table_class 'results' - header_class 'headings' - row_class 'data' - totals_class 'totals' - skipped_total_cols [] - end - - def set_headings(headings) - headings.map!(&:to_sym) - @headings = Hash[headings.zip(headings)].with_indifferent_access - end - - def override_heading(*args, &block) - if block_given? - @headings.each do |key, val| - res = yield val.to_s - @headings[key] = res if res - end - else - col, label = args - @headings[col] = label - end - end - - def format(column, &block) - @settings[:formatters] ||= {} - @settings[:formatters][column] = block - end - - def formatters - (@settings[:formatters] || {}) - end - - def skip_total_for(*cols) - @settings[:skipped_total_cols].concat(cols.map(&:to_sym)) - end - end -end diff --git a/app/classes/compendium/presenters/table.rb b/app/classes/compendium/presenters/table.rb deleted file mode 100644 index 907f081..0000000 --- a/app/classes/compendium/presenters/table.rb +++ /dev/null @@ -1,96 +0,0 @@ -module Compendium::Presenters - class Table < Query - attr_reader :records, :totals, :settings - - def initialize(*) - super - - @records = results.records - - @settings = settings_class.new(query) - @settings.set_headings(results.keys) - @settings.update(&query.table_settings) if query.table_settings - yield @settings if block_given? - - if has_totals_row? - @totals = @records.pop - totals[totals.keys.first] = translate(:total) - end - end - - def render - content_tag(:table, class: @settings.table_class) do - table = ActiveSupport::SafeBuffer.new - table << content_tag(:thead, build_row(headings, settings.header_class, :th, &heading_proc)) - table << content_tag(:tbody) do - tbody = ActiveSupport::SafeBuffer.new - records.each { |row| tbody << build_row(row, settings.row_class, &data_proc) } - tbody - end - table << content_tag(:tfoot, build_row(totals, @settings.totals_class, :th, &totals_proc)) if has_totals_row? - table - end - end - - private - - def headings - @settings.headings - end - - def has_totals_row? - query.options.fetch(:totals, false) - end - - def data_proc - proc { |key, val| formatted_value(key, val) } - end - - def heading_proc - proc { |_, val| formatted_heading(val) } - end - - def totals_proc - proc { |key, val| formatted_value(key, val) unless settings.skipped_total_cols.include?(key.to_sym) } - end - - def build_row(row, row_class, cell_type = :td) - content_tag(:tr, class: row_class) do - out = ActiveSupport::SafeBuffer.new - - row.each.with_index do |(key, val), i| - val = yield key, val, i if block_given? - out << content_tag(cell_type, val) - end - - out - end - end - - def formatted_heading(v) - v.is_a?(Symbol) ? translate(v) : v - end - - def formatted_value(k, v) - if @settings.formatters[k] - @settings.formatters[k].call(v) - else - if v.numeric? - if v.zero? && @settings.display_zero_as? - @settings.display_zero_as - else - sprintf(@settings.number_format, v) - end - elsif v.nil? - @settings.display_nil_as - end - end || v - end - - def translate(v, opts = {}) - opts.reverse_merge!(scope: settings.i18n_scope) if settings.i18n_scope? - opts[:default] = -> * { I18n.t(v, scope: 'compendium') } - I18n.t(v, opts) - end - end -end diff --git a/app/controllers/compendium/reports_controller.rb b/app/controllers/compendium/reports_controller.rb index db9b093..bc9a3e6 100644 --- a/app/controllers/compendium/reports_controller.rb +++ b/app/controllers/compendium/reports_controller.rb @@ -19,7 +19,7 @@ def run end format.any do - template = template_exists?(@prefix, get_template_prefixes) ? @prefix : 'run' + template = template_exists?(@prefix, template_prefixes) ? @prefix : 'run' render action: template, locals: { report: @report } end end @@ -33,11 +33,7 @@ def export respond_to do |format| format.csv do - filename = @report.report_name.to_s.parameterize + '-' + Time.current.strftime('%Y%m%d%H%I%S') - response.headers['Content-Disposition'] = 'attachment; filename="' + filename + '.csv"' - - query = @report.queries[@report.exporters[:csv]] - render text: query.render_csv + render_csv end format.any do @@ -65,11 +61,10 @@ def find_report def find_query return unless params[:query] @query = @report.queries[params[:query]] + return unless @query - unless @query - flash[:error] = t(:invalid_report_query, scope: 'compendium.reports') - redirect_to action: :setup, report_name: params[:report_name] - end + flash[:error] = t(:invalid_report_query, scope: 'compendium.reports') + redirect_to action: :setup, report_name: params[:report_name] end def render_setup(opts = {}) @@ -89,16 +84,24 @@ def run_report @report.run(self, @query ? { only: @query.name } : {}) end - def get_template_prefixes + def template_prefixes paths = [] klass = self.class - begin + while klass != ActionController::Base paths << klass.name.underscore.gsub(/_controller$/, '') klass = klass.superclass - end while(klass != ActionController::Base) + end paths end + + def render_csv + filename = @report.report_name.to_s.parameterize + '-' + Time.current.strftime('%Y%m%d%H%I%S') + response.headers['Content-Disposition'] = 'attachment; filename="' + filename + '.csv"' + + query = @report.queries[@report.exporters[:csv]] + render text: query.render_csv + end end end diff --git a/app/helpers/compendium/reports_helper.rb b/app/helpers/compendium/reports_helper.rb index 8ece860..b2a5581 100644 --- a/app/helpers/compendium/reports_helper.rb +++ b/app/helpers/compendium/reports_helper.rb @@ -1,6 +1,7 @@ module Compendium module ReportsHelper private + def expose(*args) klass = args.pop if args.last.is_a?(Class) klass ||= "Compendium::Presenters::#{args.first.class}".constantize @@ -14,9 +15,7 @@ def render_report_setup(assigns) end def render_if_exists(options = {}) - if lookup_context.template_exists?(options[:partial] || options[:template], options[:path], options.key?(:partial)) - render(options) - end + render(options) if lookup_context.template_exists?(options[:partial] || options[:template], options[:path], options.key?(:partial)) end end -end \ No newline at end of file +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..2a76fb3 --- /dev/null +++ b/bin/console @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'compendium' + +begin + require 'pry' + Pry.start +rescue LoadError + require 'irb' + IRB.start(__FILE__) +end diff --git a/compendium.gemspec b/compendium.gemspec index 8b1db4e..d97e37a 100644 --- a/compendium.gemspec +++ b/compendium.gemspec @@ -1,28 +1,31 @@ -# -*- encoding: utf-8 -*- -lib = File.expand_path('../lib', __FILE__) +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'compendium/version' Gem::Specification.new do |gem| - gem.name = "compendium" + gem.name = 'compendium' gem.version = Compendium::VERSION - gem.authors = ["Daniel Vandersluis"] - gem.email = ["dvandersluis@selfmgmt.com"] - gem.description = %q{Ruby on Rails reporting framework} - gem.summary = %q{Ruby on Rails reporting framework} - gem.homepage = "https://github.com/dvandersluis/compendium" - gem.license = "MIT" + gem.authors = ['Daniel Vandersluis'] + gem.email = ['daniel.vandersluis@gmail.com'] + gem.description = 'Ruby on Rails reporting framework' + gem.summary = 'Ruby on Rails reporting framework' + gem.homepage = 'https://github.com/dvandersluis/compendium' + gem.license = 'MIT' - gem.files = `git ls-files`.split($/) - gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) + gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) - gem.require_paths = ["lib"] + gem.require_paths = ['lib'] - gem.add_dependency 'rails', '>= 3.0.0', '< 4' - gem.add_dependency 'sass-rails', '>= 3.0.0' - gem.add_dependency 'compass-rails', '>= 1.0.0' gem.add_dependency 'collection_of', '1.0.6' + gem.add_dependency 'compass-rails', '>= 1.0.0' gem.add_dependency 'inheritable_attr', '>= 1.0.0' - gem.add_development_dependency 'rake', '> 11.0.1', '< 12' - gem.add_development_dependency 'rspec', '~> 3.7.0' + gem.add_dependency 'rails', '>= 5.1.0' + gem.add_dependency 'sass-rails', '>= 3.0.0' + + gem.add_development_dependency 'pry' + gem.add_development_dependency 'rake', '> 11.0.1', '< 12.3.3' + gem.add_development_dependency 'rspec', '~> 3.8.0' + gem.add_development_dependency 'rubocop', '~> 0.58' + gem.add_development_dependency 'rubocop-rspec', '~> 1.28' end diff --git a/config/initializers/rails/active_record/connection_adapters/quoting.rb b/config/initializers/rails/active_record/connection_adapters/quoting.rb index 83f275d..46f228c 100644 --- a/config/initializers/rails/active_record/connection_adapters/quoting.rb +++ b/config/initializers/rails/active_record/connection_adapters/quoting.rb @@ -3,12 +3,18 @@ # crash. # Override AR::ConnectionAdapters::Quoting to forward a SimpleDelegator's object to be quoted. -module ActiveRecord::ConnectionAdapters::Quoting - def quote_with_simple_delegator(value, column = nil) +module QuoteWithSimpleDelegator + def quote(value, column = nil) return value.quoted_id if value.respond_to?(:quoted_id) value = value.__getobj__ if value.is_a?(SimpleDelegator) - quote_without_simple_delegator(value, column) + super end +end - alias_method_chain :quote, :simple_delegator -end \ No newline at end of file +module ActiveRecord + module ConnectionAdapters + module Quoting + prepend QuoteWithSimpleDelegator + end + end +end diff --git a/config/initializers/ruby/numeric.rb b/config/initializers/ruby/numeric.rb index 404cb4d..74d7965 100644 --- a/config/initializers/ruby/numeric.rb +++ b/config/initializers/ruby/numeric.rb @@ -23,4 +23,4 @@ class Numeric def numeric? true end -end \ No newline at end of file +end diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..2c09745 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,8 @@ +Compendium::Engine.routes.draw do + scope controller: 'compendium/reports', as: 'compendium_reports' do + get ':report_name', action: :setup, constraints: { format: :html }, as: 'setup' + match ':report_name/export(/:query)', action: :export, as: 'export', via: [:get, :post] + match ':report_name(/:query)', action: :run, as: 'run', via: [:get, :post] + root action: :index, as: 'root' + end +end diff --git a/lib/compendium.rb b/lib/compendium.rb index b13f5e2..9699c6a 100644 --- a/lib/compendium.rb +++ b/lib/compendium.rb @@ -4,28 +4,19 @@ require 'active_support/configurable' module Compendium - autoload :AbstractChartProvider, 'compendium/abstract_chart_provider' - autoload :ChartProvider, 'compendium/abstract_chart_provider' - autoload :CollectionQuery, 'compendium/collection_query' - autoload :ContextWrapper, 'compendium/context_wrapper' - autoload :CountQuery, 'compendium/count_query' - autoload :DSL, 'compendium/dsl' - autoload :Metric, 'compendium/metric' - autoload :Option, 'compendium/option' - autoload :Params, 'compendium/params' - autoload :Query, 'compendium/query' - autoload :ResultSet, 'compendium/result_set' - autoload :Report, 'compendium/report' - autoload :SumQuery, 'compendium/sum_query' - autoload :ThroughQuery, 'compendium/through_query' - - autoload :Param, 'compendium/param_types' - autoload :BooleanParam, 'compendium/param_types' - autoload :DateParam, 'compendium/param_types' - autoload :DropdownParam, 'compendium/param_types' - autoload :ParamWithChoices, 'compendium/param_types' - autoload :RadioParam, 'compendium/param_types' - autoload :ScalarParam, 'compendium/param_types' + require 'compendium/abstract_chart_provider' + require 'compendium/abstract_chart_provider' + require 'compendium/context_wrapper' + require 'compendium/dsl' + require 'compendium/engine' + require 'compendium/metric' + require 'compendium/option' + require 'compendium/params' + require 'compendium/param_types' + require 'compendium/presenters' + require 'compendium/queries' + require 'compendium/result_set' + require 'compendium/report' def self.reports @reports ||= [] @@ -35,7 +26,7 @@ def self.reports # Compendium.configure do |config| # config.chart_provider = :AmCharts # end - def self.configure(&block) + def self.configure yield @config ||= Compendium::Configuration.new end diff --git a/lib/compendium/abstract_chart_provider.rb b/lib/compendium/abstract_chart_provider.rb index cf98d8f..f120259 100644 --- a/lib/compendium/abstract_chart_provider.rb +++ b/lib/compendium/abstract_chart_provider.rb @@ -40,4 +40,4 @@ def respond_to_missing?(name, include_private = false) super end end -end \ No newline at end of file +end diff --git a/lib/compendium/collection_query.rb b/lib/compendium/collection_query.rb deleted file mode 100644 index 4dde2ed..0000000 --- a/lib/compendium/collection_query.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'compendium/query' - -module Compendium - # A CollectionQuery is a Query which runs once for each in a given set of criteria - class CollectionQuery < Query - attr_accessor :collection - - def initialize(*) - super - self.collection = prepare_collection(@options[:collection]) - end - - def run(params, context = self) - collection_values = get_collection_values(context, params) - - results = collection_values.inject({}) do |r, (key, value)| - res = collect_results(context, params, key, value) - r[key] = res unless res.empty? - r - end - - # A CollectionQuery's results will be a ResultSet of ResultSets - @results = ResultSet.new(results) - end - - private - - def get_collection_values(context, params) - self.collection = get_associated_query(collection) if collection.is_a?(Symbol) - - if collection.is_a?(Query) - collection.run(params, context) unless collection.ran? - collection.results - elsif collection.is_a?(Proc) - prepare_collection(collection.call(params)) - else - collection - end - end - - def prepare_collection(collection) - return collection if collection.is_a?(Query) || collection.is_a?(Symbol) || collection.is_a?(Proc) - collection.is_a?(Hash) ? collection : Hash[collection.zip(collection)] - end - end -end diff --git a/lib/compendium/context_wrapper.rb b/lib/compendium/context_wrapper.rb index 3e377d8..88ff022 100644 --- a/lib/compendium/context_wrapper.rb +++ b/lib/compendium/context_wrapper.rb @@ -24,4 +24,4 @@ def respond_to_missing?(name, include_private = false) delegator end end -end \ No newline at end of file +end diff --git a/lib/compendium/count_query.rb b/lib/compendium/count_query.rb deleted file mode 100644 index 3a604c0..0000000 --- a/lib/compendium/count_query.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'compendium/errors' -require 'compendium/query' - -module Compendium - # A CountQuery is a Query which runs an SQL count statement - # Often useful in conjunction with a grouped query - class CountQuery < Query - - def initialize(*args) - super - - @options.reverse_merge!(order: 'COUNT(*)', reverse: true) - end - - private - - def execute_command(command) - return [] if command.nil? - raise InvalidCommand unless command.respond_to?(:count) - command.count - end - end -end diff --git a/lib/compendium/dsl.rb b/lib/compendium/dsl.rb index f70780e..f320b22 100644 --- a/lib/compendium/dsl.rb +++ b/lib/compendium/dsl.rb @@ -1,102 +1,37 @@ require 'collection_of' require 'inheritable_attr' + +require 'compendium/dsl/exports' +require 'compendium/dsl/filter' +require 'compendium/dsl/metric' +require 'compendium/dsl/option' +require 'compendium/dsl/query' +require 'compendium/dsl/table' require 'compendium/option' +require 'compendium/queries/query' + require 'active_support/core_ext/class/attribute' module Compendium module DSL + include Exports + include Filter + include Metric + include Option + include Query + include Table + def self.extended(klass) - klass.inheritable_attr :queries, default: ::Collection[Query] - klass.inheritable_attr :options, default: ::Collection[Option] + klass.inheritable_attr :queries, default: ::Collection[Queries::Query] + klass.inheritable_attr :options, default: ::Collection[Compendium::Option] klass.inheritable_attr :exporters, default: {} end - # Define a query - def query(name, opts = {}, &block) - define_query(name, opts, &block) - end - alias_method :chart, :query - alias_method :data, :query - - # Define a parameter for the report - def option(name, *args) - opts = args.extract_options! - type = args.shift - - add_params_validations(name, opts.delete(:validates)) - - if options[name] - options[name].type = type if type - options[name].default = opts.delete(:default) if opts.key?(:default) - options[name].merge!(opts) - else - options << Compendium::Option.new(opts.merge(name: name, type: type)) - end - end - - # Define a metric from a query or implicitly - # A metric is a derived statistic from a report, for instance a count of rows - def metric(name, *args, &block) - proc = args.first.is_a?(Proc) ? args.first : block - opts = args.extract_options! - - if opts.key?(:through) - [opts.delete(:through)].flatten.each do |query| - raise ArgumentError, "query #{query} is not defined" unless queries.key?(query) - queries[query].add_metric(name, proc, opts) - end - else - # Allow metrics to define queries implicitly - # ie. if you need a metric that counts a column, there's no need to explicitly create a query - # and just pass it into a metric - query = define_query("__metric_#{name}", {}, &block) - query.add_metric(name, -> result { result.first }, opts) - end - end - - # Define a filter to modify the results from specified query (in this case :deliveries) - # For example, this can be useful to translate columns prior to rendering, as it will apply - # for all render types (table, chart, JSON) - # Multiple queries can be set up with the same filter - def filter(*query_names, &block) - each_query(query_names) do |query| - query.add_filter(block) - end - end - - # Allow default table settings to be defined for a query. - # These settings are used when rendering a query to an HTML table or to CSV - def table(*query_names, &block) - each_query(query_names) do |query| - query.table_settings = block - end - end - - # Define any exports the report has - def exports(type, *opts) - exporters[type] = if opts.empty? - true - elsif opts.length == 1 - opts.first - else - opts - end - end - - # Each Report will have its own descendant of Params in order to safely add validations - def params_class - @params_class ||= Class.new(Params) - end - - def params_class=(klass) - @params_class = klass - end - # Allow defined queries to be redefined by name, eg: # query :main_query # main_query { collect_records_here } def method_missing(name, *args, &block) - if queries.keys.include?(name.to_sym) + if queries.key?(name.to_sym) query = queries[name.to_sym] query.proc = block if block_given? query.options = args.extract_options! @@ -107,57 +42,17 @@ def method_missing(name, *args, &block) end def respond_to_missing?(name, *args) - return true if queries.keys.include?(name) + return true if queries.key?(name) super end private - def each_query(query_names, &block) + def each_query(query_names) query_names.each do |query_name| raise ArgumentError, "query #{query_name} is not defined" unless queries.key?(query_name) yield queries[query_name] end end - - def define_query(name, opts, &block) - params = [name.to_sym, opts, block] - query_type = Query - - if opts.key?(:collection) - query_type = CollectionQuery - elsif opts.key?(:through) - # Ensure each through query is defined - through = [opts[:through]].flatten - through.each { |q| raise ArgumentError, "query #{q} is not defined" unless self.queries.include?(q.to_sym) } - - query_type = ThroughQuery - params.insert(1, through) - elsif opts.fetch(:count, false) - query_type = CountQuery - elsif opts.fetch(:sum, false) - query_type = SumQuery - params.insert(1, opts[:sum]) - end - - query = query_type.new(*params) - query.report = self - - metrics[name] = opts[:metric] if opts.key?(:metric) - - if queries[name] - raise CannotRedefineQueryType unless queries[name].instance_of?(query_type) - queries.delete(name) - end - - queries << query - - query - end - - def add_params_validations(name, validations) - return if validations.blank? - self.params_class.validates name, validations - end end end diff --git a/lib/compendium/dsl/exports.rb b/lib/compendium/dsl/exports.rb new file mode 100644 index 0000000..3911d7d --- /dev/null +++ b/lib/compendium/dsl/exports.rb @@ -0,0 +1,16 @@ +module Compendium + module DSL + module Exports + # Define any exports the report has + def exports(type, *opts) + exporters[type] = if opts.empty? + true + elsif opts.length == 1 + opts.first + else + opts + end + end + end + end +end diff --git a/lib/compendium/dsl/filter.rb b/lib/compendium/dsl/filter.rb new file mode 100644 index 0000000..7e6e969 --- /dev/null +++ b/lib/compendium/dsl/filter.rb @@ -0,0 +1,15 @@ +module Compendium + module DSL + module Filter + # Define a filter to modify the results from specified query (in this case :deliveries) + # For example, this can be useful to translate columns prior to rendering, as it will apply + # for all render types (table, chart, JSON) + # Multiple queries can be set up with the same filter + def filter(*query_names, &block) + each_query(query_names) do |query| + query.add_filter(block) + end + end + end + end +end diff --git a/lib/compendium/dsl/metric.rb b/lib/compendium/dsl/metric.rb new file mode 100644 index 0000000..ac32008 --- /dev/null +++ b/lib/compendium/dsl/metric.rb @@ -0,0 +1,25 @@ +module Compendium + module DSL + module Metric + # Define a metric from a query or implicitly + # A metric is a derived statistic from a report, for instance a count of rows + def metric(name, *args, &block) + proc = args.first.is_a?(Proc) ? args.first : block + opts = args.extract_options! + + if opts.key?(:through) + [opts.delete(:through)].flatten.each do |query| + raise ArgumentError, "query #{query} is not defined" unless queries.key?(query) + queries[query].add_metric(name, proc, opts) + end + else + # Allow metrics to define queries implicitly + # ie. if you need a metric that counts a column, there's no need to explicitly create a query + # and just pass it into a metric + query = define_query("__metric_#{name}", {}, &block) + query.add_metric(name, -> (result) { result.first }, opts) + end + end + end + end +end diff --git a/lib/compendium/dsl/option.rb b/lib/compendium/dsl/option.rb new file mode 100644 index 0000000..eb12a3e --- /dev/null +++ b/lib/compendium/dsl/option.rb @@ -0,0 +1,34 @@ +module Compendium + module DSL + module Option + # Define a parameter for the report + def option(name, type, default: nil, validates: nil, **opts) + add_params_validations(name, validates) + + if options[name] + options[name].type = type + options[name].default = default if default + options[name].merge!(opts) + else + options << Compendium::Option.new(opts.merge(name: name, type: type, default: default)) + end + end + + # Each Report will have its own descendant of Params in order to safely add validations + def params_class + @params_class ||= Class.new(Params) + end + + def params_class=(klass) + @params_class = klass + end + + private + + def add_params_validations(name, validations) + return if validations.blank? + params_class.validates name, validations + end + end + end +end diff --git a/lib/compendium/dsl/query.rb b/lib/compendium/dsl/query.rb new file mode 100644 index 0000000..cdab2fb --- /dev/null +++ b/lib/compendium/dsl/query.rb @@ -0,0 +1,71 @@ +module Compendium + module DSL + module Query + # Define a query + def query(name, opts = {}, &block) + define_query(name, opts, &block) + end + alias_method :chart, :query + alias_method :data, :query + end + + private + + def define_query(name, opts, &block) + klass = query_class(opts) + clean_opts(opts, klass) + params = build_params(name, opts, &block) + + query = klass.new(*params) + query.report = self + + metrics[name] = opts[:metric] if opts.key?(:metric) + + if queries[name] + raise Queries::CannotRedefineType unless queries[name].instance_of?(query.class) + queries.delete(name) + end + + queries << query + + query + end + + def query_class(opts) + if opts.key?(:collection) + Queries::Collection + elsif opts.key?(:through) + Queries::Through + elsif opts.fetch(:count, false) + Queries::Count + elsif opts.fetch(:sum, false) + Queries::Sum + else + Queries::Query + end + end + + def build_params(name, opts, &block) + params = [name.to_sym, opts, block] + + if opts.key?(:through) + # Ensure each through query is defined + through = [opts[:through]].flatten + through.each { |q| raise ArgumentError, "query #{q} is not defined" unless queries.include?(q.to_sym) } + + params.insert(1, through) + elsif opts.fetch(:sum, false) + params.insert(1, opts[:sum]) + end + + params + end + + def clean_opts(opts, query_class) + opts.delete(:count) + opts.delete(:collection) unless query_class == Queries::Collection + opts.delete(:through) unless query_class == Queries::Through + opts.delete(:sum) unless query_class == Queries::Sum + end + end +end diff --git a/lib/compendium/dsl/table.rb b/lib/compendium/dsl/table.rb new file mode 100644 index 0000000..c992fe4 --- /dev/null +++ b/lib/compendium/dsl/table.rb @@ -0,0 +1,13 @@ +module Compendium + module DSL + module Table + # Allow default table settings to be defined for a query. + # These settings are used when rendering a query to an HTML table or to CSV + def table(*query_names, &block) + each_query(query_names) do |query| + query.table_settings = block + end + end + end + end +end diff --git a/lib/compendium/engine.rb b/lib/compendium/engine.rb index 5c7b3e6..a1fe998 100644 --- a/lib/compendium/engine.rb +++ b/lib/compendium/engine.rb @@ -1,8 +1,15 @@ -require 'compendium/engine/mount' +require 'rails/engine' module Compendium - if defined?(Rails) - class Engine < ::Rails::Engine + class Engine < Rails::Engine + config.generators do |g| + g.test_framework :rspec end end -end \ No newline at end of file + + class ExportRouter + def matches?(request) + request.params[:export].present? + end + end +end diff --git a/lib/compendium/engine/mount.rb b/lib/compendium/engine/mount.rb deleted file mode 100644 index fe5343e..0000000 --- a/lib/compendium/engine/mount.rb +++ /dev/null @@ -1,21 +0,0 @@ -module ActionDispatch - module Routing - class Mapper - class ExportRouter - def matches?(request) - request.params[:export].present? - end - end - - def mount_compendium(options = {}) - scope options[:at], controller: options.fetch(:controller, 'compendium/reports'), as: 'compendium_reports' do - get ':report_name', action: :setup, constraints: { format: :html }, as: 'setup' - match ':report_name/export', action: :export, as: 'export', via: [:get, :post] - post ':report_name(/:query)', constraints: ExportRouter.new, action: :export, as: 'export_post' - match ':report_name(/:query)', action: :run, as: 'run', via: [:get, :post] - root action: :index, as: 'root' - end - end - end - end -end diff --git a/lib/compendium/errors.rb b/lib/compendium/errors.rb index e5fd797..9d66de3 100644 --- a/lib/compendium/errors.rb +++ b/lib/compendium/errors.rb @@ -1,6 +1,8 @@ module Compendium CompendiumError = Class.new(StandardError) - InvalidCommand = Class.new(CompendiumError) - CannotRedefineQueryType = Class.new(CompendiumError) -end \ No newline at end of file + module Queries + InvalidCommand = Class.new(CompendiumError) + CannotRedefineType = Class.new(CompendiumError) + end +end diff --git a/lib/compendium/metric.rb b/lib/compendium/metric.rb index 113bd49..c0d2c8f 100644 --- a/lib/compendium/metric.rb +++ b/lib/compendium/metric.rb @@ -30,4 +30,4 @@ def condition_failed?(ctx) (options.key?(:if) && !ctx.instance_exec(&options[:if])) || (options.key?(:unless) && ctx.instance_exec(&options[:unless])) end end -end \ No newline at end of file +end diff --git a/lib/compendium/open_hash.rb b/lib/compendium/open_hash.rb index 6ce7c83..141cf30 100644 --- a/lib/compendium/open_hash.rb +++ b/lib/compendium/open_hash.rb @@ -28,21 +28,21 @@ def convert_value(value) end end - def method_missing(name, *args, &block) + def method_missing(name, *args, &block) # rubocop:disable Metrics/CyclomaticComplexity method = name.to_s case method - when %r{.=$} + when /.=$/ super unless args.length == 1 return self[method[0...-1]] = args.first - when %r{.\?$} + when /.\?$/ super unless args.empty? - return self.key?(method[0...-1].to_sym) + return key?(method[0...-1].to_sym) - when %r{^_.} + when /^_./ super unless args.empty? - return self[method[1..-1]] if self.key?(method[1..-1].to_sym) + return self[method[1..-1]] if key?(method[1..-1].to_sym) else return self[method] if key?(method) || !respond_to?(method) @@ -55,11 +55,11 @@ def respond_to_missing?(name, include_private = false) method = name.to_s case method - when %r{.[=?]$} - return true if self.key?(method[0...-1]) + when /.[=?]$/ + return true if key?(method[0...-1]) - when %r{^_.} - return true if self.key?(method[1..-1]) + when /^_./ + return true if key?(method[1..-1]) end super diff --git a/lib/compendium/option.rb b/lib/compendium/option.rb index e6bdedc..22b878d 100644 --- a/lib/compendium/option.rb +++ b/lib/compendium/option.rb @@ -5,19 +5,19 @@ module Compendium class Option - attr_accessor :name, :type, :default, :choices, :options + attr_reader :type + attr_accessor :name, :default, :choices, :options delegate :boolean?, :date?, :dropdown?, :radio?, :scalar?, to: :type - delegate :merge, :merge!, :[], to: :@options + delegate :merge, :merge!, :[], :[]=, to: :@options - def initialize(hash = {}) - raise ArgumentError, "name must be provided" unless hash.key?(:name) + def initialize(name:, type:, default: nil, choices: nil, **options) + @name = name.to_sym + @default = default + @choices = choices + @options = options.with_indifferent_access - @name = hash.delete(:name).to_sym - @default = hash.delete(:default) - @choices = hash.delete(:choices) - self.type = hash.delete(:type) - @options = hash.with_indifferent_access + self.type = type end def type=(type) @@ -35,4 +35,4 @@ def respond_to_missing?(name, include_private = false) super end end -end \ No newline at end of file +end diff --git a/lib/compendium/param_types.rb b/lib/compendium/param_types.rb index 6a4a419..0247a61 100644 --- a/lib/compendium/param_types.rb +++ b/lib/compendium/param_types.rb @@ -1,111 +1,11 @@ -require_relative '../../config/initializers/ruby/numeric' -require 'delegate' - module Compendium - class Param < ::SimpleDelegator - def scalar?; false; end - def boolean?; false; end - def date?; false; end - def dropdown?; false; end - def radio?; false; end - - def ==(other) - return true if (value == other rescue false) - super - end - - # Need to explicitly delegate nil? to the object, otherwise it's always false - # This is because SimpleDelegator is a non-nil object, and it only forwards non-defined methods! - def nil? - __getobj__.nil? - end - - def to_f - Kernel.Float(__getobj__) - end - - def to_i - Kernel.Integer(__getobj__) - end - end - - class ParamWithChoices < Param - def initialize(obj, choices) - @choices = choices - - if @choices.respond_to?(:call) - # If given a proc, defer determining values until later. - index = obj - else - index = obj.numeric? ? obj.to_i : @choices.index(obj) - raise IndexError if (!obj.nil? && index.nil?) || index.to_i.abs > @choices.length - 1 - end - - super(index) - end - - def value - @choices[self] - end - end - - class ScalarParam < Param - def initialize(obj, *) - super obj - end - - # A scalar param just keeps track of a value with no modifications - def scalar? - true - end - end - - class BooleanParam < Param - def initialize(obj, *) - # If given 0, 1, or a version thereof (ie. "0"), pass it along - return super obj.to_i if obj.numeric? && (0..1).cover?(obj.to_i) - super !!obj ? 0 : 1 - end - - def boolean? - true - end - - def value - [true, false][self] - end - - # When negating a BooleanParam, use the value instead - def ! - !value - end - end - - class DateParam < Param - def initialize(obj, *) - if obj.respond_to?(:to_date) - obj = obj.to_date - else - obj = Date.parse(obj) rescue nil - end - - super obj - end - - def date? - true - end - end - - class RadioParam < ParamWithChoices - def radio? - true - end - end - - class DropdownParam < ParamWithChoices - def dropdown? - true - end - end -end \ No newline at end of file + module ParamTypes + require 'compendium/param_types/param' + require 'compendium/param_types/boolean' + require 'compendium/param_types/date' + require 'compendium/param_types/dropdown' + require 'compendium/param_types/with_choices' + require 'compendium/param_types/radio' + require 'compendium/param_types/scalar' + end +end diff --git a/lib/compendium/param_types/boolean.rb b/lib/compendium/param_types/boolean.rb new file mode 100644 index 0000000..275aa77 --- /dev/null +++ b/lib/compendium/param_types/boolean.rb @@ -0,0 +1,31 @@ +require 'compendium/param_types/param' + +module Compendium + module ParamTypes + class Boolean < Param + def initialize(obj, *) + value = if obj.numeric? && (0..1).cover?(obj.to_i) + # If given 0, 1, or a version thereof (ie. "0"), pass it along + obj.to_i + else + obj ? 0 : 1 + end + + super value + end + + def boolean? + true + end + + def value + [true, false][self] + end + + # When negating a BooleanParam, use the value instead + def ! + !value + end + end + end +end diff --git a/lib/compendium/param_types/date.rb b/lib/compendium/param_types/date.rb new file mode 100644 index 0000000..c3cea53 --- /dev/null +++ b/lib/compendium/param_types/date.rb @@ -0,0 +1,19 @@ +module Compendium + module ParamTypes + class Date < Param + def initialize(obj, *) + obj = if obj.respond_to?(:to_date) + obj.to_date + else + ::Date.parse(obj) rescue nil + end + + super obj + end + + def date? + true + end + end + end +end diff --git a/lib/compendium/param_types/dropdown.rb b/lib/compendium/param_types/dropdown.rb new file mode 100644 index 0000000..a1414b4 --- /dev/null +++ b/lib/compendium/param_types/dropdown.rb @@ -0,0 +1,11 @@ +require 'compendium/param_types/with_choices' + +module Compendium + module ParamTypes + class Dropdown < WithChoices + def dropdown? + true + end + end + end +end diff --git a/lib/compendium/param_types/param.rb b/lib/compendium/param_types/param.rb new file mode 100644 index 0000000..8dcd00b --- /dev/null +++ b/lib/compendium/param_types/param.rb @@ -0,0 +1,47 @@ +require_relative '../../../config/initializers/ruby/numeric' +require 'delegate' + +module Compendium + module ParamTypes + class Param < ::SimpleDelegator + def scalar? + false + end + + def boolean? + false + end + + def date? + false + end + + def dropdown? + false + end + + def radio? + false + end + + def ==(other) + return true if (value == other rescue false) + super + end + + # Need to explicitly delegate nil? to the object, otherwise it's always false + # This is because SimpleDelegator is a non-nil object, and it only forwards non-defined methods! + def nil? + __getobj__.nil? + end + + def to_f + Kernel.Float(__getobj__) + end + + def to_i + Kernel.Integer(__getobj__) + end + end + end +end diff --git a/lib/compendium/param_types/radio.rb b/lib/compendium/param_types/radio.rb new file mode 100644 index 0000000..d6353ba --- /dev/null +++ b/lib/compendium/param_types/radio.rb @@ -0,0 +1,9 @@ +module Compendium + module ParamTypes + class Radio < WithChoices + def radio? + true + end + end + end +end diff --git a/lib/compendium/param_types/scalar.rb b/lib/compendium/param_types/scalar.rb new file mode 100644 index 0000000..d0ad77c --- /dev/null +++ b/lib/compendium/param_types/scalar.rb @@ -0,0 +1,14 @@ +module Compendium + module ParamTypes + class Scalar < Param + def initialize(obj, *) + super obj + end + + # A scalar param just keeps track of a value with no modifications + def scalar? + true + end + end + end +end diff --git a/lib/compendium/param_types/with_choices.rb b/lib/compendium/param_types/with_choices.rb new file mode 100644 index 0000000..6e13437 --- /dev/null +++ b/lib/compendium/param_types/with_choices.rb @@ -0,0 +1,23 @@ +module Compendium + module ParamTypes + class WithChoices < Param + def initialize(obj, choices) + @choices = choices + + if @choices.respond_to?(:call) + # If given a proc, defer determining values until later. + index = obj + else + index = obj.numeric? ? obj.to_i : @choices.index(obj) + raise IndexError if (!obj.nil? && index.nil?) || index.to_i.abs > @choices.length - 1 + end + + super(index) + end + + def value + @choices[self] + end + end + end +end diff --git a/lib/compendium/params.rb b/lib/compendium/params.rb index e23f138..dd04d4e 100644 --- a/lib/compendium/params.rb +++ b/lib/compendium/params.rb @@ -26,7 +26,7 @@ def prepare_hash_from_options(params) options.each do |option| begin - klass = "Compendium::#{"#{option.type}Param".classify}".constantize + klass = Compendium::ParamTypes.const_get(option.type.classify) params[option.name] = klass.new(get_default_value(params[option.name], option.default), option.choices) rescue IndexError raise IndexError, "invalid index for #{option_name}" @@ -44,4 +44,4 @@ def get_default_value(current, default) end end end -end \ No newline at end of file +end diff --git a/lib/compendium/presenters.rb b/lib/compendium/presenters.rb new file mode 100644 index 0000000..a4ba5dd --- /dev/null +++ b/lib/compendium/presenters.rb @@ -0,0 +1,12 @@ +module Compendium + module Presenters + require 'compendium/presenters/base' + require 'compendium/presenters/chart' + require 'compendium/presenters/csv' + require 'compendium/presenters/metric' + require 'compendium/presenters/option' + require 'compendium/presenters/query' + require 'compendium/presenters/table' + require 'compendium/presenters/settings' + end +end diff --git a/lib/compendium/presenters/base.rb b/lib/compendium/presenters/base.rb new file mode 100644 index 0000000..4c4b11f --- /dev/null +++ b/lib/compendium/presenters/base.rb @@ -0,0 +1,32 @@ +module Compendium + module Presenters + class Base + def self.presents(name) + define_method(name) do + @object + end + end + + def initialize(template, object) + @object = object + @template = template + end + + def to_s + "#<#{self.class.name}:0x00#{sprintf('%x', object_id << 1)}>" + end + + private + + def method_missing(*args, &block) + return @template.send(*args, &block) if @template.respond_to?(args.first) + super + end + + def respond_to_missing?(*args) + return true if @template.respond_to?(*args) + super + end + end + end +end diff --git a/lib/compendium/presenters/chart.rb b/lib/compendium/presenters/chart.rb new file mode 100644 index 0000000..3d579e8 --- /dev/null +++ b/lib/compendium/presenters/chart.rb @@ -0,0 +1,86 @@ +require 'compendium/presenters/query' +require 'active_support/core_ext/array/extract_options' + +module Compendium + module Presenters + class Chart < Query + attr_reader :data, :params, :container, :chart_provider + attr_accessor :options + + def initialize(template, object, *args, &setup) + super(template, object) + + self.options = args.extract_options! + type, container = args + + if remote? + # If the query hasn't run yet, render a chart that loads its data remotely (ie. through AJAX) + # ie. if rendering a query from a report class directly + @data = query.url + @params = collect_params + else + @data = options[:index] ? results.records[options[:index]] : results + @data = @data.records if @data.is_a?(Compendium::ResultSet) + @data = @data[0...-1] if query.options[:totals] + end + + @container = container || query.name + + initialize_chart_provider(type, &setup) + end + + def render + chart_provider.render(@template, @container) + end + + # You can force the chart to render remote data, even if the query has already run by passing the remote: true option + def remote? + !query.ran? || options.fetch(:remote, false) + end + + private + + def provider + provider = Compendium.config.chart_provider + require "compendium/#{provider.downcase}" + provider.is_a?(Class) ? provider : Compendium::ChartProvider.const_get(provider) + end + + def initialize_chart_provider(type, &setup) + @chart_provider = provider.new(type, @data, @params, &setup) + end + + def collect_params + params = {} + params[:report] = options[:params] if options[:params] + + if remote? && protected_against_csrf? + # If we're loading remotely, and CSRF protection is enabled, + # automatically include the CSRF token in AJAX params + params.merge!(form_authenticity_param) + end + + params + end + + def protected_against_csrf? + @template.controller.send(:protect_against_forgery?) + end + + def form_authenticity_param + return {} unless protected_against_csrf? + { @template.controller.request_forgery_protection_token => @template.controller.send(:form_authenticity_token) } + end + + def method_missing(name, *args, &block) + return chart_provider.send(name, *args, &block) if chart_provider.respond_to?(name) + super + end + + def respond_to_missing?(name, include_private = false) + return true if chart_provider.respond_to?(name) + super + end + end + end +end diff --git a/lib/compendium/presenters/csv.rb b/lib/compendium/presenters/csv.rb new file mode 100644 index 0000000..af8695d --- /dev/null +++ b/lib/compendium/presenters/csv.rb @@ -0,0 +1,34 @@ +require 'csv' + +module Compendium + module Presenters + class CSV < Table + def initialize(object, &block) + super(nil, object, &block) + end + + def render + ::CSV.generate do |csv| + csv << headings.map { |_, val| formatted_heading(val) } + + records.each do |row| + csv << row.map { |key, val| formatted_value(key, val) } + end + + if totals_row? + totals[totals.keys.first] = translate(:total) + csv << totals.map do |key, val| + formatted_value(key, val) unless settings.skipped_total_cols.include?(key.to_sym) + end + end + end + end + + private + + def settings_class + Compendium::Presenters::Settings::Table + end + end + end +end diff --git a/lib/compendium/presenters/metric.rb b/lib/compendium/presenters/metric.rb new file mode 100644 index 0000000..ae384ca --- /dev/null +++ b/lib/compendium/presenters/metric.rb @@ -0,0 +1,34 @@ +module Compendium + module Presenters + class Metric < Base + presents :metric + + delegate :name, :query, :description, :ran?, to: :metric + + def initialize(template, object, options = {}) + super(template, object) + @options = options + end + + def label + @options[:label] || t("#{query}.#{name}") + end + + def description + @options[:description] + end + + def result(number_format = '%0.1f', display_nil_as = :na) + if metric.result + sprintf(number_format, metric.result) + else + t(display_nil_as) + end + end + + def render + @template.render 'compendium/reports/metric', metric: self + end + end + end +end diff --git a/lib/compendium/presenters/option.rb b/lib/compendium/presenters/option.rb new file mode 100644 index 0000000..55bd7a9 --- /dev/null +++ b/lib/compendium/presenters/option.rb @@ -0,0 +1,124 @@ +require 'active_support/core_ext/string/output_safety' + +module Compendium + module Presenters + class Option < Base + MISSING_CHOICES_ERROR = 'choices must be specified'.freeze + + presents :option + delegate :hidden?, to: :option + + def name + t("options.#{option.name}", cascade: { offset: 2 }) + end + + def label(form) + return label_with_accessible_tooltip(form) if option.note? && defined?(AccessibleTooltip) + + out = ActiveSupport::SafeBuffer.new + out << content_tag(:span, label_content(form), class: 'option-label') + out << content_tag(:div, note_text, class: 'option-note') if option.note? + out + end + + def note + return unless option.note? + content_tag(:div, t(note_key), class: 'option-note') + end + + def input(ctx, form) + out = ActiveSupport::SafeBuffer.new + + raise ArgumentError, MISSING_CHOICES_ERROR if missing_choices? + + out << case option.type.to_sym + when :scalar + scalar_field(form) + + when :date + date_field(form) + + when :dropdown + dropdown_field(form, ctx) + + when :boolean, :radio + radio_fields(form) + end + end + + def hidden_field(form) + form.hidden_field option.name + end + + private + + def note_key + return unless option.note? + option.note == true ? :"#{option.name}_note" : option.note + end + + def note_text + return unless option.note? + t("options.#{note_key}", cascade: { offset: 2 }) + end + + def missing_choices? + !option.choices && (option.radio? || option.dropdown?) + end + + def date_field(form, include_time = false) + content_tag('div', class: 'option-date') do + if defined?(CalendarDateSelect) + form.calendar_date_select option.name, time: include_time, popup: 'force' + else + form.text_field option.name + end + end + end + + def scalar_field(form) + content_tag('div', class: 'option-scalar') do + form.text_field option.name + end + end + + def dropdown_field(form, ctx) + choices = option.choices + choices = ctx.instance_exec(&choices) if choices.respond_to?(:call) + + content_tag('div', class: 'option-dropdown') do + form.select option.name, choices, option.options.symbolize_keys + end + end + + def radio_fields(form) + choices = option.radio? ? option.choices : %w(true false) + choices.each.with_object(ActiveSupport::SafeBuffer.new).with_index { |(choice, out), index| out << radio_button(form, choice, index) } + end + + def radio_button(form, label, value) + content_tag('div', class: 'option-radio') do + div_content = ActiveSupport::SafeBuffer.new + div_content << form.radio_button(option.name, value) + div_content << form.label(option.name, t(label), value: value) + end + end + + def label_with_accessible_tooltip(form) + title = t("options.#{option.name}_note_title", default: '', cascade: { offset: 2 }) + tooltip = accessible_tooltip(:help, label: name, title: title) { note_text } + form.label option.name, tooltip + end + + def label_content(form) + case option.type.to_sym + when :boolean, :radio + name + + else + form.label option.name, name + end + end + end + end +end diff --git a/lib/compendium/presenters/query.rb b/lib/compendium/presenters/query.rb new file mode 100644 index 0000000..fc4c574 --- /dev/null +++ b/lib/compendium/presenters/query.rb @@ -0,0 +1,29 @@ +require 'compendium/presenters/base' +require 'compendium/presenters/settings/query' +require 'compendium/presenters/settings/table' + +module Compendium + module Presenters + class Query < Base + presents :query + + def initialize(template, object) + super(template, object) + end + + def render + raise NotImplementedError + end + + private + + def results + query.results + end + + def settings_class + Settings::Query + end + end + end +end diff --git a/lib/compendium/presenters/settings.rb b/lib/compendium/presenters/settings.rb new file mode 100644 index 0000000..6dfddef --- /dev/null +++ b/lib/compendium/presenters/settings.rb @@ -0,0 +1,8 @@ +module Compendium + module Presenters + module Settings + require 'compendium/presenters/settings/query' + require 'compendium/presenters/settings/table' + end + end +end diff --git a/lib/compendium/presenters/settings/query.rb b/lib/compendium/presenters/settings/query.rb new file mode 100644 index 0000000..e8f86dc --- /dev/null +++ b/lib/compendium/presenters/settings/query.rb @@ -0,0 +1,41 @@ +require 'active_support/core_ext/module/delegation' +require 'active_support/core_ext/hash/indifferent_access' + +module Compendium + module Presenters + module Settings + class Query + attr_reader :query + + delegate :[], :fetch, to: :@settings + delegate :report, to: :query, allow_nil: true + + def initialize(query = nil) + @settings = {}.with_indifferent_access + @query = query + end + + def update(&block) + instance_exec(self, &block) + end + + def method_missing(name, *args, &_block) + if block_given? + @settings[name] = yield(*args) + elsif !args.empty? + @settings[name] = args.length == 1 ? args.first : args + elsif name.to_s.end_with?('?') + prefix = name.to_s.gsub(/\?\z/, '') + @settings.key?(prefix) + else + @settings[name] + end + end + + def respond_to_missing?(*) + true + end + end + end + end +end diff --git a/lib/compendium/presenters/settings/table.rb b/lib/compendium/presenters/settings/table.rb new file mode 100644 index 0000000..46db222 --- /dev/null +++ b/lib/compendium/presenters/settings/table.rb @@ -0,0 +1,55 @@ +require 'compendium/presenters/settings/query' + +module Compendium + module Presenters + module Settings + class Table < Query + attr_reader :headings + + def initialize(*) + super + + @headings = {} + + # Set default values for settings + number_format '%0.2f' + table_class 'results' + header_class 'headings' + row_class 'data' + totals_class 'totals' + skipped_total_cols [] + end + + def set_headings(headings) # rubocop:disable Naming/AccessorMethodName + headings.map!(&:to_sym) + @headings = Hash[headings.zip(headings)].with_indifferent_access + end + + def override_heading(*args) + if block_given? + @headings.each do |key, val| + res = yield val.to_s + @headings[key] = res if res + end + else + col, label = args + @headings[col] = label + end + end + + def format(column, &block) + @settings[:formatters] ||= {} + @settings[:formatters][column] = block + end + + def formatters + (@settings[:formatters] || {}) + end + + def skip_total_for(*cols) + @settings[:skipped_total_cols].concat(cols.map(&:to_sym)) + end + end + end + end +end diff --git a/lib/compendium/presenters/table.rb b/lib/compendium/presenters/table.rb new file mode 100644 index 0000000..989cc97 --- /dev/null +++ b/lib/compendium/presenters/table.rb @@ -0,0 +1,104 @@ +require 'compendium/presenters/query' + +module Compendium + module Presenters + class Table < Query + attr_reader :records, :totals, :settings + + def initialize(*) + super + + @records = results.records + + @settings = settings_class.new(query) + @settings.set_headings(results.keys) + @settings.update(&query.table_settings) if query.table_settings + yield @settings if block_given? + + setup_totals if totals_row? + end + + def render + content_tag(:table, class: @settings.table_class) do + table = ActiveSupport::SafeBuffer.new + table << content_tag(:thead, build_row(headings, settings.header_class, :th, &heading_proc)) + table << content_tag(:tbody) do + tbody = ActiveSupport::SafeBuffer.new + records.each { |row| tbody << build_row(row, settings.row_class, &data_proc) } + tbody + end + table << content_tag(:tfoot, build_row(totals, @settings.totals_class, :th, &totals_proc)) if totals_row? + table + end + end + + private + + def headings + @settings.headings + end + + def totals_row? + query.options.fetch(:totals, false) + end + + def data_proc + proc { |key, val| formatted_value(key, val) } + end + + def heading_proc + proc { |_, val| formatted_heading(val) } + end + + def totals_proc + proc { |key, val| formatted_value(key, val) unless settings.skipped_total_cols.include?(key.to_sym) } + end + + def build_row(row, row_class, cell_type = :td) + content_tag(:tr, class: row_class) do + out = ActiveSupport::SafeBuffer.new + + row.each.with_index do |(key, val), i| + val = yield key, val, i if block_given? + out << content_tag(cell_type, val) + end + + out + end + end + + def formatted_heading(heading) + heading.is_a?(Symbol) ? translate(heading) : heading + end + + def formatted_value(name, value) + if @settings.formatters[name] + @settings.formatters[name].call(value) + elsif value.numeric? + if value.zero? && @settings.display_zero_as? + @settings.display_zero_as + else + sprintf(@settings.number_format, value) + end + elsif value.nil? + @settings.display_nil_as + end || value + end + + def translate(value, opts = {}) + opts.reverse_merge!(scope: settings.i18n_scope) if settings.i18n_scope? + opts[:default] = -> (*) { I18n.t(value, scope: 'compendium') } + I18n.t(value, opts) + end + + def setup_totals + @totals = @records.pop + totals[totals.keys.first] = translate(:total) + end + + def settings_class + Settings::Table + end + end + end +end diff --git a/lib/compendium/queries.rb b/lib/compendium/queries.rb new file mode 100644 index 0000000..a117145 --- /dev/null +++ b/lib/compendium/queries.rb @@ -0,0 +1,9 @@ +module Compendium + module Queries + require 'compendium/queries/query' + require 'compendium/queries/collection' + require 'compendium/queries/count' + require 'compendium/queries/sum' + require 'compendium/queries/through' + end +end diff --git a/lib/compendium/queries/collection.rb b/lib/compendium/queries/collection.rb new file mode 100644 index 0000000..411939f --- /dev/null +++ b/lib/compendium/queries/collection.rb @@ -0,0 +1,47 @@ +require 'compendium/queries/query' + +module Compendium + module Queries + # A Collection is a Query which runs once for each in a given set of criteria + class Collection < Query + attr_accessor :collection + + def initialize(*) + super + self.collection = prepare_collection(@options[:collection]) + end + + def run(params, context = self) + collection_values = get_collection_values(context, params) + + results = collection_values.each_with_object({}) do |(key, value), r| + res = collect_results(context, params, key, value) + r[key] = res unless res.empty? + end + + # A CollectionQuery's results will be a ResultSet of ResultSets + @results = Compendium::ResultSet.new(results) + end + + private + + def get_collection_values(context, params) + self.collection = get_associated_query(collection) if collection.is_a?(Symbol) + + if collection.is_a?(Query) + collection.run(params, context) unless collection.ran? + collection.results + elsif collection.is_a?(Proc) + prepare_collection(collection.call(params)) + else + collection + end + end + + def prepare_collection(collection) + return collection if collection.is_a?(Query) || collection.is_a?(Symbol) || collection.is_a?(Proc) + collection.is_a?(Hash) ? collection : Hash[collection.zip(collection)] + end + end + end +end diff --git a/lib/compendium/queries/count.rb b/lib/compendium/queries/count.rb new file mode 100644 index 0000000..ad05073 --- /dev/null +++ b/lib/compendium/queries/count.rb @@ -0,0 +1,24 @@ +require 'compendium/errors' +require 'compendium/queries/query' + +module Compendium + module Queries + # A Count is a Query which runs an SQL count statement + # Often useful in conjunction with a grouped query + class Count < Query + def initialize(*args) + super + + @options.reverse_merge!(order: 'COUNT(*)', reverse: true) + end + + private + + def execute_sql_command(command) + return [] if command.nil? + raise InvalidCommand unless command.respond_to?(:count) + command.count + end + end + end +end diff --git a/lib/compendium/queries/query.rb b/lib/compendium/queries/query.rb new file mode 100644 index 0000000..3e1661e --- /dev/null +++ b/lib/compendium/queries/query.rb @@ -0,0 +1,147 @@ +require 'compendium/result_set' +require 'compendium/params' +require 'compendium/metric' +require 'compendium/presenters/chart' +require 'compendium/presenters/table' +require 'compendium/queries/query/render' +require 'collection_of' + +module Compendium + module Queries + class Query + include Render + + attr_reader :name, :results, :metrics, :filters + attr_accessor :options, :proc, :report, :table_settings + + def initialize(*args) + @report = args.shift if arg_is_report?(args.first) + + raise ArgumentError, "wrong number of arguments (#{args.size + (@report ? 1 : 0)} for 3..4)" unless args.size == 3 + + @name, @options, @proc = args + @metrics = ::Collection[Compendium::Metric] + @filters = ::Collection[Proc] + + options.assert_valid_keys(*valid_keys) + end + + def initialize_clone(*) + super + @metrics = @metrics.clone + @filters = @filters.clone + end + + def run(params, context = self) + if report.is_a?(Class) + # If running a query directly from a class rather than an instance, the class's query should + # not be affected/modified, so run the query without a reference back to the report. + # Otherwise, if the class is subsequently instantiated, the instance will already have results. + dup.tap { |q| q.report = nil }.run(params, context) + else + collect_results(context, params) + collect_metrics(context) + + @results + end + end + + # Get a URL for this query (format: :json set by default) + def url(params = {}) + report.url(params.merge(query: name)) + end + + def add_metric(name, proc, options = {}) + Compendium::Metric.new(name, self.name, proc, options).tap { |m| @metrics << m } + end + + def add_filter(filter) + @filters << filter + end + + def ran? + !@results.nil? + end + alias_method :has_run?, :ran? + + # A query is nil if it has no proc + def nil? + proc.nil? + end + + # A query is empty if it has no results + def empty? + results.blank? + end + + private + + def valid_keys + %i(collection order reverse execute_sql totals) + end + + def collect_results(context, *params) + command = context.instance_exec(*params, &proc) if proc + command = order_command(command) if options[:order] + + results = fetch_results(command) + results = filter_results(results, *params) if filters.any? + @results = Compendium::ResultSet.new(results) if results + end + + def collect_metrics(context) + metrics.each { |m| m.run(context, results) } unless results.empty? + end + + def fetch_results(command) + options.fetch(:execute_sql, true) ? execute_sql_command(command) : command + end + + def filter_results(results, params) + return unless results + + if results.respond_to? :with_indifferent_access + results = results.with_indifferent_access + else + results.map!(&:with_indifferent_access) + end + + filters.each do |f| + results = if f.arity == 2 + f.call(results, params) + else + f.call(results) + end + end + + results + end + + def order_command(command) + return command unless command.respond_to?(:order) + + command = command.order(options[:order]) + command = command.reverse_order if options.fetch(:reverse, false) + command + end + + def execute_sql_command(command) + return [] if command.nil? + command = command.to_sql if command.respond_to?(:to_sql) + execute_query(command) + end + + def execute_query(command) + ::ActiveRecord::Base.connection.select_all(command) + end + + def arg_is_report?(arg) + arg.is_a?(Compendium::Report) || (arg.is_a?(Class) && arg < Compendium::Report) + end + + def get_associated_query(query) + query.is_a?(Query) ? query : report.queries[query] + end + end + end +end diff --git a/lib/compendium/queries/query/render.rb b/lib/compendium/queries/query/render.rb new file mode 100644 index 0000000..450be29 --- /dev/null +++ b/lib/compendium/queries/query/render.rb @@ -0,0 +1,27 @@ +module Compendium + module Queries + class Query + module Render + def render_table(template, *options, &block) + Compendium::Presenters::Table.new(template, self, *options, &block).render unless empty? + end + + def render_csv(&block) + Compendium::Presenters::CSV.new(self, &block).render unless empty? + end + + def render_chart(template, *options, &block) + # A query can be rendered regardless of if it has data or not + # Rendering a chart with no result set builds a chart scaffold which can be updated through AJAX + chart(template, *options, &block).render + end + + # Allow access to the chart object without having to explicitly render it + def chart(template, *options, &block) + # Access the actual chart object + Compendium::Presenters::Chart.new(template, self, *options, &block) + end + end + end + end +end diff --git a/lib/compendium/queries/sum.rb b/lib/compendium/queries/sum.rb new file mode 100644 index 0000000..3b24478 --- /dev/null +++ b/lib/compendium/queries/sum.rb @@ -0,0 +1,33 @@ +require 'compendium/errors' +require 'compendium/queries/query' + +module Compendium + module Queries + # A Sum is a Query which runs an SQL sum statement (with a given column) + # Often useful in conjunction with a grouped query and counter cache + # (alternately, see Count) + class Sum < Query + attr_accessor :column + + def initialize(*args) + @report = args.shift if arg_is_report?(args.first) + @column = args.slice!(1) + super(*args) + + @options.reverse_merge!(order: "SUM(#{@column})", reverse: true) + end + + private + + def valid_keys + super.concat([:sum]) + end + + def execute_sql_command(command) + return [] if command.nil? + raise InvalidCommand unless command.respond_to?(:sum) + command.sum(column) + end + end + end +end diff --git a/lib/compendium/queries/through.rb b/lib/compendium/queries/through.rb new file mode 100644 index 0000000..df83b88 --- /dev/null +++ b/lib/compendium/queries/through.rb @@ -0,0 +1,58 @@ +require 'compendium/queries/query' + +module Compendium + module Queries + # A Through is a Query which distills data from previously run queries (one or multiple) + class Through < Query + attr_accessor :through + + def initialize(*args) + @report = args.shift if arg_is_report?(args.first) + @through = args.slice!(1) + super(*args) + end + + private + + def valid_keys + super.concat([:through]) + end + + def collect_results(context, params) + results = collect_through_query_results(params, context) + + # If none of the through queries have any results, we shouldn't try to execute the query, because it + # depends on the results of its parents. + return @results = Compendium::ResultSet.new([]) if any_results?(results) + + # If the proc collects two arguments, pass results and params, otherwise just results + args = !proc || proc.arity == 1 ? [results] : [results, params] + + super(context, *args) + end + + def fetch_results(command) + command + end + + def collect_through_query_results(params, context) + results = {} + + queries = Array.wrap(through).map(&method(:get_associated_query)) + + queries.each do |q| + q.run(params, context) unless q.ran? + results[q.name] = q.results.records.dup + end + + results = results[queries.first.name] if queries.size == 1 + results + end + + def any_results?(results) + results = results.values if results.is_a? Hash + results.all?(&:blank?) + end + end + end +end diff --git a/lib/compendium/query.rb b/lib/compendium/query.rb deleted file mode 100644 index 1194017..0000000 --- a/lib/compendium/query.rb +++ /dev/null @@ -1,156 +0,0 @@ -require 'compendium/result_set' -require 'compendium/params' -require 'compendium/metric' -require 'compendium/presenters/chart' -require 'compendium/presenters/table' -require 'collection_of' - -module Compendium - class Query - attr_reader :name, :results, :metrics, :filters - attr_accessor :options, :proc, :report, :table_settings - - def initialize(*args) - @report = args.shift if arg_is_report?(args.first) - - raise ArgumentError, "wrong number of arguments (#{args.size + (@report ? 1 : 0)} for 3..4)" unless args.size == 3 - - @name, @options, @proc = args - @metrics = ::Collection[Metric] - @filters = ::Collection[Proc] - end - - def initialize_clone(*) - super - @metrics = @metrics.clone - @filters = @filters.clone - end - - def run(params, context = self) - if report.is_a?(Class) - # If running a query directly from a class rather than an instance, the class's query should - # not be affected/modified, so run the query without a reference back to the report. - # Otherwise, if the class is subsequently instantiated, the instance will already have results. - dup.tap{ |q| q.report = nil }.run(params, context) - else - collect_results(context, params) - collect_metrics(context) - - @results - end - end - - # Get a URL for this query (format: :json set by default) - def url(params = {}) - report.url(params.merge(query: self.name)) - end - - def add_metric(name, proc, options = {}) - Compendium::Metric.new(name, self.name, proc, options).tap { |m| @metrics << m } - end - - def add_filter(filter) - @filters << filter - end - - def render_table(template, *options, &block) - Compendium::Presenters::Table.new(template, self, *options, &block).render unless empty? - end - - def render_csv(&block) - Compendium::Presenters::CSV.new(self, &block).render unless empty? - end - - # Allow access to the chart object without having to explicitly render it - def chart(template, *options, &block) - # Access the actual chart object - Compendium::Presenters::Chart.new(template, self, *options, &block) - end - - def render_chart(template, *options, &block) - # A query can be rendered regardless of if it has data or not - # Rendering a chart with no result set builds a chart scaffold which can be updated through AJAX - chart(template, *options, &block).render - end - - def ran? - !@results.nil? - end - alias_method :has_run?, :ran? - - # A query is nil if it has no proc - def nil? - proc.nil? - end - - # A query is empty if it has no results - def empty? - results.blank? - end - - private - - def collect_results(context, *params) - command = context.instance_exec(*params, &proc) if proc - command = order_command(command) if options[:order] - - results = fetch_results(command) - results = filter_results(results, *params) if filters.any? - @results = ResultSet.new(results) if results - end - - def collect_metrics(context) - metrics.each{ |m| m.run(context, results) } unless results.empty? - end - - def fetch_results(command) - (options.fetch(:collect, nil) == :active_record) ? command : execute_command(command) - end - - def filter_results(results, params) - return unless results - - if results.respond_to? :with_indifferent_access - results = results.with_indifferent_access - else - results.map! &:with_indifferent_access - end - - filters.each do |f| - if f.arity == 2 - results = f.call(results, params) - else - results = f.call(results) - end - end - - results - end - - def order_command(command) - return command unless command.respond_to?(:order) - - command = command.order(options[:order]) - command = command.reverse_order if options.fetch(:reverse, false) - command - end - - def execute_command(command) - return [] if command.nil? - command = command.to_sql if command.respond_to?(:to_sql) - execute_query(command) - end - - def execute_query(command) - ::ActiveRecord::Base.connection.select_all(command) - end - - def arg_is_report?(arg) - arg.is_a?(Report) || (arg.is_a?(Class) && arg < Report) - end - - def get_associated_query(query) - query.is_a?(Query) ? query : report.queries[query] - end - end -end diff --git a/lib/compendium/report.rb b/lib/compendium/report.rb index b89094e..76a9d02 100644 --- a/lib/compendium/report.rb +++ b/lib/compendium/report.rb @@ -3,6 +3,7 @@ require 'active_support/core_ext/module/delegation' require 'active_support/core_ext/string/inflections' require 'compendium/dsl' +require 'compendium/context_wrapper' module Compendium class Report @@ -22,16 +23,16 @@ def inherited(report) # Each Report object has its own Params class so that validations can be added without affecting other # reports. However, validations also need to be inherited, so when inheriting a report, subclass its # params_class - report.params_class = Class.new(self.params_class) - report.params_class.class_eval %Q{ + report.params_class = Class.new(params_class) + report.params_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def self.model_name ActiveModel::Name.new(Compendium::Params, Compendium, "compendium.params.#{report.name.underscore rescue 'report'}") end - } + RUBY end def report_name - name.underscore.gsub(/_report$/,'').to_sym + name.underscore.gsub(/_report$/, '').to_sym end # Get a URL for this report (format: :json set by default) @@ -61,8 +62,8 @@ def respond_to_missing?(name, include_private = false) private def path_helper(params) - raise ActionController::RoutingError, "compendium_reports_run_path must be defined" unless route_helper_defined? - Rails.application.routes.url_helpers.compendium_reports_run_path(self.report_name, params.reverse_merge(format: :json)) + raise ActionController::RoutingError, 'compendium_reports_run_path must be defined' unless route_helper_defined? + Rails.application.routes.url_helpers.compendium_reports_run_path(report_name, params.reverse_merge(format: :json)) end def route_helper_defined? @@ -96,17 +97,17 @@ def run(context = nil, options = {}) queries end - queries_to_run.each{ |q| self.results[q.name] = q.run(params, ContextWrapper.wrap(context, self)) } + queries_to_run.each { |q| results[q.name] = q.run(params, ContextWrapper.wrap(context, self)) } self end def metrics - Collection[Metric, queries.map{ |q| q.metrics.to_a }.flatten] + Collection[Metric, queries.map { |q| q.metrics.to_a }.flatten] end def exports?(type) - return exporters[type.to_sym] + exporters[type.to_sym] end private @@ -116,17 +117,20 @@ def exports?(type) def method_missing(name, *args, &block) prefix = name.to_s.sub(/(?:_results|\?)\Z/, '').to_sym - return queries[name] if queries.keys.include?(name) - return results[prefix] if name.to_s.end_with?('_results') && queries.keys.include?(prefix) - return params[name] if options.keys.include?(name) - return !!params[prefix] if name.to_s.end_with?('?') && options.keys.include?(prefix) + return queries[name] if queries.key?(name) + return results[prefix] if name.to_s.end_with?('_results') && queries.key?(prefix) + return params[name] if options.key?(name) + return params[prefix] if name.to_s.end_with?('?') && options.key?(prefix) super end def respond_to_missing?(name, include_private = false) - prefix = name.to_s.sub(/_results\Z/, '').to_sym - return true if queries.keys.include?(name) - return true if name.to_s.end_with?('_results') && queries.keys.include?(prefix) + prefix = name.to_s.sub(/(?:_results|\?)\Z/, '').to_sym + + return true if queries.key?(name) + return true if name.to_s.end_with?('_results') && queries.key?(prefix) + return true if options.key?(name) + return true if name.to_s.end_with?('?') && options.key?(prefix) super end end diff --git a/lib/compendium/result_set.rb b/lib/compendium/result_set.rb index 409363b..8fd041b 100644 --- a/lib/compendium/result_set.rb +++ b/lib/compendium/result_set.rb @@ -3,10 +3,11 @@ module Compendium class ResultSet - delegate :first, :last, :to_a, :empty?, :each, :map, :inject, :select, :detect, :[], :count, :length, :size, :==, to: :records + include Enumerable + delegate :each, :empty?, :length, :size, :==, to: :records attr_reader :records - alias :all :records + alias_method :all, :records def initialize(records) @records = if records.respond_to?(:map) @@ -26,7 +27,7 @@ def keys def as_json(options = {}) return records unless records.first.respond_to?(:except) - records.map{ |r| r.except(*options[:except]) } + records.map { |r| r.except(*options[:except]) } end end end diff --git a/lib/compendium/sum_query.rb b/lib/compendium/sum_query.rb deleted file mode 100644 index 9963ca2..0000000 --- a/lib/compendium/sum_query.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'compendium/errors' -require 'compendium/query' - -module Compendium - # A SumQuery is a Query which runs an SQL sum statement (with a given column) - # Often useful in conjunction with a grouped query and counter cache - # (alternately, see CountQuery) - class SumQuery < Query - - attr_accessor :column - - def initialize(*args) - @report = args.shift if arg_is_report?(args.first) - @column = args.slice!(1) - super(*args) - - @options.reverse_merge!(order: "SUM(#{@column})", reverse: true) - end - - private - - def execute_command(command) - return [] if command.nil? - raise InvalidCommand unless command.respond_to?(:sum) - command.sum(column) - end - end -end diff --git a/lib/compendium/through_query.rb b/lib/compendium/through_query.rb deleted file mode 100644 index 164801d..0000000 --- a/lib/compendium/through_query.rb +++ /dev/null @@ -1,51 +0,0 @@ -require 'compendium/query' - -module Compendium - class ThroughQuery < Query - attr_accessor :through - - def initialize(*args) - @report = args.shift if arg_is_report?(args.first) - @through = args.slice!(1) - super(*args) - end - - private - - def collect_results(context, params) - results = collect_through_query_results(params, context) - - # If none of the through queries have any results, we shouldn't try to execute the query, because it - # depends on the results of its parents. - return @results = ResultSet.new([]) if any_results?(results) - - # If the proc collects two arguments, pass results and params, otherwise just results - args = !proc || proc.arity == 1 ? [results] : [results, params] - - super(context, *args) - end - - def fetch_results(command) - command - end - - def collect_through_query_results(params, context) - results = {} - - queries = Array.wrap(through).map(&method(:get_associated_query)) - - queries.each do |q| - q.run(params, context) unless q.ran? - results[q.name] = q.results.records.dup - end - - results = results[queries.first.name] if queries.size == 1 - results - end - - def any_results?(results) - results = results.values if results.is_a? Hash - results.all?(&:blank?) - end - end -end diff --git a/lib/compendium/version.rb b/lib/compendium/version.rb index 4c7424f..cabce79 100644 --- a/lib/compendium/version.rb +++ b/lib/compendium/version.rb @@ -1,3 +1,3 @@ module Compendium - VERSION = '1.2.2' + VERSION = '1.3.0'.freeze end diff --git a/spec/collection_query_spec.rb b/spec/collection_query_spec.rb deleted file mode 100644 index db99a9a..0000000 --- a/spec/collection_query_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -require 'spec_helper' -require 'compendium/collection_query' - -describe Compendium::CollectionQuery do - let(:collection) { { one: 1, two: 2, three: 3 } } - subject { described_class.new(:collection_query, { collection: collection }, -> _, key, item { [item * 2] }) } - - before { allow_any_instance_of(Compendium::Query).to receive(:execute_query) { |instance, cmd| cmd } } - - describe "#run" do - context do - before { subject.run(nil) } - - specify { expect(subject.results).to be_a Compendium::ResultSet } - specify { expect(subject.results).to eq({ one: [2], two: [4], three: [6] }) } - - context "when given an array instead of a hash" do - let(:collection) { [1, 2, 3] } - - specify { expect(subject.results).to be_a Compendium::ResultSet } - specify { expect(subject.results).to eq({ 1 => [2], 2 => [4], 3 => [6] }) } - end - end - - it "should not collect empty results" do - subject.proc = -> _, key, item { [item] if item > 2 } - subject.run(nil) - expect(subject.results).to eq({ three: [3] }) - end - - context "when given another query" do - let(:q) { Compendium::Query.new(:q, {}, -> * { { one: 1, two: 2, three: 3 } }) } - subject { described_class.new(:collection, { collection: q }, -> _, key, item { [ item * 2 ] }) } - - before { subject.run(nil) if RSpec.current_example.metadata.fetch(:run_query, true) } - - specify { expect(subject.results).to eq({ one: [2], two: [4], three: [6] }) } - - it "should not re-run the query if it has already ran", run_query: false do - q.run(nil) - expect(q).not_to receive(:run) - subject.run(nil) - end - end - - context 'when given a proc' do - let(:proc) { -> * { [1, 2, 3] } } - subject { described_class.new(:collection, { collection: proc }, -> _, key, item { [ item * 2 ] }) } - - it 'should use the collection from the proc' do - subject.run(nil) - expect(subject.results).to eq({ 1 => [2], 2 => [4], 3 => [6] }) - end - - context do - let(:proc) { -> * { raise ArgumentError } } - it 'should not run the proc until runtime' do - expect { subject }.to_not raise_error - end - end - end - end -end diff --git a/spec/context_wrapper_spec.rb b/spec/compendium/context_wrapper_spec.rb similarity index 72% rename from spec/context_wrapper_spec.rb rename to spec/compendium/context_wrapper_spec.rb index e5501de..206ee01 100644 --- a/spec/context_wrapper_spec.rb +++ b/spec/compendium/context_wrapper_spec.rb @@ -24,8 +24,8 @@ def wrapper_num end end -describe Compendium::ContextWrapper do - describe ".wrap" do +RSpec.describe Compendium::ContextWrapper do + describe '.wrap' do let(:w1) { Wrapper1.new } let(:w2) { Wrapper2.new } let(:w3) { Wrapper3.new } @@ -39,32 +39,34 @@ def wrapper_num specify { expect(subject.test_val).to eq(123) } specify { expect(subject.wrapped).to eq(true) } - it "should not affect the original objects" do + it 'does not affect the original objects' do subject - expect(w1).not_to respond_to :wrapped - expect(w2).not_to respond_to :test_val + expect(w1).to_not respond_to :wrapped + expect(w2).to_not respond_to :test_val end - it "should yield a block if given" do + it 'yields a block if given' do expect(described_class.wrap(w2, w1) { test_val }).to eq(123) end - context "overriding methods" do + context 'overriding methods' do subject { described_class.wrap(w4, w3) } + specify { expect(subject.wrapper_num).to eq(4) } end - context "nested wrapping" do + context 'nested wrapping' do let(:inner) { described_class.wrap(w2, w1) } + subject { described_class.wrap(inner, w3) } it { is_expected.to respond_to :test_val } it { is_expected.to respond_to :wrapped } it { is_expected.to respond_to :wrapper_num } - it "should not extend the inner wrap" do + it 'does not extend the inner wrap' do subject - expect(inner).not_to respond_to :wrapper_num + expect(inner).to_not respond_to :wrapper_num end end end diff --git a/spec/compendium/dsl/exports_spec.rb b/spec/compendium/dsl/exports_spec.rb new file mode 100644 index 0000000..6cf71fd --- /dev/null +++ b/spec/compendium/dsl/exports_spec.rb @@ -0,0 +1,29 @@ +require 'compendium' +require 'compendium/dsl' + +RSpec.describe Compendium::DSL::Exports do + subject do + Class.new do + extend Compendium::DSL::Exports + inheritable_attr :exporters, default: {} + end + end + + describe '#exports' do + it 'does not have any exporters by default' do + expect(subject.exporters).to be_empty + end + + it 'sets the export to true if no options are given' do + subject.exports :csv + expect(subject.exporters[:csv]).to eq(true) + end + + it 'saves any given options' do + subject.exports :csv, :main_query + subject.exports :pdf, :foo, :bar + expect(subject.exporters[:csv]).to eq(:main_query) + expect(subject.exporters[:pdf]).to eq([:foo, :bar]) + end + end +end diff --git a/spec/compendium/dsl/filter_spec.rb b/spec/compendium/dsl/filter_spec.rb new file mode 100644 index 0000000..d2837a8 --- /dev/null +++ b/spec/compendium/dsl/filter_spec.rb @@ -0,0 +1,39 @@ +require 'compendium' +require 'compendium/dsl' + +RSpec.describe Compendium::DSL::Filter do + subject do + Class.new do + extend Compendium::DSL + end + end + + describe '#filter' do + let(:filter_proc) { -> { :filter } } + + it 'adds a filter to the given query' do + subject.query :test + subject.filter :test, &filter_proc + expect(subject.queries[:test].filters).to include filter_proc + end + + it 'raises an error if there is no query of the given name' do + expect { subject.filter :test, &filter_proc }.to raise_error(ArgumentError, 'query test is not defined') + end + + it 'allows multiple filters to be defined for the same query' do + subject.query :test + subject.filter :test, &filter_proc + subject.filter :test, &-> { :another_filter } + expect(subject.queries[:test].filters.count).to eq(2) + end + + it 'allows a filter to be applied to multiple queries at once' do + subject.query :query1 + subject.query :query2 + subject.filter :query1, :query2, &filter_proc + expect(subject.queries[:query1].filters).to include filter_proc + expect(subject.queries[:query2].filters).to include filter_proc + end + end +end diff --git a/spec/compendium/dsl/metric_spec.rb b/spec/compendium/dsl/metric_spec.rb new file mode 100644 index 0000000..f118a3f --- /dev/null +++ b/spec/compendium/dsl/metric_spec.rb @@ -0,0 +1,56 @@ +require 'compendium' +require 'compendium/dsl' + +RSpec.describe Compendium::DSL::Metric do + subject do + Class.new do + extend Compendium::DSL + end + end + + describe '#metric' do + let(:metric_proc) { -> { :metric } } + + before do + subject.query :test + subject.metric :test_metric, metric_proc, through: :test + end + + it 'adds a metric to the given query' do + expect(subject.queries[:test].metrics.first.name).to eq(:test_metric) + end + + it 'sets the metric command' do + expect(subject.queries[:test].metrics.first.command).to eq(metric_proc) + end + + context 'when through is specified' do + it 'raises an error if specified for an invalid query' do + expect { subject.metric :test_metric, metric_proc, through: :fake }.to raise_error ArgumentError, 'query fake is not defined' + end + + it 'allows metrics to be defined with a block' do + subject.metric :block_metric, through: :test do + 123 + end + + expect(subject.queries[:test].metrics[:block_metric].run(self, nil)).to eq(123) + end + + it 'allows metrics to be defined with a lambda' do + subject.metric :block_metric, -> (*) { 123 }, through: :test + expect(subject.queries[:test].metrics[:block_metric].run(self, nil)).to eq(123) + end + end + + context 'when through is not specified' do + before { subject.metric(:no_through_metric) { |data| data } } + + specify { expect(subject.queries).to include :__metric_no_through_metric } + + it 'returns the result of the query as the result of the metric' do + expect(subject.queries[:__metric_no_through_metric].metrics[:no_through_metric].run(self, [123])).to eq(123) + end + end + end +end diff --git a/spec/compendium/dsl/option_spec.rb b/spec/compendium/dsl/option_spec.rb new file mode 100644 index 0000000..d284202 --- /dev/null +++ b/spec/compendium/dsl/option_spec.rb @@ -0,0 +1,51 @@ +require 'compendium' +require 'compendium/dsl' +require 'compendium/option' + +RSpec.describe Compendium::DSL::Option do + subject do + Class.new do + extend Compendium::DSL::Option + inheritable_attr :options, default: ::Collection[Compendium::Option] + end + end + + describe '#option' do + before { subject.option :starting_on, :date } + + specify { expect(subject.options).to include :starting_on } + specify { expect(subject.options[:starting_on]).to be_date } + + it 'allows previously defined options to be redefined' do + expect { subject.option :starting_on, :boolean }.to change { subject.options[:starting_on].type }. + from('date').to('boolean') + end + + it 'allows overriding default value' do + proc = -> { Date.new(2013, 6, 1) } + subject.option :starting_on, :date, default: proc + expect(subject.options[:starting_on].default).to eq(proc) + end + + it 'adds validations' do + subject.option :foo, :scalar, validates: { presence: true } + expect(subject.params_class.validators_on(:foo)).to_not be_empty + end + + it 'does not add validations if no validates option is given' do + expect(subject.params_class).to_not receive :validates + subject.option :foo, :scalar + end + + it 'does not bleed overridden options into the superclass' do + r = Class.new(subject) + r.option :starting_on, :boolean + r.option :new, :date + expect(subject.options[:starting_on]).to be_date + end + + it 'requires a type be given' do + expect { subject.option :foo }.to raise_error(ArgumentError) + end + end +end diff --git a/spec/compendium/dsl/query_spec.rb b/spec/compendium/dsl/query_spec.rb new file mode 100644 index 0000000..935faa1 --- /dev/null +++ b/spec/compendium/dsl/query_spec.rb @@ -0,0 +1,156 @@ +require 'compendium/dsl' + +RSpec.describe Compendium::DSL::Query do + let(:proc1) { -> { :proc1 } } + let(:proc2) { -> { :proc2 } } + let(:report_class) do + proc1 = proc1 + + Class.new(Compendium::Report) do + query :test, &proc1 + end + end + + subject { report_class } + + describe '#query' do + specify { expect(subject.queries).to include :test } + + it 'relates the new query back to the report instance' do + r = subject.new + expect(r.test.report).to eq(r) + end + + it 'relates a query to the report class' do + expect(subject.test.report).to eq(subject) + end + + context 'when the query was previously defined' do + before { subject.query :test_query } + + it 'allows previously defined queries to be redefined by name' do + subject.test_query foo: :bar + expect(subject.queries[:test_query].options).to eq(foo: :bar) + end + + it 'allows previously defined queries to be accessed by name' do + expect(subject.test_query).to eq(subject.queries[:test_query]) + end + end + + context 'when overriding an existing query' do + before do + subject.query :test, &proc2 + subject.query :another_test, count: true + end + + it 'replaces the existing query' do + expect(subject.queries.count).to eq(2) + end + + it 'only has one query with each name' do + expect(subject.queries.map(&:name)).to match_array([:test, :another_test]) + end + + it 'uses the new proc' do + expect(subject.test.proc).to eq(proc2) + end + + it 'does not allow replacing a query with a different type' do + expect { subject.query :test, count: true }.to raise_error(Compendium::Queries::CannotRedefineType) + expect(subject.test).to be_instance_of Compendium::Queries::Query + end + + it 'allows replacing a query with the same type' do + subject.query :another_test, count: true, &proc2 + expect(subject.another_test.proc).to eq(proc2) + expect(subject.another_test).to be_instance_of Compendium::Queries::Count + end + end + + context 'when given a through option' do + before { report_class.query :through, through: :test } + + subject { report_class.queries[:through] } + + specify { is_expected.to be_a Compendium::Queries::Through } + specify { expect(subject.through).to eq([:test]) } + end + + context 'when given a collection option' do + subject { report_class.queries[:collection] } + + context 'that is an enumerable' do + before { report_class.query :collection, collection: [] } + + it { is_expected.to be_a Compendium::Queries::Collection } + end + + context 'that is a symbol' do + let(:query) { double('Query') } + + before do + allow_any_instance_of(Compendium::Queries::Query).to receive(:get_associated_query).with(:query).and_return(query) + report_class.query :collection, collection: :query + end + + specify { expect(subject.collection).to eq(:query) } + end + + context 'that is a query' do + let(:query) { Compendium::Queries::Query.new(:query, {}, -> {}) } + + before { report_class.query :collection, collection: query } + + specify { expect(subject.collection).to eq(query) } + end + end + + context 'when given a count option' do + subject { report_class.queries[:counted] } + + context 'set to true' do + before { report_class.query :counted, count: true } + + it { is_expected.to be_a Compendium::Queries::Count } + end + + context 'set to false' do + before { report_class.query :counted, count: false } + + it { is_expected.to be_a Compendium::Queries::Query } + it { is_expected.to_not be_a Compendium::Queries::Count } + end + end + + context 'when given a sum option' do + subject { report_class.queries[:summed] } + + context 'set to a truthy value' do + before { report_class.query :summed, sum: :assoc_count } + + it { is_expected.to be_a Compendium::Queries::Sum } + specify { expect(subject.column).to eq(:assoc_count) } + end + + context 'set to false' do + before { report_class.query :summed, sum: false } + + it { is_expected.to be_a Compendium::Queries::Query } + it { is_expected.to_not be_a Compendium::Queries::Sum } + end + end + end + + describe '#chart' do + before { subject.chart(:chart) } + + specify { expect(subject.queries).to include :chart } + end + + describe '#data' do + before { subject.data(:data) } + + specify { expect(subject.queries).to include :data } + end +end diff --git a/spec/compendium/dsl/table_spec.rb b/spec/compendium/dsl/table_spec.rb new file mode 100644 index 0000000..50a05ad --- /dev/null +++ b/spec/compendium/dsl/table_spec.rb @@ -0,0 +1,32 @@ +require 'compendium' +require 'compendium/dsl' + +RSpec.describe Compendium::DSL::Table do + subject do + Class.new do + extend Compendium::DSL + end + end + + describe '#table' do + let(:table_proc) { -> { display_nil_as 'na' } } + + it 'adds table settings to the given query' do + subject.query :test + subject.table :test, &table_proc + expect(subject.queries[:test].table_settings).to eq(table_proc) + end + + it 'raises an error if there is no query of the given name' do + expect { subject.table :test, &table_proc }.to raise_error(ArgumentError, 'query test is not defined') + end + + it 'allows table settings to be applied to multiple queries at once' do + subject.query :query1 + subject.query :query2 + subject.table :query1, :query2, &table_proc + expect(subject.queries[:query1].table_settings).to eq(table_proc) + expect(subject.queries[:query2].table_settings).to eq(table_proc) + end + end +end diff --git a/spec/compendium/metric_spec.rb b/spec/compendium/metric_spec.rb new file mode 100644 index 0000000..33b49a2 --- /dev/null +++ b/spec/compendium/metric_spec.rb @@ -0,0 +1,82 @@ +require 'compendium/metric' + +class MetricContext + def calculate(data) + data.first.first + end +end + +RSpec.describe Compendium::Metric do + let(:ctx) { MetricContext.new } + let(:data) { [[1, 2, 3], [4, 5, 6]] } + + subject { described_class.new(:test_metric, :query, nil) } + + describe '#run' do + it 'delegates the command to the context when the command is a symbol' do + subject.command = :calculate + expect(subject.run(ctx, data)).to eq(1) + end + + it 'calls the command when it is a proc' do + subject.command = -> (d) { d.flatten.inject(:+) } + expect(subject.run(ctx, data)).to eq(21) + end + + it 'allows procs that refer back to the context' do + subject.command = -> (d) { calculate(d) * 2 } + expect(subject.run(ctx, data)).to eq(2) + end + + context 'when an if proc is given' do + before { subject.command = -> (*) { 100 } } + + it 'calculates the metric if the proc evaluates to true' do + subject.options[:if] = -> { true } + expect(subject.run(ctx, data)).to eq(100) + end + + it 'does not calculate the metric if the proc evaluates to false' do + subject.options[:if] = -> { false } + expect(subject.run(ctx, data)).to be_nil + end + + it 'sets the result to nil if the proc evaluates to false' do + subject.options[:if] = -> { false } + subject.result = 123 + expect { subject.run(ctx, data) }.to change { subject.result }.from(123).to(nil) + end + end + + context 'when an unless proc is given' do + before { subject.command = -> (*) { 100 } } + + it 'calculates the metric if the proc evaluates to false' do + subject.options[:unless] = -> { false } + expect(subject.run(ctx, data)).to eq(100) + end + + it 'does not calculate the metric if the proc evaluates to true' do + subject.options[:unless] = -> { true } + expect(subject.run(ctx, data)).to be_nil + end + + it 'sets the result to nil if the proc evaluates to true' do + subject.options[:unless] = -> { true } + subject.result = 123 + expect { subject.run(ctx, data) }.to change { subject.result }.from(123).to(nil) + end + end + end + + describe '#ran?' do + it 'returns true if there are any results' do + allow(subject).to receive_messages(result: 123) + expect(subject).to have_ran + end + + it 'returns false if there are no results' do + expect(subject).to_not have_ran + end + end +end diff --git a/spec/compendium/option_spec.rb b/spec/compendium/option_spec.rb new file mode 100644 index 0000000..6bfc3fd --- /dev/null +++ b/spec/compendium/option_spec.rb @@ -0,0 +1,21 @@ +require 'compendium/option' + +RSpec.describe Compendium::Option do + it 'raises an ArgumentError if no name is given' do + expect { described_class.new }.to raise_error ArgumentError + end + + it 'raises an ArgumentError if no type is given' do + expect { described_class.new(name: 'foo').type }.to raise_error ArgumentError + end + + it 'sets up type predicates from the type option' do + option = described_class.new(name: :option, type: :date) + expect(option).to be_date + end + + it 'sets options if given' do + option = described_class.new(name: :option, type: :scalar, foo: 1, bar: 2, baz: 3) + expect(option.options).to match(foo: 1, bar: 2, baz: 3) + end +end diff --git a/spec/compendium/param_types/boolean_spec.rb b/spec/compendium/param_types/boolean_spec.rb new file mode 100644 index 0000000..c9c872b --- /dev/null +++ b/spec/compendium/param_types/boolean_spec.rb @@ -0,0 +1,55 @@ +require 'compendium/param_types/boolean' + +RSpec.describe Compendium::ParamTypes::Boolean do + subject { described_class.new(true) } + + it { is_expected.to_not be_scalar } + it { is_expected.to be_boolean } + it { is_expected.to_not be_date } + it { is_expected.to_not be_dropdown } + it { is_expected.to_not be_radio } + + it 'passes along 0 and 1' do + expect(described_class.new(0)).to eq(0) + expect(described_class.new(1)).to eq(1) + end + + it 'converts a numeric string to a number' do + expect(described_class.new('0')).to eq(0) + expect(described_class.new('1')).to eq(1) + end + + it 'returns 0 for a truthy value' do + expect(described_class.new(true)).to eq(0) + expect(described_class.new(:abc)).to eq(0) + end + + it 'returns 1 for a falsey value' do + expect(described_class.new(false)).to eq(1) + expect(described_class.new(nil)).to eq(1) + end + + describe '#value' do + it 'returns true for a truthy value' do + expect(described_class.new(true).value).to eq(true) + expect(described_class.new(:abc).value).to eq(true) + expect(described_class.new(0).value).to eq(true) + end + + it 'returns false for a falsey value' do + expect(described_class.new(false).value).to eq(false) + expect(described_class.new(nil).value).to eq(false) + expect(described_class.new(1).value).to eq(false) + end + end + + describe '#!' do + it 'returns false if the boolean is true' do + expect(!described_class.new(true)).to eq(false) + end + + it 'returns true if the boolean is false' do + expect(!described_class.new(false)).to eq(true) + end + end +end diff --git a/spec/compendium/param_types/date_spec.rb b/spec/compendium/param_types/date_spec.rb new file mode 100644 index 0000000..3d34d50 --- /dev/null +++ b/spec/compendium/param_types/date_spec.rb @@ -0,0 +1,22 @@ +require 'compendium/param_types/date' + +RSpec.describe Compendium::ParamTypes::Date do + subject { described_class.new(Date.today) } + + it { is_expected.to_not be_scalar } + it { is_expected.to_not be_boolean } + it { is_expected.to be_date } + it { is_expected.to_not be_dropdown } + it { is_expected.to_not be_radio } + + it 'converts date strings to date objects' do + p = described_class.new('2010-05-20') + expect(p).to eq(Date.new(2010, 5, 20)) + end + + it 'converts other date/time formats to date objects' do + expect(described_class.new(DateTime.new(2010, 5, 20, 10, 30, 59))).to eq(Date.new(2010, 5, 20)) + expect(described_class.new(Time.new(2010, 5, 20, 10, 30, 59))).to eq(Date.new(2010, 5, 20)) + expect(described_class.new(Date.new(2010, 5, 20))).to eq(Date.new(2010, 5, 20)) + end +end diff --git a/spec/compendium/param_types/dropdown_spec.rb b/spec/compendium/param_types/dropdown_spec.rb new file mode 100644 index 0000000..4caf3cb --- /dev/null +++ b/spec/compendium/param_types/dropdown_spec.rb @@ -0,0 +1,11 @@ +require 'compendium/param_types/dropdown' + +RSpec.describe Compendium::ParamTypes::Dropdown do + subject { described_class.new(0, %w(a b c)) } + + it { is_expected.to_not be_scalar } + it { is_expected.to_not be_boolean } + it { is_expected.to_not be_date } + it { is_expected.to be_dropdown } + it { is_expected.to_not be_radio } +end diff --git a/spec/compendium/param_types/param_spec.rb b/spec/compendium/param_types/param_spec.rb new file mode 100644 index 0000000..10dc05d --- /dev/null +++ b/spec/compendium/param_types/param_spec.rb @@ -0,0 +1,17 @@ +require 'compendium/param_types/param' + +RSpec.describe Compendium::ParamTypes::Param do + subject { described_class.new(:test) } + + it { is_expected.to_not be_scalar } + it { is_expected.to_not be_boolean } + it { is_expected.to_not be_date } + it { is_expected.to_not be_dropdown } + it { is_expected.to_not be_radio } + + describe '#==' do + it "compares to the param's value" do + expect(subject).to eq(:test) + end + end +end diff --git a/spec/compendium/param_types/radio_spec.rb b/spec/compendium/param_types/radio_spec.rb new file mode 100644 index 0000000..f65dc68 --- /dev/null +++ b/spec/compendium/param_types/radio_spec.rb @@ -0,0 +1,11 @@ +require 'compendium/param_types/radio' + +RSpec.describe Compendium::ParamTypes::Radio do + subject { described_class.new(0, %w(a b c)) } + + it { is_expected.to_not be_scalar } + it { is_expected.to_not be_boolean } + it { is_expected.to_not be_date } + it { is_expected.to_not be_dropdown } + it { is_expected.to be_radio } +end diff --git a/spec/compendium/param_types/scalar_spec.rb b/spec/compendium/param_types/scalar_spec.rb new file mode 100644 index 0000000..1e39251 --- /dev/null +++ b/spec/compendium/param_types/scalar_spec.rb @@ -0,0 +1,15 @@ +require 'compendium/param_types/scalar' + +RSpec.describe Compendium::ParamTypes::Scalar do + subject { described_class.new(123) } + + it { is_expected.to be_scalar } + it { is_expected.to_not be_boolean } + it { is_expected.to_not be_date } + it { is_expected.to_not be_dropdown } + it { is_expected.to_not be_radio } + + it 'does not change values' do + expect(subject).to eq(123) + end +end diff --git a/spec/compendium/param_types/with_choices_spec.rb b/spec/compendium/param_types/with_choices_spec.rb new file mode 100644 index 0000000..444b19d --- /dev/null +++ b/spec/compendium/param_types/with_choices_spec.rb @@ -0,0 +1,40 @@ +require 'compendium/param_types/with_choices' + +RSpec.describe Compendium::ParamTypes::WithChoices do + subject { described_class.new(0, %w(a b c)) } + + it { is_expected.to_not be_boolean } + it { is_expected.to_not be_date } + it { is_expected.to_not be_dropdown } + it { is_expected.to_not be_radio } + + it 'returns the index when given an index' do + p = described_class.new(1, %i(foo bar baz)) + expect(p).to eq(1) + end + + it 'returns the index when given a value' do + p = described_class.new(:foo, %i(foo bar baz)) + expect(p).to eq(0) + end + + it 'returns the index when given a string value' do + p = described_class.new('2', %i(foo bar baz)) + expect(p).to eq(2) + end + + it 'raises an error if given an invalid index' do + expect { described_class.new(3, %i(foo bar baz)) }.to raise_error IndexError + end + + it 'raises an error if given a value that is not included in the choices' do + expect { described_class.new(:quux, %i(foo bar baz)) }.to raise_error IndexError + end + + describe '#value' do + it 'returns the value of the given choice' do + p = described_class.new(2, %i(foo bar baz)) + expect(p.value).to eq(:baz) + end + end +end diff --git a/spec/params_spec.rb b/spec/compendium/params_spec.rb similarity index 68% rename from spec/params_spec.rb rename to spec/compendium/params_spec.rb index a25e3e4..275220d 100644 --- a/spec/params_spec.rb +++ b/spec/compendium/params_spec.rb @@ -1,35 +1,34 @@ require 'compendium/params' -describe Compendium::Params do - let(:options) { +RSpec.describe Compendium::Params do + let(:params) { {} } + let(:options) do opts = Collection[Compendium::Option] - opts << Compendium::Option.new(name: :starting_on, type: :date, default: ->{ Date.today }) + opts << Compendium::Option.new(name: :starting_on, type: :date, default: -> { Date.today }) opts << Compendium::Option.new(name: :ending_on, type: :date) opts << Compendium::Option.new(name: :report_type, type: :radio, choices: [:big, :small]) opts << Compendium::Option.new(name: :boolean, type: :boolean) opts << Compendium::Option.new(name: :another_boolean, type: :boolean) opts << Compendium::Option.new(name: :number, type: :scalar) opts - } + end - subject{ described_class.new(@params, options) } + subject { described_class.new(params, options) } - it "should only allow keys that are given as options" do - @params = { starting_on: '2013-10-15', foo: :bar } - expect(subject.keys).not_to include :foo + it 'only allows keys that are given as options' do + params.merge!(starting_on: '2013-10-15', foo: :bar) + expect(subject.keys).to_not include :foo end - it "should set missing options to their default value" do - @params = {} + it 'sets missing options to their default value' do expect(subject.starting_on).to eq(Date.today) end - it "should set missing options to nil if there is no default value" do - @params = {} + it 'sets missing options to nil if there is no default value' do expect(subject.ending_on).to be_nil end - describe "#validations" do + describe '#validations' do let(:report_class) { Class.new(described_class) } context 'presence' do @@ -40,18 +39,19 @@ subject.valid? end - it { is_expected.not_to be_valid } + it { is_expected.to_not be_valid } specify { expect(subject.errors.keys).to include :ending_on } end context 'numericality' do subject { report_class.new({ number: 'abcd' }, options) } + before do report_class.validates :number, numericality: true subject.valid? end - it { is_expected.not_to be_valid } + it { is_expected.to_not be_valid } specify { expect(subject.errors.keys).to include :number } end end diff --git a/spec/presenters/base_spec.rb b/spec/compendium/presenters/base_spec.rb similarity index 55% rename from spec/presenters/base_spec.rb rename to spec/compendium/presenters/base_spec.rb index 6d83ed5..48b5c09 100644 --- a/spec/presenters/base_spec.rb +++ b/spec/compendium/presenters/base_spec.rb @@ -1,20 +1,20 @@ -require 'spec_helper' require 'compendium/presenters/base' TestPresenter = Class.new(Compendium::Presenters::Base) do presents :test_obj end -describe Compendium::Presenters::Base do - let(:template) { double("Template", delegated?: true) } +RSpec.describe Compendium::Presenters::Base do + let(:template) { double('Template', delegated?: true) } + subject { TestPresenter.new(template, :test) } - it "should allow the object name to be overridden" do + it 'allows the object name to be overridden' do expect(subject.test_obj).to eq(:test) end - it "should delegate missing methods to the template object" do + it 'delegates missing methods to the template object' do expect(template).to receive(:delegated?) expect(subject).to be_delegated end -end \ No newline at end of file +end diff --git a/spec/presenters/chart_spec.rb b/spec/compendium/presenters/chart_spec.rb similarity index 57% rename from spec/presenters/chart_spec.rb rename to spec/compendium/presenters/chart_spec.rb index b79d45a..8bf4c59 100644 --- a/spec/presenters/chart_spec.rb +++ b/spec/compendium/presenters/chart_spec.rb @@ -1,8 +1,14 @@ -require 'spec_helper' require 'compendium/presenters/chart' -describe Compendium::Presenters::Chart do - let(:template) { double('Template', protect_against_forgery?: false, request_forgery_protection_token: :authenticity_token, form_authenticity_token: "ABCDEFGHIJ").as_null_object } +RSpec.describe Compendium::Presenters::Chart do + let(:template) do + double( + 'Template', + protect_against_forgery?: false, + request_forgery_protection_token: :authenticity_token, + form_authenticity_token: 'ABCDEFGHIJ' + ).as_null_object + end let(:query) { double('Query', name: 'test_query', results: results, ran?: true, options: {}).as_null_object } let(:results) { Compendium::ResultSet.new([]) } @@ -13,59 +19,60 @@ describe '#initialize' do context 'when all params are given' do - subject{ described_class.new(template, query, :pie, :container) } + subject { described_class.new(template, query, :pie, :container) } specify { expect(subject.data).to eq(results.records) } specify { expect(subject.container).to eq(:container) } end context 'when container is not given' do - subject{ described_class.new(template, query, :pie) } + subject { described_class.new(template, query, :pie) } specify { expect(subject.data).to eq(results.records) } specify { expect(subject.container).to eq('test_query') } end - context "when options are given" do - before { allow(results).to receive(:records) { { one: [] } } } - subject{ described_class.new(template, query, :pie, index: :one) } + context 'when options are given' do + before { allow(results).to receive(:records).and_return(one: []) } + + subject { described_class.new(template, query, :pie, index: :one) } specify { expect(subject.data).to eq(results.records[:one]) } specify { expect(subject.container).to eq('test_query') } end - context "when the query has not been run" do + context 'when the query has not been run' do before { allow(query).to receive_messages(ran?: false, url: '/path/to/query.json') } - subject{ described_class.new(template, query, :pie, params: { foo: 'bar' }) } + subject { described_class.new(template, query, :pie, params: { foo: 'bar' }) } specify { expect(subject.data).to eq('/path/to/query.json') } - specify { expect(subject.params).to eq({ report: { foo: 'bar' } }) } + specify { expect(subject.params).to eq(report: { foo: 'bar' }) } - context "when CSRF protection is enabled" do + context 'when CSRF protection is enabled' do before { allow(template).to receive_messages(protect_against_forgery?: true) } - specify { expect(subject.params).to include authenticity_token: "ABCDEFGHIJ" } + specify { expect(subject.params).to include authenticity_token: 'ABCDEFGHIJ' } end - context "when CSRF protection is disabled" do - specify { expect(subject.params).to_not include authenticity_token: "ABCDEFGHIJ" } + context 'when CSRF protection is disabled' do + specify { expect(subject.params).to_not include authenticity_token: 'ABCDEFGHIJ' } end end end describe '#remote?' do - it 'should be true if options[:remote] is set to true' do + it 'returns true if options[:remote] is set to true' do expect(described_class.new(template, query, :pie, remote: true)).to be_remote end - it 'should be true if the query has not been run yet' do + it 'returns true if the query has not been run yet' do allow(query).to receive_messages(run?: false) described_class.new(template, query, :pie).should_be_remote end - it 'should be false otherwise' do - expect(described_class.new(template, query, :pie)).not_to be_remote + it 'returns false otherwise' do + expect(described_class.new(template, query, :pie)).to_not be_remote end end end diff --git a/spec/presenters/csv_spec.rb b/spec/compendium/presenters/csv_spec.rb similarity index 63% rename from spec/presenters/csv_spec.rb rename to spec/compendium/presenters/csv_spec.rb index 12426a1..ffa3d8c 100644 --- a/spec/presenters/csv_spec.rb +++ b/spec/compendium/presenters/csv_spec.rb @@ -1,27 +1,26 @@ require 'compendium/presenters/csv' -describe Compendium::Presenters::CSV do - let(:results) { double('Results', records: [{ group: 'A', one: 1, two: 2 }, { group: 'B', one: 3, two: 4 }], keys: [:group, :one, :two]) } +RSpec.describe Compendium::Presenters::CSV do + let(:results) { double('Results', records: [{ group: 'A', one: 1, two: 2 }, { group: 'B', one: 3, two: 4 }], keys: %i(group one two)) } let(:query) { double('Query', results: results, options: {}, table_settings: nil) } let(:presenter) { described_class.new(query) } before do allow(query).to receive(:pos) { raise caller.join("\n") } + allow(I18n).to receive(:t) { |key| key } end - before { allow(I18n).to receive(:t) { |key| key } } - describe '#render' do - it 'should return a CSV of the results' do + it 'returns the results as CSV' do expect(presenter.render).to eq("group,one,two\nA,1.00,2.00\nB,3.00,4.00\n") end - it "should use the query's table settings" do - allow(query).to receive(:table_settings).and_return(-> * { number_format '%0.0f' }) + it "uses the query's table settings" do + allow(query).to receive(:table_settings).and_return(-> (*) { number_format '%0.0f' }) expect(presenter.render).to eq("group,one,two\nA,1,2\nB,3,4\n") end - it 'should output a total row if the query has totals' do + it 'outputs a total row if the query has totals' do query.results.records << { group: '', one: 4, two: 6 } query.options[:totals] = true expect(presenter.render).to eq("group,one,two\nA,1.00,2.00\nB,3.00,4.00\ntotal,4.00,6.00\n") diff --git a/spec/compendium/presenters/option_spec.rb b/spec/compendium/presenters/option_spec.rb new file mode 100644 index 0000000..17595fb --- /dev/null +++ b/spec/compendium/presenters/option_spec.rb @@ -0,0 +1,177 @@ +require 'compendium/presenters/option' +require 'compendium/option' + +RSpec.describe Compendium::Presenters::Option do + let(:template) do + t = double('Template') + allow(t).to receive(:t) { |key| key } # Stub I18n.t to just return the given value + t + end + + let(:form) { double('Form') } + let(:ctx) { double('Context') } + let(:option) { Compendium::Option.new(name: :test_option, type: :scalar) } + + subject { described_class.new(template, option) } + + describe '#name' do + it 'passes the name through I18n' do + expect(template).to receive(:t).with('options.test_option', anything) + subject.name + end + end + + describe '#note' do + before { allow(template).to receive(:content_tag) } + + it 'returns nil if no note is specified' do + expect(subject.note).to be_nil + end + + context 'given note: true' do + it 'retrieves the default key from I18n' do + option[:note] = true + expect(template).to receive(:t).with(:test_option_note) + subject.note + end + end + + context 'given note: something' do + it 'retrieves the given key from I18n' do + option[:note] = :the_note + expect(template).to receive(:t).with(:the_note) + subject.note + end + end + + it 'creates the note within a div with class option-note' do + option[:note] = true + expect(template).to receive(:content_tag).with(:div, anything, class: 'option-note') + subject.note + end + end + + describe '#label' do + context 'when the option has a note' do + before do + allow(template).to receive(:content_tag) + allow(form).to receive(:label) { |_field, name| name } + end + + context 'when a note is provided' do + before { option[:note] = :test } + + context 'when AccessibleTooltip is present' do + before do + stub_const('AccessibleTooltip', Object.new) + allow(template).to receive(:accessible_tooltip).and_yield + end + + it 'returns a label with the tooltip' do + expect(form).to receive(:label).with(:test_option, 'options.test') + subject.label(form) + end + end + + it 'translates the note' do + expect(template).to receive(:t).with('options.test', anything) + subject.label(form) + end + + it 'translates the option name if no specific note is given' do + option[:note] = true + expect(template).to receive(:t).with('options.test_option_note', anything) + subject.label(form) + end + + it 'renders the note' do + expect(template).to receive(:content_tag).with(:div, 'options.test', class: 'option-note') + subject.label(form) + end + end + + it 'renders the label' do + expect(template).to receive(:content_tag).with(:span, 'options.test_option', class: 'option-label') + subject.label(form) + end + end + end + + describe '#input' do + before do + allow(template).to receive(:content_tag).and_yield + + option.options = { foo: :bar } + option.choices = [1, 2, 3] + end + + context 'with a scalar option' do + before { option.type = :scalar } + + it 'renders an text field' do + expect(form).to receive(:text_field).with(:test_option) + subject.input(ctx, form) + end + end + + context 'with a date option' do + before { option.type = :date } + + it 'renders a text field' do + expect(form).to receive(:text_field).with(:test_option) + subject.input(ctx, form) + end + + it 'renders a calendar date select if defined' do + stub_const('CalendarDateSelect', Object.new) + expect(form).to receive(:calendar_date_select).with(:test_option, anything) + subject.input(ctx, form) + end + end + + context 'with a dropdown option' do + before { option.type = :dropdown } + + it 'renders a select field' do + expect(form).to receive(:select).with(:test_option, [1, 2, 3], foo: :bar) + subject.input(ctx, form) + end + + it 'raises if there are no choices' do + option.choices = nil + expect { subject.input(ctx, form) }.to raise_error ArgumentError + end + end + + context 'with a boolean option' do + before { option.type = :boolean } + + it 'renders radio buttons and labels for true and false' do + expect(form).to receive(:radio_button).with(:test_option, 0) + expect(form).to receive(:label).with(:test_option, 'true', value: 0) + expect(form).to receive(:radio_button).with(:test_option, 1) + expect(form).to receive(:label).with(:test_option, 'false', value: 1) + subject.input(ctx, form) + end + end + + context 'with a radio option' do + before { option.type = :radio } + + it 'renders radio buttons and labels for each option' do + expect(form).to receive(:radio_button).with(:test_option, 0) + expect(form).to receive(:label).with(:test_option, 1, value: 0) + expect(form).to receive(:radio_button).with(:test_option, 1) + expect(form).to receive(:label).with(:test_option, 2, value: 1) + expect(form).to receive(:radio_button).with(:test_option, 2) + expect(form).to receive(:label).with(:test_option, 3, value: 2) + subject.input(ctx, form) + end + + it 'raises if there are no choices' do + option.choices = nil + expect { subject.input(ctx, form) }.to raise_error ArgumentError + end + end + end +end diff --git a/spec/presenters/settings/query_spec.rb b/spec/compendium/presenters/settings/query_spec.rb similarity index 66% rename from spec/presenters/settings/query_spec.rb rename to spec/compendium/presenters/settings/query_spec.rb index b409096..c39be2f 100644 --- a/spec/presenters/settings/query_spec.rb +++ b/spec/compendium/presenters/settings/query_spec.rb @@ -1,13 +1,12 @@ -require 'spec_helper' require 'compendium/presenters/settings/query' -describe Compendium::Presenters::Settings::Query do +RSpec.describe Compendium::Presenters::Settings::Query do subject { described_class.new } describe '#update' do before { subject.foo = :bar } - it 'should override previous settings' do + it 'overrides previous settings' do subject.update do |s| s.foo :quux end @@ -15,7 +14,7 @@ expect(subject.foo).to eq(:quux) end - it 'should allow the block parameter to be skipped' do + it 'allows the block parameter to be skipped' do subject.update do foo :quux end diff --git a/spec/presenters/settings/table_spec.rb b/spec/compendium/presenters/settings/table_spec.rb similarity index 68% rename from spec/presenters/settings/table_spec.rb rename to spec/compendium/presenters/settings/table_spec.rb index 0a4a05a..07e8bbb 100644 --- a/spec/presenters/settings/table_spec.rb +++ b/spec/compendium/presenters/settings/table_spec.rb @@ -1,7 +1,6 @@ -require 'spec_helper' require 'compendium/presenters/table' -describe Compendium::Presenters::Settings::Table do +RSpec.describe Compendium::Presenters::Settings::Table do let(:results) { double('Results', records: [{ one: 1, two: 2 }, { one: 3, two: 4 }], keys: [:one, :two]) } let(:query) { double('Query', results: results, options: {}, table_settings: nil) } let(:table) { Compendium::Presenters::Table.new(nil, query) } @@ -9,7 +8,7 @@ subject { table.settings } context 'default settings' do - it 'should return default values' do + it 'returns the default values' do expect(subject.number_format).to eq '%0.2f' expect(subject.table_class).to eq('results') expect(subject.header_class).to eq('headings') @@ -30,7 +29,7 @@ end end - it 'should have overriden settings' do + it 'has overriden settings' do expect(subject.number_format).to eq('%0.1f') expect(subject.table_class).to eq('report_table') expect(subject.header_class).to eq('report_heading') @@ -39,7 +38,7 @@ end describe '#update' do - it 'should override previous settings' do + it 'overrides previous settings' do subject.update do |s| s.number_format '%0.3f' end @@ -49,20 +48,34 @@ end describe '#skip_total_for' do - it 'should add columns to the setting' do + it 'adds columns to the setting' do subject.skip_total_for :foo, :bar expect(subject.skipped_total_cols).to eq([:foo, :bar]) end - it 'should be callable multiple times' do + it 'is callable multiple times' do subject.skip_total_for :foo, :bar subject.skip_total_for :quux - expect(subject.skipped_total_cols).to eq([:foo, :bar, :quux]) + expect(subject.skipped_total_cols).to eq(%i(foo bar quux)) end - it 'should not care about type' do + it 'does not care about type' do subject.skip_total_for 'foo' expect(subject.skipped_total_cols).to eq([:foo]) end end + + describe '#override_heading' do + it 'overrides the given heading' do + subject.override_heading :one, 'First Column' + expect(subject.headings).to eq('one' => 'First Column', 'two' => :two) + end + + it 'overrides multiple headings with a block' do + subject.override_heading do |col| + col.to_s * 2 + end + expect(subject.headings).to eq('one' => 'oneone', 'two' => 'twotwo') + end + end end diff --git a/spec/presenters/table_spec.rb b/spec/compendium/presenters/table_spec.rb similarity index 71% rename from spec/presenters/table_spec.rb rename to spec/compendium/presenters/table_spec.rb index 0cf2e4c..34d7603 100644 --- a/spec/presenters/table_spec.rb +++ b/spec/compendium/presenters/table_spec.rb @@ -1,7 +1,6 @@ -require 'spec_helper' require 'compendium/presenters/table' -describe Compendium::Presenters::Table do +RSpec.describe Compendium::Presenters::Table do let(:template) { double('Template') } let(:results) { double('Results', records: [{ one: 1, two: 2 }, { one: 3, two: 4 }], keys: [:one, :two]) } let(:query) { double('Query', results: results, options: {}, table_settings: nil) } @@ -13,66 +12,66 @@ allow(I18n).to receive(:t) { |key| key } end - it 'should use the table class given in settings' do + it 'uses the table class given in settings' do table.settings.table_class 'report_table' expect(template).to receive(:content_tag).with(:table, class: 'report_table') table.render end - it 'should use the default table class if not overridden' do + it 'uses the default table class if not specified' do expect(template).to receive(:content_tag).with(:table, class: 'results') table.render end - it 'should build the heading row' do + it 'builds the heading row' do expect(template).to receive(:content_tag).with(:tr, class: 'headings') expect(template).to receive(:content_tag).with(:th, :one) expect(template).to receive(:content_tag).with(:th, :two) table.render end - it 'should use the overridden heading class if given' do + it 'uses the overridden heading class if given' do table.settings.header_class 'report_header' expect(template).to receive(:content_tag).with(:tr, class: 'report_header') table.render end - it 'should build data rows' do + it 'builds data rows' do expect(template).to receive(:content_tag).with(:tr, class: 'data').twice table.render end - it 'should use the overridden row class if given' do + it 'uses the overridden row class if given' do table.settings.row_class 'report_row' expect(template).to receive(:content_tag).with(:tr, class: 'report_row').twice table.render end - it 'should add a totals row if the query has totals: true set' do + it 'adds a totals row if the query has totals: true set' do query.options[:totals] = true expect(template).to receive(:content_tag).with(:tr, class: 'totals') table.render end - it 'should not add a totals row if the query has totals: false set' do + it 'does not add a totals row if the query has totals: false set' do query.options[:totals] = false - expect(template).not_to receive(:content_tag).with(:tr, class: 'totals') + expect(template).to_not receive(:content_tag).with(:tr, class: 'totals') table.render end - it 'should not add a totals row if the query does not have :totals set' do + it 'does not add a totals row if the query does not have :totals set' do query.options.delete(:totals) - expect(template).not_to receive(:content_tag).with(:tr, class: 'totals') + expect(template).to_not receive(:content_tag).with(:tr, class: 'totals') table.render end - it 'should use the totals class if that setting is overridden' do + it 'uses the totals class if that setting is overridden' do query.options[:totals] = true table.settings.totals_class 'report_totals' diff --git a/spec/compendium/queries/collection_spec.rb b/spec/compendium/queries/collection_spec.rb new file mode 100644 index 0000000..5ec3383 --- /dev/null +++ b/spec/compendium/queries/collection_spec.rb @@ -0,0 +1,78 @@ +require 'compendium/queries/collection' + +RSpec.describe Compendium::Queries::Collection do + subject { described_class.new(:collection_query, { collection: collection }, -> (_, _key, item) { [item * 2] }) } + + before { allow_any_instance_of(Compendium::Queries::Query).to receive(:execute_query) { |_instance, cmd| cmd } } + + describe '#run' do + context 'when given a hash' do + let(:collection) { { one: 1, two: 2, three: 3 } } + + context 'when there are results for each element' do + before { subject.run(nil) } + + specify { expect(subject.results).to be_a Compendium::ResultSet } + specify { expect(subject.results).to eq(one: [2], two: [4], three: [6]) } + + context 'when given an array instead of a hash' do + let(:collection) { [1, 2, 3] } + + specify { expect(subject.results).to be_a Compendium::ResultSet } + specify { expect(subject.results).to eq(1 => [2], 2 => [4], 3 => [6]) } + end + end + + context 'when there are empty results' do + it 'does not collect empty results' do + subject.proc = -> (_, _key, item) { [item] if item > 2 } + subject.run(nil) + expect(subject.results).to eq(three: [3]) + end + end + end + + context 'when given an array' do + let(:collection) { [1, 2, 3] } + + it 'returns results' do + subject.run(nil) + expect(subject.results).to eq(1 => [2], 2 => [4], 3 => [6]) + end + end + + context 'when given another query' do + let(:q) { Compendium::Queries::Query.new(:q, {}, -> (*) { { one: 1, two: 2, three: 3 } }) } + let(:collection) { q } + + before { subject.run(nil) if RSpec.current_example.metadata.fetch(:run_query, true) } + + specify { expect(subject.results).to eq(one: [2], two: [4], three: [6]) } + + it 'does not re-run the query if it has already ran', run_query: false do + q.run(nil) + expect(q).to_not receive(:run) + subject.run(nil) + end + end + + context 'when given a proc' do + let(:proc) { -> (*) { [1, 2, 3] } } + + subject { described_class.new(:collection, { collection: proc }, -> (_, _key, item) { [item * 2] }) } + + it 'uses the collection from the proc' do + subject.run(nil) + expect(subject.results).to eq(1 => [2], 2 => [4], 3 => [6]) + end + + context 'when the query has not run' do + let(:proc) { -> (*) { raise ArgumentError } } + + it 'does not run the proc until execution time' do + expect { subject }.to_not raise_error + end + end + end + end +end diff --git a/spec/compendium/queries/count_spec.rb b/spec/compendium/queries/count_spec.rb new file mode 100644 index 0000000..0110d25 --- /dev/null +++ b/spec/compendium/queries/count_spec.rb @@ -0,0 +1,79 @@ +require 'compendium' +require 'compendium/queries/count' + +class SingleCounter + def count + 1792 + end +end + +class MultipleCounter + def order(*) + @order = true + self + end + + def reverse_order + @reverse = true + self + end + + def count + results = { 1 => 340, 2 => 204, 3 => 983 } + + if @order + results = results.sort_by { |r| r[1] } + results.reverse! if @reverse + results = Hash[results] + end + + results + end +end + +RSpec.describe Compendium::Queries::Count do + subject { described_class.new(:counted_query, {}, -> (*) { counter }) } + + it 'has a default order' do + expect(subject.options[:order]).to eq('COUNT(*)') + expect(subject.options[:reverse]).to eq(true) + end + + describe '#run' do + let(:counter) { SingleCounter.new } + + it 'calls count on the proc result' do + expect(counter).to receive(:count).and_return(1234) + subject.run(nil, self) + end + + it 'returns the count' do + expect(subject.run(nil, self)).to eq([1792]) + end + + context 'when given a hash' do + let(:counter) { MultipleCounter.new } + + it 'returns a hash' do + expect(subject.run(nil, self)).to eq(3 => 983, 1 => 340, 2 => 204) + end + + it 'is ordered in descending order' do + expect(subject.run(nil, self).keys).to eq([3, 1, 2]) + end + + it 'uses the given options' do + subject.options[:reverse] = false + expect(subject.run(nil, self).keys).to eq([2, 1, 3]) + end + end + + context 'when the proc does not respond to count' do + let(:counter) { Class.new } + + it 'raises an error if the proc does not respond to count' do + expect { subject.run(nil, self) }.to raise_error Compendium::Queries::InvalidCommand + end + end + end +end diff --git a/spec/query_spec.rb b/spec/compendium/queries/query_spec.rb similarity index 51% rename from spec/query_spec.rb rename to spec/compendium/queries/query_spec.rb index f72e99e..1ea358f 100644 --- a/spec/query_spec.rb +++ b/spec/compendium/queries/query_spec.rb @@ -1,14 +1,14 @@ -require 'spec_helper' -require 'compendium/query' +require 'compendium/queries/query' -describe Compendium::Query do - describe "#initialize" do - let(:options) { double("Options") } - let(:proc) { double("Proc") } +RSpec.describe Compendium::Queries::Query do + describe '#initialize' do + let(:options) { double('Options', assert_valid_keys: true) } + let(:proc) { double('Proc') } - context "when supplying a report" do + context 'when supplying a report' do let(:r) { Compendium::Report.new } - subject { described_class.new(r, :test, options, proc)} + + subject { described_class.new(r, :test, options, proc) } specify { expect(subject.report).to eq(r) } specify { expect(subject.name).to eq(:test) } @@ -16,8 +16,8 @@ specify { expect(subject.proc).to eq(proc) } end - context "when not supplying a report" do - subject { described_class.new(:test, options, proc)} + context 'when not supplying a report' do + subject { described_class.new(:test, options, proc) } specify { expect(subject.report).to be_nil } specify { expect(subject.name).to eq(:test) } @@ -26,8 +26,8 @@ end end - describe "#run" do - let(:command) { -> * { [{ value: 1 }, { value: 2 }] } } + describe '#run' do + let(:command) { -> (*) { [{ value: 1 }, { value: 2 }] } } let(:query) do described_class.new(:test, {}, command) end @@ -36,44 +36,44 @@ allow(query).to receive(:fetch_results) { |c| c } end - it "should return the result of the query" do + it 'returns the result of the query' do results = query.run(nil) expect(results).to be_a Compendium::ResultSet expect(results.to_a).to eq([{ 'value' => 1 }, { 'value' => 2 }]) end - it "should mark the query as having ran" do + it 'marks the query as having ran' do query.run(nil) expect(query).to have_run end - it "should not affect any cloned queries" do + it 'does not affect any cloned queries' do q2 = query.clone query.run(nil) - expect(q2).not_to have_run + expect(q2).to_not have_run end - it "should return an empty result set if running an query with no proc" do + it 'returns an empty result set if running an query with no proc' do query = described_class.new(:blank, {}, nil) expect(query.run(nil)).to be_empty end - it "should filter the result set if a filter is provided" do - query.add_filter(-> data { data.reject{ |d| d[:value].odd? } }) + it 'filters the result set if a filter is provided' do + query.add_filter(-> (data) { data.reject { |d| d[:value].odd? } }) expect(query.run(nil).to_a).to eq([{ 'value' => 2 }]) end - it "should run multiple filters if given" do - query.add_filter(-> data { data.reject{ |d| d[:value].odd? } }) - query.add_filter(-> data { data.reject{ |d| d[:value].even? } }) + it 'runs multiple filters if given' do + query.add_filter(-> (data) { data.reject { |d| d[:value].odd? } }) + query.add_filter(-> (data) { data.reject { |d| d[:value].even? } }) expect(query.run(nil)).to be_empty end - it 'should allow the result set to be a single hash when filters are present' do - query = described_class.new(:test, {}, -> * { { value1: 1, value2: 2, value3: 3 } }) + it 'allows the result set to be a single hash when filters are present' do + query = described_class.new(:test, {}, -> (*) { { value1: 1, value2: 2, value3: 3 } }) allow(query).to receive(:fetch_results) { |c| c } - query.add_filter(-> d { d }) + query.add_filter(-> (d) { d }) query.run(nil) expect(query.results.records).to eq({ value1: 1, value2: 2, value3: 3 }.with_indifferent_access) end @@ -86,29 +86,29 @@ end let(:command) do - -> c { -> * { c } }.(cmd) + -> (c) { -> (*) { c } }.call(cmd) end before { query.options[:order] = 'col1' } - it 'should order the query' do + it 'orders the query' do expect(cmd).to receive(:order) query.run(nil) end - it 'should not reverse the order by default' do - expect(cmd).not_to receive(:reverse_order) + it 'does not reverse the order by default' do + expect(cmd).to_not receive(:reverse_order) query.run(nil) end - it 'should reverse order if the query is given reverse: true' do + it 'reverses the order if the query is given reverse: true' do query.options[:reverse] = true expect(cmd).to receive(:reverse_order) query.run(nil) end end - context "when the query belongs to a report class" do + context 'when the query belongs to a report class' do let(:report) do Class.new(Compendium::Report) do query(:test) { [1, 2, 3] } @@ -117,73 +117,77 @@ subject { report.queries[:test] } - before { allow_any_instance_of(described_class).to receive(:fetch_results) { |instance, c| c } } + before { allow_any_instance_of(described_class).to receive(:fetch_results) { |_instance, c| c } } - it "should return its results" do + it 'returns its results' do expect(subject.run(nil)).to eq([1, 2, 3]) end - it "should not affect the report" do + it 'does not affect the report' do subject.run(nil) expect(report.queries[:test].results).to be_nil end - it "should not affect future instances of the report" do + it 'does not affect future instances of the report' do subject.run(nil) expect(report.new.queries[:test].results).to be_nil end end end - describe "#nil?" do - it "should return true if the query's proc is nil" do - expect(Compendium::Query.new(:test, {}, nil)).to be_nil + describe '#nil?' do + it "returns true if the query's proc is nil" do + expect(described_class.new(:test, {}, nil)).to be_nil end - it "should return false if the query's proc is not nil" do - expect(Compendium::Query.new(:test, {}, ->{})).not_to be_nil + it "returns false if the query's proc is not nil" do + expect(described_class.new(:test, {}, -> {})).to_not be_nil end end - describe "#render_chart" do - let(:template) { double("Template") } - subject { described_class.new(:test, {}, -> * {}) } + describe '#render_chart' do + let(:template) { double('Template') } + + subject { described_class.new(:test, {}, -> (*) {}) } - it "should initialize a new Chart presenter if the query has no results" do + it 'initializes a new Chart presenter if the query has no results' do allow(subject).to receive_messages(empty?: true) - expect(Compendium::Presenters::Chart).to receive(:new).with(template, subject).and_return(double("Presenter").as_null_object) + expect(Compendium::Presenters::Chart).to receive(:new).with(template, subject).and_return(double('Presenter').as_null_object) subject.render_chart(template) end - it "should initialize a new Chart presenter if the query has results" do + it 'initializes a new Chart presenter if the query has results' do allow(subject).to receive_messages(empty?: false) - expect(Compendium::Presenters::Chart).to receive(:new).with(template, subject).and_return(double("Presenter").as_null_object) + expect(Compendium::Presenters::Chart).to receive(:new).with(template, subject).and_return(double('Presenter').as_null_object) subject.render_chart(template) end end - describe "#render_table" do - let(:template) { double("Template") } - subject { described_class.new(:test, {}, -> * {}) } + describe '#render_table' do + let(:template) { double('Template') } + + subject { described_class.new(:test, {}, -> (*) {}) } - it "should return nil if the query has no results" do + it 'returns nil if the query has no results' do allow(subject).to receive_messages(empty?: true) expect(subject.render_table(template)).to be_nil end - it "should initialize a new Table presenter if the query has results" do + it 'initializes a new Table presenter if the query has results' do allow(subject).to receive_messages(empty?: false) - expect(Compendium::Presenters::Table).to receive(:new).with(template, subject).and_return(double("Presenter").as_null_object) + expect(Compendium::Presenters::Table).to receive(:new).with(template, subject).and_return(double('Presenter').as_null_object) subject.render_table(template) end end - describe "#url" do - let(:report) { double("Report") } - subject { described_class.new(:test, {}, ->{}) } + describe '#url' do + let(:report) { double('Report') } + + subject { described_class.new(:test, {}, -> {}) } + before { subject.report = report } - it "should build a URL using its report's URL" do + it "builds a URL using its report's URL" do expect(report).to receive(:url).with(query: :test) subject.url end diff --git a/spec/compendium/queries/sum_spec.rb b/spec/compendium/queries/sum_spec.rb new file mode 100644 index 0000000..76b67df --- /dev/null +++ b/spec/compendium/queries/sum_spec.rb @@ -0,0 +1,79 @@ +require 'compendium/queries/sum' +require 'compendium/report' + +class SingleSummer + def sum(*) + 1792 + end +end + +class MultipleSummer + def order(*) + @order = true + self + end + + def reverse_order + @reverse = true + self + end + + def sum(*) + results = { 1 => 340, 2 => 204, 3 => 983 } + + if @order + results = results.sort_by { |r| r[1] } + results.reverse! if @reverse + results = Hash[results] + end + + results + end +end + +RSpec.describe Compendium::Queries::Sum do + subject { described_class.new(:counted_query, :col, { sum: :col }, -> (*) { summer }) } + + it 'has a default order' do + expect(subject.options[:order]).to eq('SUM(col)') + expect(subject.options[:reverse]).to eq(true) + end + + describe '#run' do + let(:summer) { SingleSummer.new } + + it 'calls sum on the proc result' do + expect(summer).to receive(:sum).with(:col).and_return(1234) + subject.run(nil, self) + end + + it 'returns the sum' do + expect(subject.run(nil, self)).to eq([1792]) + end + + context 'when given a hash' do + let(:summer) { MultipleSummer.new } + + it 'returns a hash if given' do + expect(subject.run(nil, self)).to eq(3 => 983, 1 => 340, 2 => 204) + end + + it 'is ordered in descending order' do + expect(subject.run(nil, self).keys).to eq([3, 1, 2]) + end + + it 'uses the given options' do + subject.options[:reverse] = false + expect(subject.run(nil, self).keys).to eq([2, 1, 3]) + end + end + + context 'when the proc does not respond to sum' do + let(:summer) { Class.new } + + it 'raises an error if the proc does not respond to sum' do + expect { subject.run(nil, self) }.to raise_error Compendium::Queries::InvalidCommand + end + end + end +end diff --git a/spec/through_query_spec.rb b/spec/compendium/queries/through_spec.rb similarity index 51% rename from spec/through_query_spec.rb rename to spec/compendium/queries/through_spec.rb index e00faeb..6ad1fff 100644 --- a/spec/through_query_spec.rb +++ b/spec/compendium/queries/through_spec.rb @@ -1,14 +1,15 @@ -require 'compendium/through_query' +require 'compendium/queries/through' -describe Compendium::ThroughQuery do - describe "#initialize" do - let(:options) { double("Options") } - let(:proc) { double("Proc") } - let(:through) { double("Query") } +RSpec.describe Compendium::Queries::Through do + describe '#initialize' do + let(:options) { double('Options', assert_valid_keys: true) } + let(:proc) { double('Proc') } + let(:through) { double('Query') } - context "when supplying a report" do + context 'when supplying a report' do let(:r) { Compendium::Report.new } - subject { described_class.new(r, :test, through, options, proc)} + + subject { described_class.new(r, :test, through, options, proc) } specify { expect(subject.report).to eq(r) } specify { expect(subject.name).to eq(:test) } @@ -17,8 +18,8 @@ specify { expect(subject.proc).to eq(proc) } end - context "when not supplying a report" do - subject { described_class.new(:test, through, options, proc)} + context 'when not supplying a report' do + subject { described_class.new(:test, through, options, proc) } specify { expect(subject.report).to be_nil } specify { expect(subject.name).to eq(:test) } @@ -28,63 +29,63 @@ end end - describe "#run" do - let(:parent1) { Compendium::Query.new(:parent1, {}, -> * { }) } - let(:parent2) { Compendium::Query.new(:parent2, {}, -> * { }) } - let(:parent3) { Compendium::Query.new(:parent3, {}, -> * { [[1, 2, 3]] }) } + describe '#run' do + let(:parent1) { Compendium::Queries::Query.new(:parent1, {}, -> (*) {}) } + let(:parent2) { Compendium::Queries::Query.new(:parent2, {}, -> (*) {}) } + let(:parent3) { Compendium::Queries::Query.new(:parent3, {}, -> (*) { [[1, 2, 3]] }) } before { allow(parent3).to receive(:execute_query) { |cmd| cmd } } - it "should pass along the params if the proc collects it" do + it 'passes along the params if the proc collects it' do params = { one: 1, two: 2 } - q = described_class.new(:through, parent3, {}, -> r, params { params }) + q = described_class.new(:through, parent3, {}, -> (_r, p) { p }) expect(q.run(params)).to eq(params) end - it "should pass along the params if the proc has a splat argument" do + it 'passes along the params if the proc has a splat argument' do params = { one: 1, two: 2 } - q = described_class.new(:through, parent3, {}, -> *args { args }) + q = described_class.new(:through, parent3, {}, -> (*args) { args }) expect(q.run(params)).to eq([[[1, 2, 3]], params.with_indifferent_access]) end - it "should not pass along the params if the proc doesn't collects it" do + it "does not pass along the params if the proc doesn't collects it" do params = { one: 1, two: 2 } - q = described_class.new(:through, parent3, {}, -> r { r }) + q = described_class.new(:through, parent3, {}, -> (r) { r }) expect(q.run(params)).to eq([[1, 2, 3]]) end - it "should not affect its parent query" do - q = described_class.new(:through, parent3, {}, -> r { r.map!{ |i| i * 2 } }) + it 'does not affect its parent query' do + q = described_class.new(:through, parent3, {}, -> (r) { r.map! { |i| i * 2 } }) expect(q.run(nil)).to eq([[1, 2, 3, 1, 2, 3]]) expect(parent3.results).to eq([[1, 2, 3]]) end - context "with a single parent" do - subject { described_class.new(:sub, parent1, {}, -> r { r.first }) } + context 'with a single parent' do + subject { described_class.new(:sub, parent1, {}, -> (r) { r.first }) } - it "should not try to run a through query if the parent query has no results" do + it 'does not try to run a through query if the parent query has no results' do expect { subject.run(nil) }.to_not raise_error expect(subject.results).to be_empty end end - context "with multiple parents" do - subject { described_class.new(:sub, [parent1, parent2], {}, -> r { r.first }) } + context 'with multiple parents' do + subject { described_class.new(:sub, [parent1, parent2], {}, -> (r) { r.first }) } - it "should not try to run a through query with multiple parents all of which have no results" do + it 'does not try to run a through query with multiple parents all of which have no results' do expect { subject.run(nil) }.to_not raise_error expect(subject.results).to be_empty end - it "should allow non blank queries" do + it 'allows non blank queries' do subject.through = parent3 subject.run(nil) expect(subject.results).to eq([1, 2, 3]) end end - context "when the through option is an actual query" do - subject { described_class.new(:sub, parent3, {}, -> r { r.first }) } + context 'when the through option is an actual query' do + subject { described_class.new(:sub, parent3, {}, -> (r) { r.first }) } before { subject.run(nil) } diff --git a/spec/compendium/report_spec.rb b/spec/compendium/report_spec.rb new file mode 100644 index 0000000..5b9000a --- /dev/null +++ b/spec/compendium/report_spec.rb @@ -0,0 +1,392 @@ +require 'compendium/queries' + +RSpec.describe Compendium::Report do + subject { described_class } + + specify { expect(subject.queries).to be_empty } + specify { expect(subject.options).to be_empty } + + it 'does not do anything when run' do + report = subject.new + report.run + expect(report.results).to be_empty + end + + context 'with multiple instances' do + let(:report_class) do + Class.new(Compendium::Report) do + query :test + metric :test_metric, -> {}, through: :test + end + end + let(:report2) { report_class.new } + + subject { report_class.new } + + specify { expect(subject.queries).to_not equal report2.queries } + specify { expect(subject.queries).to_not equal report_class.queries } + specify { expect(subject.metrics).to_not equal report2.metrics } + end + + describe '.report_name' do + subject { TestReport = Class.new(described_class) } + + specify { expect(subject.report_name).to eq(:test) } + end + + describe '#run' do + context 'test' do + let(:report_class) do + Class.new(Compendium::Report) do + option :first, :date + option :second, :date + + query :test do |params| + [params[:first].__getobj__, params[:second].__getobj__] + end + + metric :lambda_metric, -> (results) { results.to_a.max }, through: :test + metric(:block_metric, through: :test) { |results| results.to_a.max } + metric(:implicit_metric) { [1, 2, 3].count } + end + end + let!(:report2) { report_class.new } + + subject { report_class.new(first: '2010-10-10', second: '2011-11-11') } + + before do + allow_any_instance_of(Compendium::Queries::Query).to receive(:fetch_results) { |_instance, c| c } + subject.run + end + + specify { expect(subject.test_results.records).to eq [Date.new(2010, 10, 10), Date.new(2011, 11, 11)] } + + it 'allows metric results to be accessed through a query' do + expect(subject.test.metrics[:lambda_metric].result).to eq(Date.new(2011, 11, 11)) + end + + it 'runs its metrics defined as a lambda' do + expect(subject.metrics[:lambda_metric].result).to eq(Date.new(2011, 11, 11)) + end + + it 'runs its metrics defined as a block' do + expect(subject.metrics[:block_metric].result).to eq(Date.new(2011, 11, 11)) + end + + it 'runs its implicit metrics' do + expect(subject.metrics[:implicit_metric].result).to eq(3) + end + + it 'does not affect other instances of the report class' do + expect(report2.test.results).to be_nil + expect(report2.metrics[:lambda_metric].result).to be_nil + end + + it 'does not affect the class collections' do + expect(report_class.test.results).to be_nil + end + + context 'with through queries' do + let(:report_class) do + Class.new(Compendium::Report) do + option :first, :boolean, default: false + query(:test) { |params| params[:first].value ? [100, 200, 400, 800] : [1600, 3200, 6400] } + query(:through, through: :test) { |results| [results.first] } + end + end + + subject { report_class.new(first: true) } + + specify { expect(subject.through.results).to eq([100]) } + + it "does not mark other instances' queries as ran" do + expect(report2.test).to_not have_run + end + + it 'does not affect other instances' do + report2.queries.each { |q| allow(q).to receive(:fetch_results) { |c| c } } + report2.run + expect(report2.through.results).to eq([1600]) + end + end + end + + context 'when specifying which queries to run' do + let(:report_class) do + Class.new(Compendium::Report) do + query :first + query :second + end + end + + subject { report_class.new } + + it 'raises an error if given both :only and :except options' do + expect { subject.run(nil, only: :first, except: :second) }.to raise_error(ArgumentError) + end + + it 'raises an error if given an invalid query name' do + expect { subject.run(nil, only: :foo) }.to raise_error(ArgumentError) + end + + it 'runs all queries if nothing is specified' do + subject.run(nil) + expect(subject.first).to have_run + expect(subject.second).to have_run + end + + context 'when :only is given' do + it 'only run specified queries' do + subject.run(nil, only: :first) + expect(subject.first).to have_run + expect(subject.second).to_not have_run + end + + it 'allows multiple queries to be specified' do + report_class.query(:third) {} + subject.run(nil, only: [:first, :third]) + expect(subject.first).to have_run + expect(subject.second).to_not have_run + expect(subject.third).to have_run + end + + it 'does not run through queries related to a query specified by only if not also specified' do + report_class.query(:through, through: :first) {} + subject.run(nil, only: :first) + expect(subject.through).to_not have_run + end + + it 'runs through queries related to a query specified by only if also specified' do + report_class.query(:through, through: :first) {} + subject.run(nil, only: [:first, :through]) + expect(subject.through).to have_run + end + end + + context 'when :except is given' do + it 'does run queries specified by :except' do + subject.run(nil, except: :first) + expect(subject.first).to_not have_run + expect(subject.second).to have_run + end + + it 'allows multiple queries to be specified by :except' do + report_class.query(:third) {} + subject.run(nil, except: [:first, :third]) + expect(subject.first).to_not have_run + expect(subject.second).to have_run + expect(subject.third).to_not have_run + end + + it 'does not run through queries related to a skipped query even if the main query is not excepted' do + report_class.query(:through, through: :first) {} + subject.run(nil, except: :through) + expect(subject.through).to_not have_run + expect(subject.first).to have_run + end + end + end + end + + context 'class name predicates' do + before do + OneReport = Class.new(described_class) + TwoReport = Class.new(described_class) + ThreeReport = Class.new + end + + after do + Object.send(:remove_const, :OneReport) + Object.send(:remove_const, :TwoReport) + Object.send(:remove_const, :ThreeReport) + end + + it { is_expected.to respond_to(:one?) } + it { is_expected.to respond_to(:two?) } + it { is_expected.to_not respond_to(:three?) } + + it { is_expected.to_not be_one } + it { is_expected.to_not be_two } + + specify { expect(OneReport).to be_one } + specify { expect(TwoReport).to be_two } + end + + describe 'parameters' do + let(:report_class) { Class.new(subject) } + let(:report_class2) { Class.new(report_class) } + + it 'includes ancestors params' do + expect(report_class.params_class.ancestors).to include subject.params_class + end + + it 'inherits validations' do + report_class.params_class.validates :foo, presence: true + expect(report_class2.params_class.validators_on(:foo)).to_not be_nil + end + end + + describe '#valid?' do + context 'built-in validations' do + let(:report_class) do + Class.new(described_class) do + option :id, :dropdown, choices: (0..10).to_a, validates: { presence: true } + end + end + + it 'returns true if there are no validation failures' do + r = report_class.new(id: 5) + expect(r).to be_valid + end + + it 'returns false if there are validation failures' do + r = report_class.new(id: nil) + expect(r).to_not be_valid + expect(r.errors.keys).to include :id + end + end + + context 'custom validation' do + let(:report_class) do + Class.new(described_class) do + option :number, :scalar + + validate do + errors.add(:number, :invalid_number) unless number.even? + end + end + end + + it 'returns true if there are no validation failures' do + r = report_class.new(number: 4) + expect(r).to be_valid + end + + it 'returns false if there are validation failures' do + r = report_class.new(number: 5) + expect(r).to_not be_valid + expect(r.errors.keys).to include :number + end + end + end + + describe '.filter' do + let(:filter_proc) { -> (*) {} } + + let(:report_class) do + Class.new(described_class) do + query :main_query + end + end + + let(:subclass1) do + k = Class.new(report_class) + k.filter(:main_query, &filter_proc) + k + end + + let(:subclass2) { Class.new(report_class) } + let(:subclass3) { Class.new(subclass1) } + + it 'adds filters to the specified query' do + expect(subclass1.main_query.filters).to include filter_proc + end + + it 'adds filters by inheritence' do + expect(subclass3.main_query.filters).to_not be_empty + end + + it 'does not bleed filters from a subclass into other subclasses' do + subclass1 + expect(subclass2.main_query.filters).to be_empty + end + end + + describe '#exports?' do + let(:report_class) do + Class.new(described_class) do + exports :csv, :main_query + exports :pdf, false + end + end + + subject { report_class.new } + + it 'returns true if there is an export for the given type' do + expect(subject).to be_exports(:csv) + end + + it 'returns false if there is no export for the given type explicitly' do + expect(subject).to_not be_exports(:pdf) + end + + it 'returns false if there is no export for the given type implicitly' do + expect(subject).to_not be_exports(:xls) + end + end + + describe '#method_missing' do + let(:report_class) do + Class.new(described_class) do + option :foo, :scalar + option :bar, :scalar + query :my_query + end + end + + subject { report_class.new(foo: 123) } + + it 'returns a query if given a query name' do + expect(subject.my_query).to eq(subject.queries[:my_query]) + end + + it 'returns query results if given a query_results' do + subject.run + expect(subject.my_query_results).to eql(subject.results[:my_query]) + end + + it 'returns the param value if given a param name' do + expect(subject.foo).to eq(123) + end + + it 'returns the param truthiness if given a param predicate' do + expect(subject).to be_foo + expect(subject).to_not be_bar + end + end + + describe '#respond_to_missing?' do + let(:report_class) do + Class.new(described_class) do + option :foo, :scalar + query :my_query + end + end + + subject { report_class.new } + + it 'accepts the name of a query' do + expect(subject).to respond_to :my_query + end + + it 'accepts the name of a query with _results' do + expect(subject).to respond_to :my_query_results + end + + it 'accepts the name of an option' do + expect(subject).to respond_to :foo + end + + it 'accepts the name of an option as a predicate' do + expect(subject).to respond_to :foo? + end + + it 'does not accept the name of an option with _results' do + expect(subject).to_not respond_to :foo_results + end + + it 'does not accept the name of a query as a predicate' do + expect(subject).to_not respond_to :my_query? + end + end +end diff --git a/spec/compendium/result_set_spec.rb b/spec/compendium/result_set_spec.rb new file mode 100644 index 0000000..fbd44ef --- /dev/null +++ b/spec/compendium/result_set_spec.rb @@ -0,0 +1,32 @@ +require 'compendium/result_set' + +RSpec.describe Compendium::ResultSet do + describe '#initialize' do + subject { described_class.new(results).records } + + context 'when given an array' do + let(:results) { [1, 2, 3] } + + it { is_expected.to eq([1, 2, 3]) } + end + + context 'when given an array of hashes' do + let(:results) { [{ one: 1 }, { two: 2 }] } + + it { is_expected.to eq([{ 'one' => 1 }, { 'two' => 2 }]) } + specify { expect(subject.first).to be_a ActiveSupport::HashWithIndifferentAccess } + end + + context 'when given a hash' do + let(:results) { { one: 1, two: 2 } } + + it { is_expected.to eq(one: 1, two: 2) } + end + + context 'when given a scalar' do + let(:results) { 3 } + + it { is_expected.to eq([3]) } + end + end +end diff --git a/spec/count_query_spec.rb b/spec/count_query_spec.rb deleted file mode 100644 index 46706eb..0000000 --- a/spec/count_query_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -require 'spec_helper' -require 'compendium' -require 'compendium/count_query' - -class SingleCounter - def count - 1792 - end -end - -class MultipleCounter - def order(*) - @order = true - self - end - - def reverse_order - @reverse = true - self - end - - def count - results = { 1 => 340, 2 => 204, 3 => 983 } - - if @order - results = results.sort_by{ |r| r[1] } - results.reverse! if @reverse - results = Hash[results] - end - - results - end -end - -describe Compendium::CountQuery do - subject { described_class.new(:counted_query, { count: true }, -> * { @counter }) } - - it 'should have a default order' do - expect(subject.options[:order]).to eq('COUNT(*)') - expect(subject.options[:reverse]).to eq(true) - end - - describe "#run" do - it "should call count on the proc result" do - @counter = SingleCounter.new - expect(@counter).to receive(:count).and_return(1234) - subject.run(nil, self) - end - - it "should return the count" do - @counter = SingleCounter.new - expect(subject.run(nil, self)).to eq([1792]) - end - - context 'when given a hash' do - before { @counter = MultipleCounter.new } - - it "should return a hash" do - expect(subject.run(nil, self)).to eq({ 3 => 983, 1 => 340, 2 => 204 }) - end - - it 'should be ordered in descending order' do - expect(subject.run(nil, self).keys).to eq([3, 1, 2]) - end - - it 'should use the given options' do - subject.options[:reverse] = false - expect(subject.run(nil, self).keys).to eq([2, 1, 3]) - end - end - - it "should raise an error if the proc does not respond to count" do - @counter = Class.new - expect { subject.run(nil, self) }.to raise_error Compendium::InvalidCommand - end - end -end \ No newline at end of file diff --git a/spec/dsl_spec.rb b/spec/dsl_spec.rb deleted file mode 100644 index 6538411..0000000 --- a/spec/dsl_spec.rb +++ /dev/null @@ -1,307 +0,0 @@ -require 'spec_helper' -require 'compendium' -require 'compendium/dsl' - -describe Compendium::DSL do - subject do - Class.new do - extend Compendium::DSL - end - end - - describe "#option" do - before { subject.option :starting_on, :date } - - specify { expect(subject.options).to include :starting_on } - specify { expect(subject.options[:starting_on]).to be_date } - - it "should allow previously defined options to be redefined" do - subject.option :starting_on, :boolean - expect(subject.options[:starting_on]).to be_boolean - expect(subject.options[:starting_on]).not_to be_date - end - - it "should allow overriding default value" do - proc = -> { Date.new(2013, 6, 1) } - subject.option :starting_on, :date, default: proc - expect(subject.options[:starting_on].default).to eq(proc) - end - - it "should add validations" do - subject.option :foo, validates: { presence: true } - expect(subject.params_class.validators_on(:foo)).not_to be_empty - end - - it "should not add validations if no validates option is given" do - expect(subject.params_class).not_to receive :validates - subject.option :foo - end - - it "should not bleed overridden options into the superclass" do - r = Class.new(subject) - r.option :starting_on, :boolean - r.option :new, :date - expect(subject.options[:starting_on]).to be_date - end - end - - describe "#query" do - let(:proc1) { -> { :proc1 } } - let(:proc2) { -> { :proc2 } } - - let(:report_class) do - proc1 = proc1 - - Class.new(Compendium::Report) do - query :test, &proc1 - end - end - - subject { report_class } - - specify { expect(subject.queries).to include :test } - - it "should relate the new query back to the report instance" do - r = subject.new - expect(r.test.report).to eq(r) - end - - it "should relate a query to the report class" do - expect(subject.test.report).to eq(subject) - end - - context 'when overriding an existing query' do - before do - subject.query :test, &proc2 - subject.query :another_test, count: true - end - - it 'should delete the existing query' do - expect(subject.queries.count).to eq(2) - end - - it 'should only have one query with each name' do - expect(subject.queries.map(&:name)).to match_array([:test, :another_test]) - end - - it 'should use the new proc' do - expect(subject.test.proc).to eq(proc2) - end - - it 'should not allow replacing a query with a different type' do - expect { subject.query :test, count: true }.to raise_error { Compendium::CannotRedefineQueryType } - expect(subject.test).to be_instance_of Compendium::Query - end - - it 'should allow replacing a query with the same type' do - subject.query :another_test, count: true, &proc2 - expect(subject.another_test.proc).to eq(proc2) - expect(subject.another_test).to be_instance_of Compendium::CountQuery - end - end - - context "when given a through option" do - before { report_class.query :through, through: :test } - subject { report_class.queries[:through] } - - specify { is_expected.to be_a Compendium::ThroughQuery } - specify { expect(subject.through).to eq([:test]) } - end - - context "when given a collection option" do - subject { report_class.queries[:collection] } - - context "that is an enumerable" do - before { report_class.query :collection, collection: [] } - - it { is_expected.to be_a Compendium::CollectionQuery } - end - - context "that is a symbol" do - let(:query) { double("Query") } - - before do - allow_any_instance_of(Compendium::Query).to receive(:get_associated_query).with(:query).and_return(query) - report_class.query :collection, collection: :query - end - - specify { expect(subject.collection).to eq(:query) } - end - - context "that is a query" do - let(:query) { Compendium::Query.new(:query, {}, ->{}) } - before { report_class.query :collection, collection: query } - - specify { expect(subject.collection).to eq(query) } - end - end - - context "when given a count option" do - subject{ report_class.queries[:counted] } - - context "set to true" do - before { report_class.query :counted, count: true } - it { is_expected.to be_a Compendium::CountQuery } - end - - context "set to false" do - before { report_class.query :counted, count: false } - it { is_expected.to be_a Compendium::Query } - it { is_expected.not_to be_a Compendium::CountQuery } - end - end - - context 'when given a sum option' do - subject{ report_class.queries[:summed] } - - context 'set to a truthy value' do - before { report_class.query :summed, sum: :assoc_count } - - it { is_expected.to be_a Compendium::SumQuery } - specify { expect(subject.column).to eq(:assoc_count) } - end - - context 'set to false' do - before { report_class.query :summed, sum: false } - it { is_expected.to be_a Compendium::Query } - it { is_expected.not_to be_a Compendium::SumQuery } - end - end - end - - describe "#chart" do - before { subject.chart(:chart) } - specify { expect(subject.queries).to include :chart } - end - - describe "#data" do - before { subject.data(:data) } - specify { expect(subject.queries).to include :data } - end - - describe "#metric" do - let(:metric_proc) { ->{ :metric } } - - before do - subject.query :test - subject.metric :test_metric, metric_proc, through: :test - end - - it "should add a metric to the given query" do - expect(subject.queries[:test].metrics.first.name).to eq(:test_metric) - end - - it "should set the metric command" do - expect(subject.queries[:test].metrics.first.command).to eq(metric_proc) - end - - context "when through is specified" do - it "should raise an error if specified for an invalid query" do - expect{ subject.metric :test_metric, metric_proc, through: :fake }.to raise_error ArgumentError, 'query fake is not defined' - end - - it "should allow metrics to be defined with a block" do - subject.metric :block_metric, through: :test do - 123 - end - - expect(subject.queries[:test].metrics[:block_metric].run(self, nil)).to eq(123) - end - - it "should allow metrics to be defined with a lambda" do - subject.metric :block_metric, -> * { 123 }, through: :test - expect(subject.queries[:test].metrics[:block_metric].run(self, nil)).to eq(123) - end - end - - context "when through is not specified" do - before { subject.metric(:no_through_metric) { |data| data } } - - specify { expect(subject.queries).to include :__metric_no_through_metric } - - it "should return the result of the query as the result of the metric" do - expect(subject.queries[:__metric_no_through_metric].metrics[:no_through_metric].run(self, [123])).to eq(123) - end - end - end - - describe "#filter" do - let(:filter_proc) { ->{ :filter } } - - it "should add a filter to the given query" do - subject.query :test - subject.filter :test, &filter_proc - expect(subject.queries[:test].filters).to include filter_proc - end - - it "should raise an error if there is no query of the given name" do - expect { subject.filter :test, &filter_proc }.to raise_error(ArgumentError, "query test is not defined") - end - - it "should allow multiple filters to be defined for the same query" do - subject.query :test - subject.filter :test, &filter_proc - subject.filter :test, &->{ :another_filter } - expect(subject.queries[:test].filters.count).to eq(2) - end - - it "should allow a filter to be applied to multiple queries at once" do - subject.query :query1 - subject.query :query2 - subject.filter :query1, :query2, &filter_proc - expect(subject.queries[:query1].filters).to include filter_proc - expect(subject.queries[:query2].filters).to include filter_proc - end - end - - describe '#table' do - let(:table_proc) { -> { display_nil_as 'na' } } - - it 'should add table settings to the given query' do - subject.query :test - subject.table :test, &table_proc - expect(subject.queries[:test].table_settings).to eq(table_proc) - end - - it 'should raise an error if there is no query of the given name' do - expect { subject.table :test, &table_proc }.to raise_error(ArgumentError, "query test is not defined") - end - - it 'should allow table settings to be applied to multiple queries at once' do - subject.query :query1 - subject.query :query2 - subject.table :query1, :query2, &table_proc - expect(subject.queries[:query1].table_settings).to eq(table_proc) - expect(subject.queries[:query2].table_settings).to eq(table_proc) - end - end - - describe '#exports' do - it 'should not have any exporters by default' do - expect(subject.exporters).to be_empty - end - - it 'should set the export to true if no options are given' do - subject.exports :csv - expect(subject.exporters[:csv]).to eq(true) - end - - it 'should save any given options' do - subject.exports :csv, :main_query - subject.exports :pdf, :foo, :bar - expect(subject.exporters[:csv]).to eq(:main_query) - expect(subject.exporters[:pdf]).to eq([:foo, :bar]) - end - end - - it "should allow previously defined queries to be redefined by name" do - subject.query :test_query - subject.test_query foo: :bar - expect(subject.queries[:test_query].options).to eq({ foo: :bar }) - end - - it "should allow previously defined queries to be accessed by name" do - subject.query :test_query - expect(subject.test_query).to eq(subject.queries[:test_query]) - end -end diff --git a/spec/metric_spec.rb b/spec/metric_spec.rb deleted file mode 100644 index d0e5a6d..0000000 --- a/spec/metric_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -require 'compendium/metric' - -class MetricContext - def calculate(data) - data.first.first - end -end - -describe Compendium::Metric do - let(:ctx) { MetricContext.new } - let(:data) { [[1, 2, 3], [4, 5, 6]] } - - subject { described_class.new(:test_metric, :query, nil) } - - describe "#run" do - it "should delegate the command to the context when the command is a symbol" do - subject.command = :calculate - expect(subject.run(ctx, data)).to eq(1) - end - - it "should call the command when it is a proc" do - subject.command = -> d { d.flatten.inject(:+) } - expect(subject.run(ctx, data)).to eq(21) - end - - it "should allow procs that refer back to the context" do - subject.command = -> d { calculate(d) * 2 } - expect(subject.run(ctx, data)).to eq(2) - end - - context "when an if proc is given" do - before { subject.command = -> * { 100 } } - - it "should calculate the metric if the proc evaluates to true" do - subject.options[:if] = ->{ true } - expect(subject.run(ctx, data)).to eq(100) - end - - it "should not calculate the metric if the proc evaluates to false" do - subject.options[:if] = ->{ false } - expect(subject.run(ctx, data)).to be_nil - end - - it "should clear the result if the proc evaluates to false" do - subject.options[:if] = ->{ false } - subject.result = 123 - subject.run(ctx, data) - expect(subject.result).to be_nil - end - end - - context "when an unless proc is given" do - before { subject.command = -> * { 100 } } - - it "should calculate the metric if the proc evaluates to false" do - subject.options[:unless] = ->{ false } - expect(subject.run(ctx, data)).to eq(100) - end - - it "should not calculate the metric if the proc evaluates to true" do - subject.options[:unless] = ->{ true } - expect(subject.run(ctx, data)).to be_nil - end - - it "should clear the result if the proc evaluates to false" do - subject.options[:unless] = ->{ true } - subject.result = 123 - subject.run(ctx, data) - expect(subject.result).to be_nil - end - end - end - - describe "#ran?" do - it "should return true if there are any results" do - allow(subject).to receive_messages(result: 123) - expect(subject).to have_ran - end - - it "should return false if there are no results" do - expect(subject).not_to have_ran - end - end -end \ No newline at end of file diff --git a/spec/option_spec.rb b/spec/option_spec.rb deleted file mode 100644 index 1d25514..0000000 --- a/spec/option_spec.rb +++ /dev/null @@ -1,12 +0,0 @@ -require 'compendium/option' - -describe Compendium::Option do - it "should raise an ArgumentError if no name is given" do - expect { described_class.new }.to raise_error ArgumentError, "name must be provided" - end - - it "should set up type predicates from the type option" do - o = described_class.new(name: :option, type: :date) - expect(o).to be_date - end -end \ No newline at end of file diff --git a/spec/param_types_spec.rb b/spec/param_types_spec.rb deleted file mode 100644 index 3c2dd3b..0000000 --- a/spec/param_types_spec.rb +++ /dev/null @@ -1,166 +0,0 @@ -require 'compendium/param_types' - -describe Compendium::Param do - subject{ described_class.new(:test) } - - it { is_expected.not_to be_scalar } - it { is_expected.not_to be_boolean } - it { is_expected.not_to be_date } - it { is_expected.not_to be_dropdown } - it { is_expected.not_to be_radio } - - describe "#==" do - it "should compare to the param's value" do - allow(subject).to receive_messages(value: :test_value) - expect(subject).to eq(:test_value) - end - end -end - -describe Compendium::ScalarParam do - subject{ described_class.new(123) } - - it { is_expected.to be_scalar } - it { is_expected.not_to be_boolean } - it { is_expected.not_to be_date } - it { is_expected.not_to be_dropdown } - it { is_expected.not_to be_radio } - - it "should not change values" do - expect(subject).to eq(123) - end -end - -describe Compendium::ParamWithChoices do - subject{ described_class.new(0, %w(a b c)) } - - it { is_expected.not_to be_boolean } - it { is_expected.not_to be_date } - it { is_expected.not_to be_dropdown } - it { is_expected.not_to be_radio } - - it "should return the index when given an index" do - p = described_class.new(1, [:foo, :bar, :baz]) - expect(p).to eq(1) - end - - it "should return the index when given a value" do - p = described_class.new(:foo, [:foo, :bar, :baz]) - expect(p).to eq(0) - end - - it "should return the index when given a string value" do - p = described_class.new("2", [:foo, :bar, :baz]) - expect(p).to eq(2) - end - - it "should raise an error if given an invalid index" do - expect { described_class.new(3, [:foo, :bar, :baz]) }.to raise_error IndexError - end - - it "should raise an error if given a value that is not included in the choices" do - expect { described_class.new(:quux, [:foo, :bar, :baz]) }.to raise_error IndexError - end - - describe "#value" do - it "should return the value of the given choice" do - p = described_class.new(2, [:foo, :bar, :baz]) - expect(p.value).to eq(:baz) - end - end -end - -describe Compendium::BooleanParam do - subject{ described_class.new(true) } - - it { is_expected.not_to be_scalar } - it { is_expected.to be_boolean } - it { is_expected.not_to be_date } - it { is_expected.not_to be_dropdown } - it { is_expected.not_to be_radio } - - it "should pass along 0 and 1" do - expect(described_class.new(0)).to eq(0) - expect(described_class.new(1)).to eq(1) - end - - it "should convert a numeric string to a number" do - expect(described_class.new('0')).to eq(0) - expect(described_class.new('1')).to eq(1) - end - - it "should return 0 for a truthy value" do - expect(described_class.new(true)).to eq(0) - expect(described_class.new(:abc)).to eq(0) - end - - it "should return 1 for a falsey value" do - expect(described_class.new(false)).to eq(1) - expect(described_class.new(nil)).to eq(1) - end - - describe "#value" do - it "should return true for a truthy value" do - expect(described_class.new(true).value).to eq(true) - expect(described_class.new(:abc).value).to eq(true) - expect(described_class.new(0).value).to eq(true) - end - - it "should return false for a falsey value" do - expect(described_class.new(false).value).to eq(false) - expect(described_class.new(nil).value).to eq(false) - expect(described_class.new(1).value).to eq(false) - end - end - - describe "#!" do - it "should return false if the boolean is true" do - expect(!described_class.new(true)).to eq(false) - end - - it "should return true if the boolean is false" do - expect(!described_class.new(false)).to eq(true) - end - end -end - -describe Compendium::DateParam do - subject{ described_class.new(Date.today) } - - it { is_expected.not_to be_scalar } - it { is_expected.not_to be_boolean } - it { is_expected.to be_date } - it { is_expected.not_to be_dropdown } - it { is_expected.not_to be_radio } - - it "should convert date strings to date objects" do - p = described_class.new("2010-05-20") - expect(p).to eq(Date.new(2010, 5, 20)) - end - - it "should convert other date/time formats to date objects" do - described_class.new(DateTime.new(2010, 5, 20, 10, 30, 59)) == Date.new(2010, 5, 20) - described_class.new(Time.new(2010, 5, 20, 10, 30, 59)) == Date.new(2010, 5, 20) - described_class.new(Date.new(2010, 5, 20)) == Date.new(2010, 5, 20) - end -end - -describe Compendium::DropdownParam do - subject{ described_class.new(0, %w(a b c)) } - - it { is_expected.not_to be_scalar } - it { is_expected.not_to be_boolean } - it { is_expected.not_to be_date } - it { is_expected.to be_dropdown } - it { is_expected.not_to be_radio } -end - -describe Compendium::RadioParam do - subject{ described_class.new(0, %w(a b c)) } - - it { is_expected.not_to be_scalar } - it { is_expected.not_to be_boolean } - it { is_expected.not_to be_date } - it { is_expected.not_to be_dropdown } - it { is_expected.to be_radio } -end diff --git a/spec/presenters/option_spec.rb b/spec/presenters/option_spec.rb deleted file mode 100644 index a6ab611..0000000 --- a/spec/presenters/option_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -require 'spec_helper' -require 'compendium/presenters/option' -require 'compendium/option' - -describe Compendium::Presenters::Option do - let(:template) do - t = double('Template') - allow(t).to receive(:t) { |key| key } # Stub I18n.t to just return the given value - t - end - - let(:option) { Compendium::Option.new(name: :test_option) } - - subject { described_class.new(template, option) } - - describe "#name" do - it "should pass the name through I18n" do - expect(template).to receive(:t).with('options.test_option', anything) - subject.name - end - end - - describe "#note" do - before { allow(template).to receive(:content_tag) } - - it "should return nil if no note is specified" do - expect(subject.note).to be_nil - end - - it "should pass to I18n if the note option is set to true" do - option.merge!(note: true) - expect(template).to receive(:t).with(:test_option_note) - subject.note - end - - it "should pass to I18n if the note option is set" do - option.merge!(note: :the_note) - expect(template).to receive(:t).with(:the_note) - subject.note - end - - it "should create the note within a div with class option-note" do - option.merge!(note: true) - expect(template).to receive(:content_tag).with(:div, anything, class: 'option-note') - subject.note - end - end -end - diff --git a/spec/report_spec.rb b/spec/report_spec.rb deleted file mode 100644 index c8fd310..0000000 --- a/spec/report_spec.rb +++ /dev/null @@ -1,322 +0,0 @@ -require 'compendium/report' - -describe Compendium::Report do - subject { described_class } - - specify { expect(subject.queries).to be_empty } - specify { expect(subject.options).to be_empty } - - it "should not do anything when run" do - report = subject.new - report.run - expect(report.results).to be_empty - end - - context "with multiple instances" do - let(:report_class) do - Class.new(Compendium::Report) do - query :test - metric :test_metric, ->{}, through: :test - end - end - - subject { report_class.new } - let(:report2) { report_class.new } - - specify { expect(subject.queries).to_not equal report2.queries } - specify { expect(subject.queries).to_not equal report_class.queries } - specify { expect(subject.metrics).to_not equal report2.metrics } - end - - describe ".report_name" do - subject { TestReport = Class.new(described_class) } - specify { expect(subject.report_name).to eq(:test) } - end - - describe "#run" do - context do - let(:report_class) do - Class.new(Compendium::Report) do - option :first, :date - option :second, :date - - query :test do |params| - [params[:first].__getobj__, params[:second].__getobj__] - end - - metric :lambda_metric, -> results { results.to_a.max }, through: :test - metric(:block_metric, through: :test) { |results| results.to_a.max } - metric(:implicit_metric) { [1, 2, 3].count } - end - end - - subject { report_class.new(first: '2010-10-10', second: '2011-11-11') } - let!(:report2) { report_class.new } - - before do - allow_any_instance_of(Compendium::Query).to receive(:fetch_results) { |instance, c| c } - subject.run - end - - specify { expect(subject.test_results.records).to eq [Date.new(2010, 10, 10), Date.new(2011, 11, 11)] } - - it "should allow metric results to be accessed through a query" do - expect(subject.test.metrics[:lambda_metric].result).to eq(Date.new(2011, 11, 11)) - end - - it "should run its metrics defined as a lambda" do - expect(subject.metrics[:lambda_metric].result).to eq(Date.new(2011, 11, 11)) - end - - it "should run its metrics defined as a block" do - expect(subject.metrics[:block_metric].result).to eq(Date.new(2011, 11, 11)) - end - - it "should run its implicit metrics" do - expect(subject.metrics[:implicit_metric].result).to eq(3) - end - - it "should not affect other instances of the report class" do - expect(report2.test.results).to be_nil - expect(report2.metrics[:lambda_metric].result).to be_nil - end - - it "should not affect the class collections" do - expect(report_class.test.results).to be_nil - end - - context "with through queries" do - let(:report_class) do - Class.new(Compendium::Report) do - option :first, :boolean, default: false - query(:test) { |params| !!params[:first] ? [100, 200, 400, 800] : [1600, 3200, 6400]} - query(:through, through: :test) { |results| [results.first] } - end - end - - subject { report_class.new(first: true) } - - specify { expect(subject.through.results).to eq([100]) } - - it "should not mark other instances' queries as ran" do - expect(report2.test).not_to have_run - end - - it "should not affect other instances" do - report2.queries.each { |q| allow(q).to receive(:fetch_results) { |c| c } } - report2.run - expect(report2.through.results).to eq([1600]) - end - end - end - - context "when specifying which queries to run" do - let(:report_class) do - Class.new(Compendium::Report) do - query :first - query :second - end - end - - subject { report_class.new } - - it "should raise an error if given :only and :except options" do - expect{ subject.run(nil, only: :first, except: :second) }.to raise_error(ArgumentError) - end - - it "should raise an error if given an invalid query name" do - expect{ subject.run(nil, only: :foo) }.to raise_error(ArgumentError) - end - - it "should run all queries if nothing is specified" do - subject.run(nil) - expect(subject.first).to have_run - expect(subject.second).to have_run - end - - it "should only run queries specified by :only" do - subject.run(nil, only: :first) - expect(subject.first).to have_run - expect(subject.second).not_to have_run - end - - it "should allow multiple queries to be specified by :only" do - report_class.query(:third) {} - subject.run(nil, only: [:first, :third]) - expect(subject.first).to have_run - expect(subject.second).not_to have_run - expect(subject.third).to have_run - end - - it "should not run through queries related to a query specified by only if not also specified" do - report_class.query(:through, through: :first) {} - subject.run(nil, only: :first) - expect(subject.through).not_to have_run - end - - it "should run through queries related to a query specified by only if also specified" do - report_class.query(:through, through: :first) {} - subject.run(nil, only: [:first, :through]) - expect(subject.through).to have_run - end - - it "should not run queries specified by :except" do - subject.run(nil, except: :first) - expect(subject.first).not_to have_run - expect(subject.second).to have_run - end - - it "should allow multiple queries to be specified by :except" do - report_class.query(:third) {} - subject.run(nil, except: [:first, :third]) - expect(subject.first).not_to have_run - expect(subject.second).to have_run - expect(subject.third).not_to have_run - end - - it "should not run through queries excepted related to a query even if the main query is not excepted" do - report_class.query(:through, through: :first) {} - subject.run(nil, except: :through) - expect(subject.through).not_to have_run - expect(subject.first).to have_run - end - end - end - - describe "predicate methods" do - before do - OneReport = Class.new(Compendium::Report) - TwoReport = Class.new(Compendium::Report) - ThreeReport = Class.new - end - - after do - Object.send(:remove_const, :OneReport) - Object.send(:remove_const, :TwoReport) - Object.send(:remove_const, :ThreeReport) - end - - it { is_expected.to respond_to(:one?) } - it { is_expected.to respond_to(:two?) } - it { is_expected.not_to respond_to(:three?) } - - it { is_expected.not_to be_one } - it { is_expected.not_to be_two } - - specify { expect(OneReport).to be_one } - specify { expect(TwoReport).to be_two } - end - - describe "parameters" do - let(:report_class) { Class.new(subject) } - let(:report_class2) { Class.new(report_class) } - - it "should include ancestors params" do - expect(report_class.params_class.ancestors).to include subject.params_class - end - - it "should inherit validations" do - report_class.params_class.validates :foo, presence: true - expect(report_class2.params_class.validators_on(:foo)).not_to be_nil - end - end - - describe "#valid?" do - context 'built-in validations' do - let(:report_class) do - Class.new(described_class) do - option :id, :dropdown, choices: (0..10).to_a, validates: { presence: true } - end - end - - it "should return true if there are no validation failures" do - r = report_class.new(id: 5) - expect(r).to be_valid - end - - it "should return false if there are validation failures" do - r = report_class.new(id: nil) - expect(r).not_to be_valid - expect(r.errors.keys).to include :id - end - end - - context 'custom validation' do - let(:report_class) do - Class.new(described_class) do - option :number, :scalar - - validate do - errors.add(:number, :invalid_number) unless number.even? - end - end - end - - it "should return true if there are no validation failures" do - r = report_class.new(number: 4) - expect(r).to be_valid - end - - it "should return false if there are validation failures" do - r = report_class.new(number: 5) - expect(r).not_to be_valid - expect(r.errors.keys).to include :number - end - end - end - - describe '.filter' do - let(:filter_proc) { -> * {} } - - let(:report_class) do - Class.new(described_class) do - query :main_query - end - end - - let(:subclass1) do - k = Class.new(report_class) - k.filter(:main_query, &filter_proc) - k - end - - let(:subclass2) { Class.new(report_class) } - let(:subclass3) { Class.new(subclass1) } - - it 'should add filters to the specified query' do - expect(subclass1.main_query.filters).to include filter_proc - end - - it 'should add filters by inheritence' do - expect(subclass3.main_query.filters).not_to be_empty - end - - it 'should not bleed filters from a subclass into other subclasses' do - subclass1 - expect(subclass2.main_query.filters).to be_empty - end - end - - describe '#exports?' do - let(:report_class) do - Class.new(described_class) do - exports :csv, :main_query - exports :pdf, false - end - end - - subject { report_class.new } - - it 'should return true if there is an export for the given type' do - expect(subject.exports?(:csv)).to be_truthy - end - - it 'should return false if there is no export for the given type explicitly' do - expect(subject.exports?(:pdf)).to be_falsey - end - - it 'should return false if there is no export for the given type implicitly' do - expect(subject.exports?(:xls)).to be_falsey - end - end -end diff --git a/spec/result_set_spec.rb b/spec/result_set_spec.rb deleted file mode 100644 index 3537770..0000000 --- a/spec/result_set_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'compendium/result_set' - -describe Compendium::ResultSet do - describe "#initialize" do - subject{ described_class.new(results).records } - - context "when given an array" do - let(:results) { [1, 2, 3] } - it { is_expected.to eq([1, 2, 3]) } - end - - context "when given an array of hashes" do - let(:results) { [{one: 1}, {two: 2}] } - it { is_expected.to eq([{"one" => 1}, {"two" => 2}]) } - specify { expect(subject.first).to be_a ActiveSupport::HashWithIndifferentAccess } - end - - context "when given a hash" do - let(:results) { { one: 1, two: 2 } } - it { is_expected.to eq({ one: 1, two: 2 }) } - end - - context "when given a scalar" do - let(:results) { 3 } - it { is_expected.to eq([3]) } - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b84478f..23180bd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,19 @@ -$:.unshift File.expand_path("../../app/classes", __FILE__) +require 'bundler/setup' +require 'compendium' +require 'pry' -RSpec.configure do |rspec| - rspec.mock_with :rspec do |mocks| +RSpec.configure do |config| + config.filter_run_when_matching(:focus) + config.disable_monkey_patching! + config.example_status_persistence_file_path = '.rspec_status' + + config.order = :random + Kernel.srand(config.seed) + + config.mock_with :rspec do |mocks| mocks.yield_receiver_to_any_instance_implementation_blocks = true + mocks.verify_partial_doubles = true end end + +require 'compendium' diff --git a/spec/sum_query_spec.rb b/spec/sum_query_spec.rb deleted file mode 100644 index d108f49..0000000 --- a/spec/sum_query_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -require 'spec_helper' -require 'compendium/sum_query' -require 'compendium/report' - -class SingleSummer - def sum(col) - 1792 - end -end - -class MultipleSummer - def order(*) - @order = true - self - end - - def reverse_order - @reverse = true - self - end - - def sum(col) - results = { 1 => 340, 2 => 204, 3 => 983 } - - if @order - results = results.sort_by{ |r| r[1] } - results.reverse! if @reverse - results = Hash[results] - end - - results - end -end - -describe Compendium::SumQuery do - subject { described_class.new(:counted_query, :col, { sum: :col }, -> * { @counter }) } - - it 'should have a default order' do - expect(subject.options[:order]).to eq('SUM(col)') - expect(subject.options[:reverse]).to eq(true) - end - - describe "#run" do - it "should call sum on the proc result" do - @counter = SingleSummer.new - expect(@counter).to receive(:sum).with(:col).and_return(1234) - subject.run(nil, self) - end - - it "should return the sum" do - @counter = SingleSummer.new - expect(subject.run(nil, self)).to eq([1792]) - end - - context 'when given a hash' do - before { @counter = MultipleSummer.new } - - it "should return a hash if given" do - expect(subject.run(nil, self)).to eq({ 3 => 983, 1 => 340, 2 => 204 }) - end - - it 'should be ordered in descending order' do - expect(subject.run(nil, self).keys).to eq([3, 1, 2]) - end - - it 'should use the given options' do - subject.options[:reverse] = false - expect(subject.run(nil, self).keys).to eq([2, 1, 3]) - end - end - - it "should raise an error if the proc does not respond to sum" do - @counter = Class.new - expect { subject.run(nil, self) }.to raise_error Compendium::InvalidCommand - end - end -end \ No newline at end of file