rose 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.autotest +3 -0
- data/.document +5 -0
- data/.gitignore +24 -0
- data/LICENSE +20 -0
- data/README.markdown +271 -0
- data/Rakefile +85 -0
- data/VERSION +1 -0
- data/features/rose.feature +9 -0
- data/features/step_definitions/rose_steps.rb +0 -0
- data/features/support/env.rb +4 -0
- data/lib/rose.rb +33 -0
- data/lib/rose/active_record.rb +56 -0
- data/lib/rose/attribute.rb +93 -0
- data/lib/rose/core_extensions.rb +27 -0
- data/lib/rose/object.rb +130 -0
- data/lib/rose/proxy.rb +114 -0
- data/lib/rose/ruport.rb +45 -0
- data/lib/rose/seedling.rb +75 -0
- data/lib/rose/shell.rb +40 -0
- data/rose.gemspec +86 -0
- data/spec/core_extensions_spec.rb +52 -0
- data/spec/db/schema.rb +32 -0
- data/spec/examples/update_flowers.csv +4 -0
- data/spec/examples/update_posts.csv +5 -0
- data/spec/rose/active_record_spec.rb +434 -0
- data/spec/rose/object_spec.rb +650 -0
- data/spec/rose_spec.rb +36 -0
- data/spec/spec.opts +6 -0
- data/spec/spec_helper.rb +52 -0
- metadata +160 -0
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'rose'
|
2
|
+
require 'active_record'
|
3
|
+
|
4
|
+
module Rose
|
5
|
+
# This class is provides ActiveRecord models the ability to run reports
|
6
|
+
class ActiveRecordAdapter < ObjectAdapter
|
7
|
+
# @see Rose::ObjectAdapter#sprout
|
8
|
+
def self.sprout(seedling, options={})
|
9
|
+
table = nil
|
10
|
+
options[:class].transaction do
|
11
|
+
table = super(seedling, options)
|
12
|
+
raise ActiveRecord::Rollback
|
13
|
+
end
|
14
|
+
table
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module ActiveRecordExtensions
|
19
|
+
def self.included(base)
|
20
|
+
base.extend(ClassMethods)
|
21
|
+
end
|
22
|
+
|
23
|
+
module ClassMethods
|
24
|
+
def rose(name, options={}, &blk)
|
25
|
+
instance = Rose::Seedling.new(Rose::ActiveRecordAdapter, options.merge(:class => self))
|
26
|
+
instance.instance_eval(&blk)
|
27
|
+
register_seedling(name, instance)
|
28
|
+
end
|
29
|
+
|
30
|
+
def rose_for(name, *args)
|
31
|
+
seedlings(name).bloom(self.find(:all, *args))
|
32
|
+
end
|
33
|
+
|
34
|
+
def root_for(name, options={}, *args)
|
35
|
+
seedlings(name).photosynthesize(self.find(:all, *args), options)
|
36
|
+
end
|
37
|
+
|
38
|
+
def seedlings(name)
|
39
|
+
@seedlings ||= {}
|
40
|
+
@seedlings[name]
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def register_seedling(name, instance)
|
46
|
+
@seedlings ||= {}
|
47
|
+
@seedlings[name] = Shell.new(instance)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# This extends ActiveRecord::Base to include Rose reporting
|
54
|
+
class ActiveRecord::Base
|
55
|
+
include Rose::ActiveRecordExtensions
|
56
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Rose
|
2
|
+
# An Attribute is value object used to operate on a table
|
3
|
+
class Attribute
|
4
|
+
attr_reader :method_name
|
5
|
+
attr_reader :column_name
|
6
|
+
|
7
|
+
def initialize(method_name, column_name)
|
8
|
+
@method_name = method_name
|
9
|
+
@column_name = column_name
|
10
|
+
end
|
11
|
+
|
12
|
+
def column_name
|
13
|
+
@column_name || @method_name
|
14
|
+
end
|
15
|
+
|
16
|
+
# This class defines an Attribute whose value is defined via a block
|
17
|
+
class Indirect < Attribute
|
18
|
+
attr_reader :value_block
|
19
|
+
|
20
|
+
def initialize(method_name, column_name, value_block)
|
21
|
+
super(method_name, column_name)
|
22
|
+
@value_block = value_block
|
23
|
+
end
|
24
|
+
|
25
|
+
def evaluate(item)
|
26
|
+
item.instance_eval(&@value_block)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# This class defines an Attribute whose value is defined via a method
|
31
|
+
class Direct < Attribute
|
32
|
+
def evaluate(item)
|
33
|
+
if item.respond_to?(@method_name.to_sym)
|
34
|
+
item.__send__(@method_name)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# This is a value object for pivot parameters
|
40
|
+
class Pivot < Indirect
|
41
|
+
alias_method :group_column, :method_name
|
42
|
+
alias_method :pivot_column, :column_name
|
43
|
+
|
44
|
+
def on(table)
|
45
|
+
table.pivot(pivot_column, :group_by => group_column, :values => value_block)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Defines a collection of attributes
|
50
|
+
class Collection < Array
|
51
|
+
def row
|
52
|
+
inject({}) do |row, attribute|
|
53
|
+
row[attribute.column_name] = yield(attribute) if block_given?
|
54
|
+
row
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def column_names
|
59
|
+
map(&:column_name)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# This is a value object for sort parameters
|
64
|
+
class Sort
|
65
|
+
attr_reader :column_name, :order, :sort_block
|
66
|
+
|
67
|
+
def initialize(column_name, order, &sort_block)
|
68
|
+
@column_name = column_name
|
69
|
+
@order = order
|
70
|
+
@sort_block = sort_block
|
71
|
+
end
|
72
|
+
|
73
|
+
def on(table)
|
74
|
+
if @sort_block
|
75
|
+
table.sort_rows_by!(nil, :order => @order) do |row|
|
76
|
+
@sort_block.call(row[@column_name])
|
77
|
+
end
|
78
|
+
else
|
79
|
+
table.sort_rows_by!(@column_name, :order => @order)
|
80
|
+
end
|
81
|
+
table
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# This class defines an Indirect Attribute for rejecting table rows
|
86
|
+
class Filter < Indirect
|
87
|
+
def on(table)
|
88
|
+
table.data.reject! { |record| !@value_block.call(record) }
|
89
|
+
table
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Rose
|
2
|
+
module CoreExtensions
|
3
|
+
# Validates that the given hash includes the required keys.
|
4
|
+
# If any keys are missing, ArgumentError will be raised.
|
5
|
+
#
|
6
|
+
# @param [Hash] hash the hash to validate
|
7
|
+
# @param [Array<Symbol>] required_keys the list of keys
|
8
|
+
# @raise [ArgumentError]
|
9
|
+
def require_keys(hash, *required_keys)
|
10
|
+
missing_keys = required_keys - hash.keys
|
11
|
+
raise ArgumentError, "Missing required key(s): #{missing_keys.join(', ')}" unless missing_keys.empty?
|
12
|
+
end
|
13
|
+
|
14
|
+
# Returns the values of required keys in the given hash
|
15
|
+
#
|
16
|
+
# @param [Hash] hash the hash to validate
|
17
|
+
# @param [Array<Symbol>] required_keys the list of keys
|
18
|
+
# @raise [ArgumentError] (see Rose::CoreExtensions#require_keys)
|
19
|
+
def required_values(hash, *required_keys)
|
20
|
+
require_keys(hash, *required_keys)
|
21
|
+
values = required_keys.inject([]) do |values, key|
|
22
|
+
values << hash[key]
|
23
|
+
end
|
24
|
+
required_keys.length == 1 ? values.first : values
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/rose/object.rb
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'rose'
|
2
|
+
require 'rose/ruport'
|
3
|
+
|
4
|
+
module Rose
|
5
|
+
# This class is provides Objects the ability to run reports
|
6
|
+
class ObjectAdapter
|
7
|
+
class << self
|
8
|
+
include CoreExtensions
|
9
|
+
end
|
10
|
+
|
11
|
+
# @param [Rose::Seedling] seedling the seedling to sprout
|
12
|
+
# @param [Hash] options the options to run with
|
13
|
+
# @option options [Array] :items (required) the items to report on
|
14
|
+
# @option options [Rose::Attribute::Collection] :attributes (nil) a row of attributes
|
15
|
+
# @option options [String] :group_column (nil) @deprecated
|
16
|
+
# @option options [String] :pivot_column (nil) @deprecated
|
17
|
+
# @option options [String] :value_block (nil) @deprecated
|
18
|
+
# @option options [String] :summary (nil) @deprecated
|
19
|
+
# @option options [String] :summary_on (nil) @deprecated
|
20
|
+
# @option options [String] :sort_by (nil) @deprecated
|
21
|
+
# @option options [String] :sort_order (nil) @deprecated
|
22
|
+
# @return [Ruport::Data::Table] the resulting table
|
23
|
+
def self.sprout(seedling, options={})
|
24
|
+
items, attributes = required_values(options, :items, :attributes)
|
25
|
+
table = Ruport::Data::RoseTable.new(:column_names => attributes.column_names)
|
26
|
+
|
27
|
+
self.rows(table, options)
|
28
|
+
|
29
|
+
if (alterations = options[:alterations])
|
30
|
+
alterations.each do |alteration|
|
31
|
+
table = alteration.on(table)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
table
|
36
|
+
end
|
37
|
+
|
38
|
+
# @param [Rose::Seedling] seedling the seedling to update
|
39
|
+
# @param [Hash] options the options to run with
|
40
|
+
# @option options [Array] :items (required) an Array of items
|
41
|
+
# @option options [Hash,String] :with (required) (see Rose::Shell#photosynthesize)
|
42
|
+
def self.osmosis(seedling, options={})
|
43
|
+
hash_or_csv, items = required_values(options, :with, :items)
|
44
|
+
|
45
|
+
root, row = seedling.root, seedling.row
|
46
|
+
idy_attr = row.identity_attribute
|
47
|
+
|
48
|
+
items = case hash_or_csv
|
49
|
+
when String # CSV File
|
50
|
+
self.osmosis_from_csv(root, options.merge(
|
51
|
+
:idy_attr => idy_attr,
|
52
|
+
:csv_file => hash_or_csv,
|
53
|
+
:items => items
|
54
|
+
))
|
55
|
+
when Hash
|
56
|
+
self.osmosis_from_hash(root, options.merge(
|
57
|
+
:idy_attr => idy_attr,
|
58
|
+
:updates => hash_or_csv,
|
59
|
+
:items => items
|
60
|
+
))
|
61
|
+
end
|
62
|
+
|
63
|
+
self.sprout(seedling, options.merge(
|
64
|
+
:attributes => row.attributes,
|
65
|
+
:items => items
|
66
|
+
))
|
67
|
+
end
|
68
|
+
|
69
|
+
protected
|
70
|
+
|
71
|
+
def self.rows(table, options={})
|
72
|
+
items, attributes = required_values(options, :items, :attributes)
|
73
|
+
items.each do |item|
|
74
|
+
options[:class].tap { |enforce_class| enforce_item_type(item, enforce_class) if enforce_class }
|
75
|
+
table << attributes.row { |attr| attr.evaluate(item).to_s }
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.enforce_item_type(item, klass)
|
80
|
+
raise TypeError.new("Expected #{klass}, got #{item.class}") unless item.kind_of?(klass)
|
81
|
+
end
|
82
|
+
|
83
|
+
# @return [Array] items (see Rose::ObjectAdapter#osmosis_from_hash)
|
84
|
+
def self.osmosis_from_csv(root, options={})
|
85
|
+
idy_attr, csv_file, items = required_values(options, :idy_attr, :csv_file, :items)
|
86
|
+
updates = data_from_csv(csv_file).inject({}) do |updates, data|
|
87
|
+
updates[data.delete(idy_attr.column_name)] = data
|
88
|
+
updates
|
89
|
+
end
|
90
|
+
self.osmosis_from_hash(root, options.merge(:updates => updates))
|
91
|
+
end
|
92
|
+
|
93
|
+
# @return [Array] items the updated items
|
94
|
+
def self.osmosis_from_hash(root, options={})
|
95
|
+
idy_attr, updates, items = required_values(options, :idy_attr, :updates, :items)
|
96
|
+
finder = root.finder || auto_finder(idy_attr)
|
97
|
+
new_items = []
|
98
|
+
updates.each do |idy, update|
|
99
|
+
record = use_finder(finder, items, idy)
|
100
|
+
if record
|
101
|
+
root.updater(options[:preview]).call(record, update)
|
102
|
+
elsif creator = root.creator(options[:preview])
|
103
|
+
new_items << creator.call(idy, update)
|
104
|
+
else
|
105
|
+
next
|
106
|
+
end
|
107
|
+
end
|
108
|
+
items | new_items
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.data_from_csv(csv_file)
|
112
|
+
Ruport::Data::RoseTable.load(csv_file).data.map(&:to_hash)
|
113
|
+
end
|
114
|
+
|
115
|
+
# @param [Rose::Attribute] idy_attr the attribute to find items with
|
116
|
+
# @return [Proc] a Proc that will find items with attribute evaluating to given id
|
117
|
+
def self.auto_finder(idy_attr)
|
118
|
+
lambda { |items, idy|
|
119
|
+
items.find do |item|
|
120
|
+
idy_attr.evaluate(item).to_s == idy
|
121
|
+
end
|
122
|
+
}
|
123
|
+
end
|
124
|
+
|
125
|
+
# In case subclasses want to call finders differently
|
126
|
+
def self.use_finder(finder, items, idy)
|
127
|
+
finder.call(items, idy)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
data/lib/rose/proxy.rb
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'rose/attribute'
|
2
|
+
|
3
|
+
module Rose
|
4
|
+
module Proxy
|
5
|
+
# This class is used by the DSL to collect a row of attributes
|
6
|
+
class Row
|
7
|
+
# Each attribute defines how values are generated for that column
|
8
|
+
# as well how that column is named
|
9
|
+
# @return [Rose::Attribute::Collection] A collection of attributes
|
10
|
+
attr_reader :attributes
|
11
|
+
|
12
|
+
# @return [Rose::Attribute] attribute used to determine which column
|
13
|
+
# is the id column
|
14
|
+
attr_reader :identity_attribute
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
@attributes = Attribute::Collection.new
|
18
|
+
end
|
19
|
+
|
20
|
+
def column(name, &blk)
|
21
|
+
@attributes << attribute(name, &blk)
|
22
|
+
end
|
23
|
+
|
24
|
+
def identity(name, &blk)
|
25
|
+
column(name, &blk)
|
26
|
+
@identity_attribute = @attributes.last
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.name_and_title(name)
|
30
|
+
case name
|
31
|
+
when Hash
|
32
|
+
name.to_a.first
|
33
|
+
else
|
34
|
+
[name, nil]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def attribute(name, &blk)
|
41
|
+
name, title = self.class.name_and_title(name)
|
42
|
+
|
43
|
+
if block_given?
|
44
|
+
attribute = Attribute::Indirect.new(name, title, blk)
|
45
|
+
else
|
46
|
+
attribute = Attribute::Direct.new(name, title)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# This class is used by the DSL to collect summary attributes
|
52
|
+
class Summary < Row
|
53
|
+
attr_reader :column_name
|
54
|
+
|
55
|
+
def initialize(column_name)
|
56
|
+
super()
|
57
|
+
@column_name = column_name
|
58
|
+
end
|
59
|
+
|
60
|
+
def on(table)
|
61
|
+
rows = table.column(@column_name).uniq.inject([]) do |rows, group|
|
62
|
+
rows << @attributes.row { |attr|
|
63
|
+
gr = table.grouped_rows({@column_name => group}, attr.column_name)
|
64
|
+
attr.evaluate(gr)
|
65
|
+
}.merge(@column_name => group)
|
66
|
+
end
|
67
|
+
Ruport::Data::RoseTable.new(:column_names => [@column_name] | @attributes.column_names).tap do |table|
|
68
|
+
rows.each { |row| table << row }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# This class is used by the DSL to collect update attributes.
|
74
|
+
# Just like a root is the foundation of transporting water into
|
75
|
+
# a tree, a Root provides what's required to import data into a Rose
|
76
|
+
class Root
|
77
|
+
attr_reader :finder
|
78
|
+
attr_reader :updater
|
79
|
+
attr_reader :update_previewer
|
80
|
+
attr_reader :creator
|
81
|
+
attr_reader :create_previewer
|
82
|
+
|
83
|
+
def find(&blk)
|
84
|
+
@finder = blk
|
85
|
+
end
|
86
|
+
|
87
|
+
def update(&blk)
|
88
|
+
@updater = blk
|
89
|
+
end
|
90
|
+
|
91
|
+
def preview_update(&blk)
|
92
|
+
@update_previewer = blk
|
93
|
+
end
|
94
|
+
|
95
|
+
def create(&blk)
|
96
|
+
@creator = blk
|
97
|
+
end
|
98
|
+
|
99
|
+
def preview_create(&blk)
|
100
|
+
@create_previewer = blk
|
101
|
+
end
|
102
|
+
|
103
|
+
# @param [true, false] preview whether to use the update_previewer or not
|
104
|
+
def updater(preview = false)
|
105
|
+
preview ? @update_previewer : @updater
|
106
|
+
end
|
107
|
+
|
108
|
+
# @param [true, false] preview whether to use the create_previewer or not
|
109
|
+
def creator(preview = false)
|
110
|
+
preview ? @create_previewer : @creator
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
data/lib/rose/ruport.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# Enables pivoting with value columns
|
2
|
+
class Ruport::Data::RoseTable < Ruport::Data::Table
|
3
|
+
def pivot(pivot_column, options = {})
|
4
|
+
group_column = options[:group_by] ||
|
5
|
+
raise(ArgumentError, ":group_by option required")
|
6
|
+
value_column = options[:values] ||
|
7
|
+
raise(ArgumentError, ":values option required")
|
8
|
+
RosePivot.new(
|
9
|
+
self, group_column, pivot_column, nil, options
|
10
|
+
).to_table(&value_column)
|
11
|
+
end
|
12
|
+
|
13
|
+
def grouped_rows(with, column_name)
|
14
|
+
rows_with(with).map { |row|
|
15
|
+
begin
|
16
|
+
row.send(column_name.to_sym)
|
17
|
+
rescue NoMethodError => no_method_error
|
18
|
+
nil
|
19
|
+
end
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Enables using value blocks for the value column
|
25
|
+
class Ruport::Data::Table::RosePivot < Ruport::Data::Table::Pivot
|
26
|
+
def to_table
|
27
|
+
result = Ruport::Data::RoseTable.new()
|
28
|
+
result.add_column(@group_column)
|
29
|
+
pivoted_columns = columns_from_pivot
|
30
|
+
pivoted_columns.each { |name| result.add_column(name) }
|
31
|
+
outer_grouping = Grouping(@table, :by => @group_column)
|
32
|
+
group_column_entries.each {|outer_group_name|
|
33
|
+
outer_group = outer_grouping[outer_group_name]
|
34
|
+
pivot_values = pivoted_columns.inject({}) do |hsh, pc|
|
35
|
+
matching_rows = outer_group.rows_with(@pivot_column => pc)
|
36
|
+
hsh[pc] = yield(matching_rows)
|
37
|
+
hsh
|
38
|
+
end
|
39
|
+
result << [outer_group_name] + pivoted_columns.map {|pc|
|
40
|
+
pivot_values[pc]
|
41
|
+
}
|
42
|
+
}
|
43
|
+
result
|
44
|
+
end
|
45
|
+
end
|