rose 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|