hyper_active_record 0.0.1

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/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ pkg/*
2
+ *.gem
3
+ .bundle
4
+ *.db
5
+ *.log
6
+ .idea
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in hyper_active_record.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,46 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ hyper_active_record (0.0.1)
5
+ activerecord (~> 3.0.0)
6
+ activesupport (~> 3.0.0)
7
+
8
+ GEM
9
+ remote: http://rubygems.org/
10
+ specs:
11
+ activemodel (3.0.5)
12
+ activesupport (= 3.0.5)
13
+ builder (~> 2.1.2)
14
+ i18n (~> 0.4)
15
+ activerecord (3.0.5)
16
+ activemodel (= 3.0.5)
17
+ activesupport (= 3.0.5)
18
+ arel (~> 2.0.2)
19
+ tzinfo (~> 0.3.23)
20
+ activesupport (3.0.5)
21
+ arel (2.0.9)
22
+ builder (2.1.2)
23
+ diff-lcs (1.1.2)
24
+ factory_girl (1.3.3)
25
+ i18n (0.5.0)
26
+ rspec (2.5.0)
27
+ rspec-core (~> 2.5.0)
28
+ rspec-expectations (~> 2.5.0)
29
+ rspec-mocks (~> 2.5.0)
30
+ rspec-core (2.5.1)
31
+ rspec-expectations (2.5.0)
32
+ diff-lcs (~> 1.1.2)
33
+ rspec-mocks (2.5.0)
34
+ sqlite3 (1.3.3)
35
+ tzinfo (0.3.25)
36
+
37
+ PLATFORMS
38
+ ruby
39
+
40
+ DEPENDENCIES
41
+ activerecord (~> 3.0.0)
42
+ activesupport (~> 3.0.0)
43
+ factory_girl (~> 1.3.3)
44
+ hyper_active_record!
45
+ rspec (~> 2.5.0)
46
+ sqlite3 (~> 1.3.3)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2011 Deepak N
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ Description
2
+ ===========
3
+
4
+ Hyper Active Record is to showcase the features that would make active record super awesome
5
+
6
+ Install
7
+ =======
8
+
9
+ gem install hyper_active_record
10
+
11
+ If you are using bundler - add dependency in gem file
12
+
13
+ gem 'hyper_active_record'
14
+
15
+ What do you get
16
+ ===============
17
+ Hyper Active Record adds the ability to query active record models by virtual attributes. This is achieved by *enhancing* the active record query methods *where, all, count* to allow scope names in conditions. It works just like passing column names in conditions. The scope on a model acts as a virtual column.
18
+
19
+ For example : If Project model is defined as
20
+
21
+ class Project < ActiveRecord::Base
22
+ #Columns - :name, :start_date, :end_date, :priority
23
+ scope :started_after, lambda { |date| where('start_date > ?', date) }
24
+ scope :completed, lambda { where('end_date IS NOT NULL') }
25
+ end
26
+
27
+ You can do
28
+
29
+ Project.where(:completed => true, :started_after => 2.years.ago, :priority => 1)
30
+
31
+ This works on any active record relation
32
+
33
+ Company.projects.where(:completed => true, :started_after => 2.years.ago)
34
+
35
+ Also you can use *scoped_by* method on a active record relation. This method accepts a hash of scope name and parameters and returns a scoped object.
36
+
37
+ Project.scoped_by(:completed => true, :started_after => 2.years.ago)
38
+
39
+ How is this useful?
40
+ ===================
41
+ You can use this to search a model by the query parameters(may be from a search form) in the index action of a controller. The query parameters can be a combination of database columns and virtual columns (provided a scope is defined for the virtual column).
42
+
43
+ If you are already using [inherited\_resources](https://github.com/josevalim/inherited_resources) and [has\_scope](https://github.com/plataformatec/has_scope), unit testing the controller would become simpler if implementation has\_scope is changed to just pass all the query params to *where* or *all* method on the model. This would eliminate the need of data setup for testing the has\_scope declaration on a controller. With this change you can just mock the model's query method to verify the scope name was passed as a condition in the method parameters.
44
+
45
+ What next?
46
+ ==========
47
+ The intention of this gem is to just get a feel of the benefits from this feature.
48
+
49
+ I would like to see these features built into the active_record instead of a monkey patch like this.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require "rspec"
5
+ require "rspec/core/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new do |t|
8
+ t.rspec_opts = %w(--format documentation --color)
9
+ t.pattern = 'spec/**/*_spec.rb'
10
+ end
11
+
12
+ task :default => ["spec"]
13
+
14
+ desc 'Clear out RDoc and generated packages'
15
+ task :clean do
16
+ rm_rf "hyper_active_record.log"
17
+ rm_rf "hyper_active_record_test.db"
18
+ rm_rf "pkg"
19
+ end
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "hyper_active_record/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "hyper_active_record"
7
+ s.version = HyperActiveRecord::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Deepak N"]
10
+ s.email = ["endeep123@gmail.com"]
11
+ s.homepage = "https://github.com/endeepak/hyper_active_record"
12
+ s.summary = %q{hyper active record}
13
+ s.description = %q{Makes active record super awesome}
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
+ s.require_paths = ["lib"]
19
+
20
+ s.add_runtime_dependency("activerecord", "~> 3.0.0")
21
+ s.add_runtime_dependency("activesupport", "~> 3.0.0")
22
+ s.add_development_dependency("rspec", "~> 2.5.0")
23
+ s.add_development_dependency("sqlite3", "~> 1.3.3")
24
+ s.add_development_dependency('factory_girl', '~> 1.3.3')
25
+ end
@@ -0,0 +1 @@
1
+ require 'hyper_active_record/query_methods'
@@ -0,0 +1,37 @@
1
+ require 'active_record'
2
+ require 'active_support/core_ext/module/delegation'
3
+
4
+ module HyperActiveRecord
5
+ module QueryMethods
6
+ APPLIED_SCOPE_MARKER = :':applied_scope:'
7
+
8
+ def where(opts, *rest)
9
+ return super unless opts.is_a?(Hash)
10
+ return super if opts.delete(APPLIED_SCOPE_MARKER)
11
+
12
+ scope_options, other_options = slice_scopes(opts)
13
+ relation = self.scoped_by(scope_options)
14
+
15
+ relation.where(other_options.merge(APPLIED_SCOPE_MARKER => true), *rest)
16
+ end
17
+
18
+ def scoped_by(opts)
19
+ opts.inject(clone) do |relation, scope|
20
+ name, value = *scope
21
+ relation = value.is_a?(Array) ? relation.send(name, *value) : relation.send(name, value)
22
+ end
23
+ end
24
+
25
+ protected
26
+ def slice_scopes(opts)
27
+ scope_options = {}
28
+ opts.each do |name, value|
29
+ next unless scopes.has_key?(name.to_sym)
30
+ scope_options[name] = value
31
+ end
32
+ return scope_options, opts.except(*(scope_options.keys))
33
+ end
34
+ end
35
+ end
36
+
37
+ ActiveRecord::Relation.send(:include, HyperActiveRecord::QueryMethods)
@@ -0,0 +1,3 @@
1
+ module HyperActiveRecord
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,9 @@
1
+ sqlite3:
2
+ adapter: sqlite3
3
+ database: hyper_active_record_test.db
4
+ mysql:
5
+ adapter: mysql
6
+ host: localhost
7
+ username: root
8
+ password:
9
+ database: hyper_active_record_test
data/spec/db/init.rb ADDED
@@ -0,0 +1,3 @@
1
+ require File.dirname(__FILE__) + '/test_database'
2
+
3
+ TestDatabase.initialize
data/spec/db/schema.rb ADDED
@@ -0,0 +1,14 @@
1
+ ActiveRecord::Schema.define(:version => 0) do
2
+ create_table :projects, :force => true do |t|
3
+ t.column :name, :string
4
+ t.column :start_date, :date
5
+ t.column :end_date, :date
6
+ t.column :priority, :integer
7
+ t.column :company_id, :integer
8
+ t.timestamps
9
+ end
10
+ create_table :companies, :force => true do |t|
11
+ t.column :name, :string
12
+ t.timestamps
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ require 'active_record'
2
+ require 'logger'
3
+
4
+ class TestDatabase
5
+ def self.initialize
6
+ config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
7
+ ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/../../hyper_active_record.log")
8
+ database = ENV['DB'] || 'sqlite3'
9
+ ActiveRecord::Base.establish_connection(config[database])
10
+ ActiveRecord::Migration.verbose = false
11
+ load(File.dirname(__FILE__) + "/schema.rb")
12
+ end
13
+ end
data/spec/factories.rb ADDED
@@ -0,0 +1,5 @@
1
+ Factory.define(:project) do |f|
2
+ f.sequence(:name) {|n| "Project #{n}"}
3
+ f.sequence(:start_date) { |n| n.years.ago }
4
+ f.sequence(:priority) {|n| 40 + n }
5
+ end
@@ -0,0 +1,181 @@
1
+ require 'spec_helper'
2
+
3
+ describe HyperActiveRecord::QueryMethods do
4
+ before(:each) do
5
+ class Project < ActiveRecord::Base
6
+ scope :started_after, lambda { |date| where('start_date > ?', date) }
7
+ scope :started_during, lambda { |begining, ending| where('start_date BETWEEN ? AND ?', begining, ending) }
8
+ scope :completed, lambda { where('end_date IS NOT NULL') }
9
+ scope "running", lambda { where('end_date IS NULL') }
10
+ belongs_to :company
11
+ end
12
+ class Company < ActiveRecord::Base
13
+ has_many :projects
14
+ end
15
+ end
16
+
17
+ after(:each) do
18
+ Project.scopes.clear
19
+ Project.delete_all
20
+ Company.delete_all
21
+ end
22
+
23
+ context "for scope with no parameters" do
24
+ it "applies scope ignoring parameter" do
25
+ project_1 = Factory(:project, :end_date => Date.today)
26
+ project_2 = Factory(:project, :end_date => nil)
27
+ project_3 = Factory(:project, :end_date => Date.today)
28
+
29
+ conditions = {:completed => 'anything'}
30
+ expected_records = [project_1, project_3]
31
+
32
+ Project.where(conditions).should == expected_records
33
+ Project.all(:conditions => conditions).should == expected_records
34
+ Project.count(:conditions => conditions).should == expected_records.size
35
+ end
36
+ end
37
+
38
+ context "when conditions key is a string" do
39
+ it "applies scope" do
40
+ project_1 = Factory(:project, :end_date => Date.today)
41
+ project_2 = Factory(:project, :end_date => nil)
42
+
43
+ conditions = {"completed" => true}
44
+ expected_records = [project_1]
45
+
46
+ Project.where(conditions).should == expected_records
47
+ Project.all(:conditions => conditions).should == expected_records
48
+ Project.count(:conditions => conditions).should == expected_records.size
49
+ end
50
+ end
51
+
52
+ context "when scope name is a string" do
53
+ it "applies scope" do
54
+ project_1 = Factory(:project, :end_date => Date.today)
55
+ project_2 = Factory(:project, :end_date => nil)
56
+
57
+ conditions = {:running => true}
58
+ expected_records = [project_2]
59
+
60
+ Project.where(conditions).should == expected_records
61
+ Project.all(:conditions => conditions).should == expected_records
62
+ Project.count(:conditions => conditions).should == expected_records.size
63
+ end
64
+ end
65
+
66
+ context "for scope with single parameter" do
67
+ it "applies scope" do
68
+ old_project = Factory(:project, :start_date => 2.years.ago)
69
+ new_project = Factory(:project, :start_date => Date.today)
70
+
71
+ conditions = {:started_after => 1.year.ago}
72
+ expected_records = [new_project]
73
+
74
+ Project.where(conditions).should == expected_records
75
+ Project.all(:conditions => conditions).should == expected_records
76
+ Project.count(:conditions => conditions).should == expected_records.size
77
+ end
78
+ end
79
+
80
+ context "for scope with multiple parameters" do
81
+ it "applies scope" do
82
+ project_1 = Factory(:project, :start_date => 1.months.ago, :end_date => Date.today)
83
+ project_2 = Factory(:project, :start_date => 2.months.ago, :end_date => nil)
84
+ project_3 = Factory(:project, :start_date => 5.months.ago, :end_date => Date.today)
85
+
86
+ conditions = {:started_during => [3.months.ago, Date.today]}
87
+ expected_records = [project_1, project_2]
88
+
89
+ Project.where(conditions).should == expected_records
90
+ Project.all(:conditions => conditions).should == expected_records
91
+ Project.count(:conditions => conditions).should == expected_records.size
92
+ end
93
+ end
94
+
95
+ context "with combination condition and scope name" do
96
+ it "applies scope and matches by condition" do
97
+ old_critical_project = Factory(:project, :start_date => 2.years.ago, :priority => 1)
98
+ new_simple_project = Factory(:project, :start_date => Date.today, :priority => 2)
99
+ new_critical_project = Factory(:project, :start_date => Date.today, :priority => 1)
100
+
101
+ conditions = {:started_after => 1.year.ago, :priority => 2 }
102
+ expected_records = [new_simple_project]
103
+
104
+ Project.where(conditions).should == expected_records
105
+ Project.all(:conditions => conditions).should == expected_records
106
+ Project.count(:conditions => conditions).should == expected_records.size
107
+ end
108
+ end
109
+
110
+ context "with multiple scope names" do
111
+ it "applies all scopes" do
112
+ project_1 = Factory(:project, :start_date => 1.months.ago, :end_date => Date.today)
113
+ project_2 = Factory(:project, :start_date => 2.months.ago, :end_date => nil)
114
+ project_3 = Factory(:project, :start_date => 5.months.ago, :end_date => Date.today)
115
+
116
+ conditions = {:started_after => 3.month.ago, :completed => true}
117
+ expected_records = [project_1]
118
+
119
+ Project.where(conditions).should == expected_records
120
+ Project.all(:conditions => conditions).should == expected_records
121
+ Project.count(:conditions => conditions).should == expected_records.size
122
+ end
123
+ end
124
+
125
+ context "with only conditions and no scope names" do
126
+ it "mathes by conditions" do
127
+ project_1 = Factory(:project, :priority => 1)
128
+ project_2 = Factory(:project, :priority => 2)
129
+ project_3 = Factory(:project, :priority => 3)
130
+
131
+ conditions = {:priority => 2}
132
+ expected_records = [project_2]
133
+
134
+ Project.where(conditions).should == expected_records
135
+ Project.all(:conditions => conditions).should == expected_records
136
+ Project.count(:conditions => conditions).should == expected_records.size
137
+ end
138
+ end
139
+
140
+ context "when scope or column does not exist" do
141
+ it "raises error" do
142
+ conditions = {:non_existing_scope => 1.year.ago}
143
+ expected_error = /no such column: projects.non_existing_scope/
144
+
145
+ lambda { Project.where(conditions).to_a }.should raise_error(expected_error)
146
+ lambda { Project.all(:conditions => conditions) }.should raise_error(expected_error)
147
+ lambda { Project.count(:conditions => conditions) }.should raise_error(expected_error)
148
+ end
149
+ end
150
+
151
+ context "when scope name is same as column name" do
152
+ it "applies scope" do
153
+ Project.class_eval { scope :priority, lambda { |value| where('priority <= ?', value) } }
154
+ project_1 = Factory(:project, :priority => 1)
155
+ project_2 = Factory(:project, :priority => 2)
156
+ project_3 = Factory(:project, :priority => 3)
157
+
158
+ conditions = {:priority => 2}
159
+ expected_records = [project_1, project_2]
160
+
161
+ Project.where(conditions).should == expected_records
162
+ Project.all(:conditions => conditions).should == expected_records
163
+ Project.count(:conditions => conditions).should == expected_records.size
164
+ end
165
+ end
166
+
167
+ context "on a association relation" do
168
+ it "applies scope" do
169
+ company = Company.create!(:name => "aaa")
170
+ old_project = Factory(:project, :start_date => 2.years.ago, :company => company)
171
+ new_project = Factory(:project, :start_date => Date.today, :company => company)
172
+
173
+ conditions = {:started_after => 1.year.ago}
174
+ expected_records = [new_project]
175
+
176
+ company.projects.where(conditions).should == expected_records
177
+ company.projects.all(:conditions => conditions).should == expected_records
178
+ company.projects.count(:conditions => conditions).should == expected_records.size
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,5 @@
1
+ require 'factory_girl'
2
+ require File.dirname(__FILE__)+ '/factories'
3
+
4
+ require 'hyper_active_record'
5
+ require File.expand_path(File.dirname(__FILE__) + "/db/init.rb")
metadata ADDED
@@ -0,0 +1,169 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hyper_active_record
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Deepak N
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-04-24 00:00:00 +05:30
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: activerecord
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ hash: 7
30
+ segments:
31
+ - 3
32
+ - 0
33
+ - 0
34
+ version: 3.0.0
35
+ type: :runtime
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: activesupport
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ hash: 7
46
+ segments:
47
+ - 3
48
+ - 0
49
+ - 0
50
+ version: 3.0.0
51
+ type: :runtime
52
+ version_requirements: *id002
53
+ - !ruby/object:Gem::Dependency
54
+ name: rspec
55
+ prerelease: false
56
+ requirement: &id003 !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ hash: 27
62
+ segments:
63
+ - 2
64
+ - 5
65
+ - 0
66
+ version: 2.5.0
67
+ type: :development
68
+ version_requirements: *id003
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ prerelease: false
72
+ requirement: &id004 !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ~>
76
+ - !ruby/object:Gem::Version
77
+ hash: 29
78
+ segments:
79
+ - 1
80
+ - 3
81
+ - 3
82
+ version: 1.3.3
83
+ type: :development
84
+ version_requirements: *id004
85
+ - !ruby/object:Gem::Dependency
86
+ name: factory_girl
87
+ prerelease: false
88
+ requirement: &id005 !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ~>
92
+ - !ruby/object:Gem::Version
93
+ hash: 29
94
+ segments:
95
+ - 1
96
+ - 3
97
+ - 3
98
+ version: 1.3.3
99
+ type: :development
100
+ version_requirements: *id005
101
+ description: Makes active record super awesome
102
+ email:
103
+ - endeep123@gmail.com
104
+ executables: []
105
+
106
+ extensions: []
107
+
108
+ extra_rdoc_files: []
109
+
110
+ files:
111
+ - .gitignore
112
+ - Gemfile
113
+ - Gemfile.lock
114
+ - LICENSE
115
+ - README.md
116
+ - Rakefile
117
+ - hyper_active_record.gemspec
118
+ - lib/hyper_active_record.rb
119
+ - lib/hyper_active_record/query_methods.rb
120
+ - lib/hyper_active_record/version.rb
121
+ - spec/db/database.yml
122
+ - spec/db/init.rb
123
+ - spec/db/schema.rb
124
+ - spec/db/test_database.rb
125
+ - spec/factories.rb
126
+ - spec/hyper_active_record/query_methods_spec.rb
127
+ - spec/spec_helper.rb
128
+ has_rdoc: true
129
+ homepage: https://github.com/endeepak/hyper_active_record
130
+ licenses: []
131
+
132
+ post_install_message:
133
+ rdoc_options: []
134
+
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ none: false
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ hash: 3
143
+ segments:
144
+ - 0
145
+ version: "0"
146
+ required_rubygems_version: !ruby/object:Gem::Requirement
147
+ none: false
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ hash: 3
152
+ segments:
153
+ - 0
154
+ version: "0"
155
+ requirements: []
156
+
157
+ rubyforge_project:
158
+ rubygems_version: 1.4.2
159
+ signing_key:
160
+ specification_version: 3
161
+ summary: hyper active record
162
+ test_files:
163
+ - spec/db/database.yml
164
+ - spec/db/init.rb
165
+ - spec/db/schema.rb
166
+ - spec/db/test_database.rb
167
+ - spec/factories.rb
168
+ - spec/hyper_active_record/query_methods_spec.rb
169
+ - spec/spec_helper.rb