compendium 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +15 -0
  2. data/README.md +70 -4
  3. data/Rakefile +4 -0
  4. data/app/assets/stylesheets/compendium/_metrics.css.scss +92 -0
  5. data/app/assets/stylesheets/compendium/options.css.scss +41 -0
  6. data/app/assets/stylesheets/compendium/report.css.scss +22 -0
  7. data/app/classes/compendium/presenters/base.rb +30 -0
  8. data/app/classes/compendium/presenters/chart.rb +31 -0
  9. data/app/classes/compendium/presenters/metric.rb +19 -0
  10. data/app/classes/compendium/presenters/option.rb +97 -0
  11. data/app/classes/compendium/presenters/query.rb +23 -0
  12. data/app/classes/compendium/presenters/settings/query.rb +22 -0
  13. data/app/classes/compendium/presenters/settings/table.rb +23 -0
  14. data/app/classes/compendium/presenters/table.rb +81 -0
  15. data/app/controllers/compendium/reports_controller.rb +52 -0
  16. data/app/helpers/compendium/reports_helper.rb +21 -0
  17. data/app/views/compendium/reports/run.haml +1 -0
  18. data/app/views/compendium/reports/setup.haml +14 -0
  19. data/compendium.gemspec +7 -1
  20. data/config/initializers/rails/active_record/connection_adapters/quoting.rb +14 -0
  21. data/config/initializers/ruby/numeric.rb +26 -0
  22. data/config/locales/en.yml +5 -0
  23. data/lib/compendium/abstract_chart_provider.rb +30 -0
  24. data/lib/compendium/chart_provider/amcharts.rb +20 -0
  25. data/lib/compendium/context_wrapper.rb +27 -0
  26. data/lib/compendium/dsl.rb +79 -0
  27. data/lib/compendium/engine/mount.rb +13 -0
  28. data/lib/compendium/engine.rb +8 -0
  29. data/lib/compendium/metric.rb +29 -0
  30. data/lib/compendium/open_hash.rb +68 -0
  31. data/lib/compendium/option.rb +37 -0
  32. data/lib/compendium/param_types.rb +91 -0
  33. data/lib/compendium/params.rb +40 -0
  34. data/lib/compendium/query.rb +94 -0
  35. data/lib/compendium/report.rb +56 -0
  36. data/lib/compendium/result_set.rb +24 -0
  37. data/lib/compendium/version.rb +1 -1
  38. data/lib/compendium.rb +46 -1
  39. data/spec/context_wrapper_spec.rb +71 -0
  40. data/spec/dsl_spec.rb +90 -0
  41. data/spec/metric_spec.rb +84 -0
  42. data/spec/option_spec.rb +12 -0
  43. data/spec/param_types_spec.rb +147 -0
  44. data/spec/params_spec.rb +28 -0
  45. data/spec/presenters/base_spec.rb +20 -0
  46. data/spec/presenters/option_spec.rb +49 -0
  47. data/spec/query_spec.rb +33 -0
  48. data/spec/report_spec.rb +93 -0
  49. data/spec/spec_helper.rb +1 -0
  50. metadata +135 -14
data/compendium.gemspec CHANGED
@@ -10,12 +10,18 @@ Gem::Specification.new do |gem|
10
10
  gem.email = ["dvandersluis@selfmgmt.com"]
11
11
  gem.description = %q{Ruby on Rails reporting framework}
12
12
  gem.summary = %q{Ruby on Rails reporting framework}
13
- gem.homepage = ""
13
+ gem.homepage = "https://github.com/dvandersluis/compendium"
14
+ gem.license = "MIT"
14
15
 
15
16
  gem.files = `git ls-files`.split($/)
16
17
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
18
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
19
  gem.require_paths = ["lib"]
19
20
 
21
+ gem.add_dependency 'rails', '>= 3.0.0'
22
+ gem.add_dependency 'sass-rails', '>= 3.0.0'
23
+ gem.add_dependency 'compass-rails', '>= 1.0.0'
24
+ gem.add_dependency 'collection_of', '>= 1.0.3'
25
+ gem.add_dependency 'inheritable_attr', '>= 1.0.0'
20
26
  gem.add_development_dependency 'rspec', '~> 2.0'
21
27
  end
@@ -0,0 +1,14 @@
1
+ # ActiveRecord doesn't know how to handle SimpleDelegators when creating SQL
2
+ # This means that when passing a SimpleDelegator (ie. Compendium::Param) into ActiveRecord::Base.find, it'll
3
+ # crash.
4
+ # Override AR::ConnectionAdapters::Quoting to forward a SimpleDelegator's object to be quoted.
5
+
6
+ module ActiveRecord::ConnectionAdapters::Quoting
7
+ def quote_with_simple_delegator(value, column = nil)
8
+ return value.quoted_id if value.respond_to?(:quoted_id)
9
+ value = value.__getobj__ if value.is_a?(SimpleDelegator)
10
+ quote_without_simple_delegator(value, column)
11
+ end
12
+
13
+ alias_method_chain :quote, :simple_delegator
14
+ end
@@ -0,0 +1,26 @@
1
+ # Monkey patch to determine if an object is numeric
2
+ # Only Numerics and Strings/Symbols that are representations of numbers are numeric
3
+
4
+ class Object
5
+ def numeric?
6
+ false
7
+ end
8
+ end
9
+
10
+ class String
11
+ def numeric?
12
+ !(self =~ /\A-?\d+(\.\d+)?\z|\A-?\.\d+\z/).nil?
13
+ end
14
+ end
15
+
16
+ class Symbol
17
+ def numeric?
18
+ to_s.numeric?
19
+ end
20
+ end
21
+
22
+ class Numeric
23
+ def numeric?
24
+ true
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ en:
2
+ compendium:
3
+ reports:
4
+ invalid_report: "Invalid report!"
5
+ generate_report: "Generate Report"
@@ -0,0 +1,30 @@
1
+ require 'active_support/core_ext/string/inflections'
2
+
3
+ module Compendium
4
+ # Abstract wrapper for rendering charts
5
+ # To add a new chart provider, #initialize and #render must be implemented
6
+ class AbstractChartProvider
7
+ attr_reader :chart
8
+
9
+ def initialize(type, data, &setup_proc)
10
+ raise NotImplementedError
11
+ end
12
+
13
+ def render(template, container)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ # As more chart providers are added, this method will have to be extended to find them
18
+ def self.find_chart_provider
19
+ if defined?(AmCharts)
20
+ :AmCharts
21
+ else
22
+ self.name.demodulize.to_sym
23
+ end
24
+ end
25
+ end
26
+
27
+ module ChartProvider
28
+ autoload :AmCharts, 'compendium/chart_provider/amcharts'
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ module Compendium
2
+ module ChartProvider
3
+ # Uses the amcharts.rb gem to provide charting
4
+ class AmCharts < Compendium::AbstractChartProvider
5
+ def initialize(type, data, &setup_proc)
6
+ @chart = chart_class(type).new(data, &setup_proc)
7
+ end
8
+
9
+ def render(template, container)
10
+ template.amchart(chart, container)
11
+ end
12
+
13
+ private
14
+
15
+ def chart_class(type)
16
+ ::AmCharts::Chart.const_get(type.to_s.titlecase)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ require 'delegate'
2
+
3
+ module Compendium
4
+ class ContextWrapper
5
+ def self.wrap(ctx, parent, params = nil, &block)
6
+ delegator = ::SimpleDelegator.new(parent)
7
+
8
+ delegator.define_singleton_method(:__context__) { ctx }
9
+
10
+ delegator.instance_eval do
11
+ def method_missing(name, *args, &block)
12
+ return __context__.__send__(name, *args, &block) if __context__.respond_to?(name)
13
+ super
14
+ end
15
+
16
+ def respond_to_missing?(name, include_private = false)
17
+ return true if __context__.respond_to?(name, include_private)
18
+ super
19
+ end
20
+ end
21
+
22
+ return delegator.instance_exec(params, &block) if block_given?
23
+
24
+ delegator
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,79 @@
1
+ require 'collection_of'
2
+ require 'inheritable_attr'
3
+ require 'compendium/option'
4
+ require 'active_support/core_ext/class/attribute'
5
+
6
+ module Compendium
7
+ module DSL
8
+ def self.extended(klass)
9
+ klass.inheritable_attr :queries, default: ::Collection[Query]
10
+ klass.inheritable_attr :options, default: {}
11
+ end
12
+
13
+ def query(name, opts = {}, &block)
14
+ define_query(name, opts, &block)
15
+ end
16
+ alias_method :chart, :query
17
+ alias_method :data, :query
18
+
19
+ def option(name, *args)
20
+ opts = args.extract_options!
21
+ type = args.shift
22
+
23
+ if options[name]
24
+ options[name].type = type if type
25
+ options[name].merge!(opts)
26
+ else
27
+ options[name] = Compendium::Option.new(opts.merge(name: name, type: type))
28
+ end
29
+ end
30
+
31
+ def metric(name, proc, opts = {})
32
+ raise ArgumentError, 'through option must be specified for metric' unless opts.key?(:through)
33
+
34
+ [opts.delete(:through)].flatten.each do |query|
35
+ raise ArgumentError, "query #{query} is not defined" unless queries.key?(query)
36
+ queries[query].add_metric(name, proc, opts)
37
+ end
38
+ end
39
+
40
+ # Allow defined queries to be redefined by name, eg:
41
+ # query :main_query
42
+ # main_query { collect_records_here }
43
+ def method_missing(name, *args, &block)
44
+ if queries.keys.include?(name.to_sym)
45
+ query = queries[name.to_sym]
46
+ query.proc = block if block_given?
47
+ query.options = args.extract_options!
48
+ return query
49
+ end
50
+
51
+ super
52
+ end
53
+
54
+ def respond_to_missing?(name, *args)
55
+ return true if queries.keys.include?(name)
56
+ super
57
+ end
58
+
59
+ private
60
+
61
+ def define_query(name, opts, type = Query, &block)
62
+ name = name.to_sym
63
+ query = type.new(name, opts, block)
64
+
65
+ if opts.key?(:through)
66
+ through = [opts[:through]].flatten
67
+
68
+ through.each do |q|
69
+ raise ArgumentError, "query #{q} is not defined" unless self.queries.include?(q.to_sym)
70
+ end
71
+
72
+ query.through = through
73
+ end
74
+
75
+ metrics[name] = opts[:metric] if opts.key?(:metric)
76
+ queries << query
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,13 @@
1
+ module ActionDispatch
2
+ module Routing
3
+ class Mapper
4
+ def mount_compendium(options = {})
5
+ scope options[:at], controller: options.fetch(:controller, 'compendium/reports') do
6
+ get ':report_name', action: :setup, as: 'compendium_reports_setup'
7
+ post ':report_name', action: :run, as: 'compendium_reports_run'
8
+ root action: :index, as: 'compendium_reports_root'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ require 'compendium/engine/mount'
2
+
3
+ module Compendium
4
+ if defined?(Rails)
5
+ class Engine < ::Rails::Engine
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ module Compendium
2
+ Metric = Struct.new(:name, :query, :command, :options) do
3
+ attr_accessor :result
4
+
5
+ def initialize(*)
6
+ super
7
+ self.options ||= {}
8
+ end
9
+
10
+ def run(ctx, data)
11
+ self.result = if condition_failed?(ctx)
12
+ nil
13
+ else
14
+ command.is_a?(Symbol) ? ctx.send(command, data) : ctx.instance_exec(data, &command)
15
+ end
16
+ end
17
+
18
+ def ran?
19
+ !result.nil?
20
+ end
21
+ alias_method :has_ran?, :ran?
22
+
23
+ private
24
+
25
+ def condition_failed?(ctx)
26
+ (options.key?(:if) and !ctx.instance_exec(&options[:if])) or (options.key?(:unless) and ctx.instance_exec(&options[:unless]))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ require 'active_support/hash_with_indifferent_access'
2
+
3
+ module Compendium
4
+ class OpenHash < ::ActiveSupport::HashWithIndifferentAccess
5
+ class << self
6
+ def [](hash = {})
7
+ new(hash)
8
+ end
9
+ end
10
+
11
+ def dup
12
+ self.class.new(self)
13
+ end
14
+
15
+ protected
16
+
17
+ def convert_value(value)
18
+ if value.is_a? Hash
19
+ Params[value].tap do |oh|
20
+ oh.each do |k, v|
21
+ oh[k] = convert_value(v) if v.is_a? Hash
22
+ end
23
+ end
24
+ elsif value.is_a?(Array)
25
+ value.dup.replace(value.map { |e| convert_value(e) })
26
+ else
27
+ value
28
+ end
29
+ end
30
+
31
+ def method_missing(name, *args, &block)
32
+ method = name.to_s
33
+
34
+ case method
35
+ when %r{.=$}
36
+ super unless args.length == 1
37
+ return self[method[0...-1]] = args.first
38
+
39
+ when %r{.\?$}
40
+ super unless args.empty?
41
+ return self.key?(method[0...-1].to_sym)
42
+
43
+ when %r{^_.}
44
+ super unless args.empty?
45
+ return self[method[1..-1]] if self.key?(method[1..-1].to_sym)
46
+
47
+ else
48
+ return self[method] if key?(method) or !respond_to?(method)
49
+ end
50
+
51
+ super
52
+ end
53
+
54
+ def respond_to_missing?(name, include_private = false)
55
+ method = name.to_s
56
+
57
+ case method
58
+ when %r{.[=?]$}
59
+ return true if self.key?(method[0...-1])
60
+
61
+ when %r{^_.}
62
+ return true if self.key?(method[1..-1])
63
+ end
64
+
65
+ super
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,37 @@
1
+ require 'compendium/open_hash'
2
+ require 'active_support/string_inquirer'
3
+ require 'active_support/core_ext/hash/indifferent_access'
4
+ require 'active_support/core_ext/module/delegation'
5
+
6
+ module Compendium
7
+ class Option
8
+ attr_reader :name, :type, :default, :options
9
+
10
+ delegate :boolean?, :date?, :dropdown?, :radio?, :text?, to: :type
11
+ delegate :merge, :merge!, :[], to: :@options
12
+
13
+ def initialize(hash = {})
14
+ raise ArgumentError, "name must be provided" unless hash.key?(:name)
15
+
16
+ @name = hash.delete(:name).to_sym
17
+ @default = hash.delete(:default)
18
+ self.type = hash.delete(:type)
19
+ @options = hash.with_indifferent_access
20
+ end
21
+
22
+ def type=(type)
23
+ @type = ActiveSupport::StringInquirer.new(type.to_s)
24
+ end
25
+
26
+ def method_missing(name, *args, &block)
27
+ return options[name] if options.key?(name)
28
+ return options.key?(name[0...-1]) if name.to_s.end_with?('?')
29
+ super
30
+ end
31
+
32
+ def respond_to_missing?(name, include_private = false)
33
+ return true if options.key?(name)
34
+ super
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,91 @@
1
+ require_relative '../../config/initializers/ruby/numeric'
2
+ require 'delegate'
3
+
4
+ module Compendium
5
+ class Param < ::SimpleDelegator
6
+ def boolean?; false; end
7
+ def date?; false; end
8
+ def dropdown?; false; end
9
+ def radio?; false; end
10
+
11
+ def ==(other)
12
+ return true if (value == other rescue false)
13
+ super
14
+ end
15
+
16
+ # Need to explicitly delegate nil? to the object, otherwise it's always false
17
+ # This is because SimpleDelegator is a non-nil object, and it only forwards non-defined methods!
18
+ def nil?
19
+ __getobj__.nil?
20
+ end
21
+ end
22
+
23
+ class ParamWithChoices < Param
24
+ def initialize(obj, choices)
25
+ @choices = choices
26
+
27
+ if @choices.respond_to?(:call)
28
+ # If given a proc, defer determining values until later.
29
+ index = obj
30
+ else
31
+ index = obj.numeric? ? obj.to_i : @choices.index(obj)
32
+ raise IndexError if (!obj.nil? and index.nil?) or index.to_i.abs > @choices.length - 1
33
+ end
34
+
35
+ super(index || 0)
36
+ end
37
+
38
+ def value
39
+ @choices[self]
40
+ end
41
+ end
42
+
43
+ class BooleanParam < Param
44
+ def initialize(obj, *)
45
+ # If given 0, 1, or a version thereof (ie. "0"), pass it along
46
+ return super obj.to_i if obj.numeric? and (0..1).cover?(obj.to_i)
47
+ super !!obj ? 0 : 1
48
+ end
49
+
50
+ def boolean?
51
+ true
52
+ end
53
+
54
+ def value
55
+ [true, false][self]
56
+ end
57
+
58
+ # When negating a BooleanParam, use the value instead
59
+ def !
60
+ !value
61
+ end
62
+ end
63
+
64
+ class DateParam < Param
65
+ def initialize(obj, *)
66
+ if obj.respond_to?(:to_date)
67
+ obj = obj.to_date
68
+ else
69
+ obj = Date.parse(obj) rescue nil
70
+ end
71
+
72
+ super obj
73
+ end
74
+
75
+ def date?
76
+ true
77
+ end
78
+ end
79
+
80
+ class RadioParam < ParamWithChoices
81
+ def radio?
82
+ true
83
+ end
84
+ end
85
+
86
+ class DropdownParam < ParamWithChoices
87
+ def dropdown?
88
+ true
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,40 @@
1
+ require 'compendium/open_hash'
2
+ require 'compendium/param_types'
3
+ require 'active_support/core_ext/string/inflections'
4
+ require 'active_support/core_ext/object/blank'
5
+
6
+ module Compendium
7
+ class Params < OpenHash
8
+ attr_reader :options
9
+
10
+ def initialize(hash = {}, options = {})
11
+ @options = options
12
+ super(prepare_hash_from_options(hash))
13
+ end
14
+
15
+ protected
16
+
17
+ def prepare_hash_from_options(params)
18
+ params = params.slice(*options.keys)
19
+
20
+ options.each do |option_name, metadata|
21
+ begin
22
+ klass = "Compendium::#{"#{metadata.type}Param".classify}".constantize
23
+ params[option_name] = klass.new(get_default_value(params[option_name], metadata.default), metadata[:choices])
24
+ rescue IndexError
25
+ raise IndexError, "invalid index for #{option_name}"
26
+ end
27
+ end
28
+
29
+ params
30
+ end
31
+
32
+ def get_default_value(current, default)
33
+ if current.blank? and !default.blank?
34
+ default.respond_to?(:call) ? default.call : default
35
+ else
36
+ current
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,94 @@
1
+ require 'compendium/result_set'
2
+ require 'compendium/params'
3
+ require 'collection_of'
4
+
5
+ module Compendium
6
+ class Query
7
+ attr_reader :name, :results, :metrics
8
+ attr_accessor :options, :proc, :through, :report
9
+
10
+ def initialize(name, options, proc)
11
+ @name = name
12
+ @options = options
13
+ @proc = proc
14
+ @metrics = ::Collection[Metric]
15
+ end
16
+
17
+ def initialize_clone(*)
18
+ super
19
+ @metrics = @metrics.clone
20
+ end
21
+
22
+ def run(params, context = self)
23
+ collect_results(params, context)
24
+ collect_metrics(context)
25
+
26
+ @results
27
+ end
28
+
29
+ def add_metric(name, proc, options = {})
30
+ Compendium::Metric.new(name, self.name, proc, options).tap { |m| @metrics << m }
31
+ end
32
+
33
+ def render_table(template, *options, &block)
34
+ Compendium::Presenters::Table.new(template, self, *options, &block).render
35
+ end
36
+
37
+ def render_chart(template, *options, &block)
38
+ Compendium::Presenters::Chart.new(template, self, *options, &block).render
39
+ end
40
+
41
+ def ran?
42
+ !@results.nil?
43
+ end
44
+ alias_method :has_run?, :ran?
45
+
46
+ def nil?
47
+ proc.nil?
48
+ end
49
+
50
+ private
51
+
52
+ def collect_results(params, context)
53
+ args = if through.nil?
54
+ params
55
+ else
56
+ collect_through_query_results(through, params, context)
57
+ end
58
+
59
+ command = context.instance_exec(args, &proc) if proc
60
+ command = fetch_results(command)
61
+ @results = ResultSet.new(command) if command
62
+ end
63
+
64
+ def collect_metrics(context)
65
+ metrics.each{ |m| m.run(context, results) } unless results.empty?
66
+ end
67
+
68
+ def fetch_results(command)
69
+ if options.key?(:through) or options.fetch(:collect, nil) == :active_record
70
+ command
71
+ else
72
+ ::ActiveRecord::Base.connection.select_all(command.respond_to?(:to_sql) ? command.to_sql : command)
73
+ end
74
+ end
75
+
76
+ def collect_through_query_results(through, params, context)
77
+ results = {}
78
+
79
+ through = [through].flatten.map(&method(:get_through_query))
80
+
81
+ through.each do |q|
82
+ q.run(params, context) unless q.ran?
83
+ results[q.name] = q.results.records
84
+ end
85
+
86
+ results = results[through.first.name] if through.size == 1
87
+ results
88
+ end
89
+
90
+ def get_through_query(name)
91
+ report.queries[name]
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,56 @@
1
+ require 'active_support/core_ext/class/attribute_accessors'
2
+ require 'active_support/core_ext/hash/slice'
3
+ require 'compendium/dsl'
4
+
5
+ module Compendium
6
+ class Report
7
+ attr_accessor :params, :results
8
+
9
+ extend Compendium::DSL
10
+
11
+ def self.inherited(report)
12
+ Compendium.reports << report
13
+ end
14
+
15
+ def initialize(params = {})
16
+ @params = Params.new(params, options)
17
+
18
+ # When creating a new report, map each query back to the report
19
+ queries.each { |q| q.report = self }
20
+ end
21
+
22
+ def run(context = nil)
23
+ self.context = context
24
+ self.results = {}
25
+
26
+ queries.each{ |q| self.results[q.name] = q.run(params, ContextWrapper.wrap(context, self)) }
27
+
28
+ self
29
+ end
30
+
31
+ def metrics
32
+ Collection[Metric, queries.map{ |q| q.metrics.to_a }.flatten]
33
+ end
34
+
35
+ private
36
+
37
+ attr_accessor :context
38
+
39
+ def method_missing(name, *args, &block)
40
+ prefix = name.to_s.sub(/(?:_results|\?)\Z/, '').to_sym
41
+
42
+ return queries[name] if queries.keys.include?(name)
43
+ return results[prefix] if name.to_s.end_with? '_results' and queries.keys.include?(prefix)
44
+ return params[name] if options.keys.include?(name)
45
+ return !!params[prefix] if name.to_s.end_with? '?' and options.keys.include?(prefix)
46
+ super
47
+ end
48
+
49
+ def respond_to_missing?(name, include_private = false)
50
+ prefix = name.to_s.sub(/_results\Z/, '').to_sym
51
+ return true if queries.keys.include?(name)
52
+ return true if name.to_s.end_with? '_results' and queries.keys.include?(prefix)
53
+ super
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,24 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+
3
+ module Compendium
4
+ class ResultSet
5
+ delegate :first, :last, :to_a, :empty?, :each, :map, :[], :count, :length, :size, :==, to: :records
6
+
7
+ attr_reader :records
8
+ alias :all :records
9
+
10
+ def initialize(records)
11
+ @records = records.map do |r|
12
+ r.respond_to?(:with_indifferent_access) ? r.with_indifferent_access : r
13
+ end
14
+ end
15
+
16
+ def keys
17
+ records.is_a?(Array) ? first.keys : records.keys
18
+ end
19
+
20
+ def as_json(options = {})
21
+ records.map{ |r| r.except(*options[:except]) }
22
+ end
23
+ end
24
+ end