Fork me on GitHub

Rose

A slick Ruby DSL for reporting.

ActiveRecord integrated too! (jump)

Installation

$ gem install rose

Getting Started

Making a Report

Start with any Ruby Object

class Flower < Struct.new(:id, :type, :color, :age)
end

Specify your column headers and how values should be generated

Rose.make(:poem, :class => Flower) do # :class restricts reporting to Flowers
  rows do
    column(:type => "Type")
    column("Color", &:color) # Equivalent to column(:color => "Color")
                             # Equivalent to column("Color") { |item| item.color }
  end
end

Of the DSL, rows {} is required, the rest is optional.

Running your Report

Rose(:poem).bloom([
  Flower.new(0, :roses, :red),
  Flower.new(1, :violets, :blue)
])

Rose(:poem).bloom returns a Ruport::Data::RoseTable (which is just a Ruport::Data::Table with a few extra methods). And like it’s predecessor, you can output to Text, HTML, PDF, CSV, etc. For example:

+-----------------+
|  Type   | Color |
+-----------------+
| roses   | red   |
| violets | blue  |
+-----------------+

Sorting

Sort :ascending (default) or :descending

Rose.make(:sorted_by_age) {
  rows do
    column(:type => "Type")
    column(:color => "Color")
    column(:age => "Age")
  end
  # Notice I'm referring to the column header "Age" instead of the attribute :age
  sort("Age", :descending)
}

Sorting happens with the text value of each cell (data is coerced to a String so it can be displayed in the report). If String comparisons don’t satisfy you, or you just want custom sorting

Rose.make(:sort_it_my_way) {
  rows do
    column(:type => "Type")
    column(:age => "Age")
  end
  sort("Age", :descending) { |age| age.to_i * 2 }
}

Filtering

You can apply filtering to reject rows from your report.

Rose.make(:no_blue_please) {
  rows do
    column(:type => "Type")
    column(:color => "Color")
    column(:age => "Age")
  end
  filter do |row|
    # Notice I'm using "Color", not :color
    row["Color"] != "blue"
  end
}

We use “Color” instead of :color, because row is a Ruport::Data::Record.

Summaries

Do sigma-type operations on your report, for say if you wanted a summary of each roses’ petal count distribution.

Rose.make(:count_all_colors) {
  rows do
    column(:type => "Type")
    column(:color => "Color")
  end
  summary("Type") do
    column("Color") { |colors| colors.uniq.join(", ") }
    column("Count") { |colors| colors.size }
  end
}

Pivots

For a pivot with row Color, column Type, and data Ages

Rose.make(:pivot_example) {
  rows do
    column(:type => "Type")
    column(:color => "Color")
    column(:age => "Age")
  end
  pivot("Color", "Type") do |rows|
    # Sum of Ages
    rows.map(&:Age).map(&:to_i).inject(0) { |sum,x| sum+x }
  end
}

Advanced Stuff: Import (Update)

Not only can you output data, you can import data too!

Making a Report Ready for Import

Rose.make(:with_find_and_update) do
  rows do
    identity(:id => "ID")      # !!!
    column(:type => "Type")
    column(:color => "Color")
    column(:age => "Age")
  end

  # The bare minimum for an importable report
  roots do
    # find is optional. By default will return first item with item["ID"] == idy
    find do |items, idy|
      items.find { |item| item.id.to_s == idy }
    end
    update do |item, updates|
      item.color = updates["Color"]
    end
  end
end

In order to update via import, you must specify one column as the identity column. Rose will pass in the correct cell as idy in your find {}

Manual Import

Assuming

@flowers = [
  Flower.new(0, :roses, :red, 1),
  Flower.new(1, :violets, :blue, 2),
  Flower.new(2, :roses, :red, 3)
]

Provide hash :with, paired idy => updates.

Rose(:with_find_and_update).photosynthesize(@flowers, {
  :with => {
    "0" => { "Color" => "blue" }
  }
})

“0” will be passed into find {}, returning @flowers.first. That will then be passed into update {} as item, along with { “Color” => “blue” } as updates. The resulting @flowers would be

[ #<struct Flower id=0, type=:roses, color="blue", age=1>, 
  #<struct Flower id=1, type=:violets, color=:blue, age=2>, 
  #<struct Flower id=2, type=:roses, color=:red, age=3>
]

CSV Import

Rose(:with_find_and_update).photosynthesize(@flowers, {
  :csv_file => "change_flowers.csv"
})

Where change_flowers.csv is

ID,Type,Color,Age
0,roses,blue,1
1,violets,red,2
2,roses,green,3

Would result in

[ #<struct Flower id=0, type=:roses, color="blue", age=1>,
  #<struct Flower id=1, type=:violets, color="red", age=2>,
  #<struct Flower id=2, type=:roses, color="green", age=3>
]

Preview Import

Sometimes you only want to see changes and not commit them (especially in the case of ActiveRecord)

Rose.make(:with_preview) do
  rows do
    identity(:id => "ID")
    column(:type => "Type")
    column(:color => "Color")
    column(:age => "Age")
  end
  roots do
    preview_update do |item, updates|
      # For example...
      item.preview(true); item.color = updates["Color"]
    end
    update { raise Exception, "you shouldn't be calling me" }
  end
end

Rose(:with_preview).photosynthesize(@flowers, {
  :with => {
    "0" => { "Color" => "blue" }
  },
  :preview => true # This is new
})

Rose(:with_preview).photosynthesize(@flowers, {
  :with => "change_flowers.csv",
  :preview  => true # This is new
})

Advanced Stuff: Import (Create)

What to do when additional information shows up in your import? Create!

Rose.make(:with_preview_create) do
  rows do
    identity(:id => "ID")
    column(:type => "Type")
    column(:color => "Color")
    column(:age => "Age")
  end
  roots do
    preview_create { |idy, updates| # Initialize }
    create { |idy, updates| # Initialize and save }
    update {}
  end
end

Rose will create when the find {} returns nil. And depending on whether or not :preview is true, it will call preview_create or create, respectively.

ActiveRecord

config.gem 'rose', :lib => 'rose/active_record'

The API is the same for the most part, except:

Employee.rose(:department_salaries) { ... }
# Almost equivalent to 
#   Rose.make(:department_salaries, :class => Employee)

Employee.rose_for(:department_salaries)
# Almost equivalent to
#   Rose(:department_salaries).photosynthesize(Employee.all)

Employee.rose_for(:department_salaries, :conditions => ["salary <> ?", nil])
# Almost equivalent to
#   Rose(:department_salaries).photosynthesize(
#     Employee.find(:all, :conditions => ["salary <> ?", nil])
#   )

Employee.root_for(:department_salaries, {
  :with => {
    "1" => { "Title" => "New Title" }
  },
  :preview => true
})
# Almost equivalent to
#   Rose(:department_salaries).photosynthesize(
#     Employee.find(:all, :conditions => ["salary <> ?", nil]), {
#     :with => {
#       "1" => { "Title" => "New Title" }
#     },
#     :preview => true
#   })

I say “Almost”, because for AR, seedlings are namespaced within the class. If you want direct access, you can use Employee.seedlings(:department_salaries).

Also, for AR, reporting happens within a transaction that is rolled-back on completion to prevent unwanted commits (at least on the :class)…

Notes

Rose obeys order of execution. If you do summary first and then sort, you can only sort on the resulting column headers from your summary.

To illustrate

Rose.make(:okay) {
  rows do
    column("Type", &:type)
    column(:color => "Color")
  end
  sort("Type", :descending)
  summary("Type") do
    column("Color") { |colors| colors.join(", ") }
  end
}

is valid, but

Rose.make(:not_okay) {
  rows do
    column("Type", &:type)
    column(:color => "Color")
  end
  summary("Type") do
    column("Color") { |colors| colors.join(", ") }
  end
  sort("Type", :descending)
}

is not.

Dependencies

gem 'ruport', '1.6.3'

Other

Inspired by Machinist and factory_girl.

Copyright

Copyright © 2010 Henry Hsu. See LICENSE for details.