time-travel 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +293 -0
- data/Rakefile +29 -0
- data/lib/generators/time_travel/USAGE +9 -0
- data/lib/generators/time_travel/templates/time_travel_migration_existing.rb.erb +17 -0
- data/lib/generators/time_travel/templates/time_travel_migration_new.rb.erb +19 -0
- data/lib/generators/time_travel/time_travel_generator.rb +49 -0
- data/lib/tasks/create_postgres_function.rake +6 -0
- data/lib/time_travel/configuration.rb +9 -0
- data/lib/time_travel/railtie.rb +7 -0
- data/lib/time_travel/sql_function_helper.rb +19 -0
- data/lib/time_travel/timeline.rb +225 -0
- data/lib/time_travel/timeline_helper.rb +105 -0
- data/lib/time_travel/update_helper.rb +72 -0
- data/lib/time_travel/version.rb +3 -0
- data/lib/time_travel.rb +22 -0
- data/lib/time_travel_backup.rb +279 -0
- data/sql/create_column_value.sql +67 -0
- data/sql/get_json_attrs.sql +36 -0
- data/sql/update_bulk_history.sql +68 -0
- data/sql/update_history.sql +209 -0
- data/sql/update_latest.sql +94 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7066602cf148bc17416b4ce73cba9f5f0272d0e3fc65d51165c28e61b6810ace
|
4
|
+
data.tar.gz: 47a9320d5458174874d576b62b8f9e2edd9ffdce4aee51ff39273c6a0397cae2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: acc6c7acd035e5a242bdcc11bb4851b89488496945556e719f292ebc98cb3e79e4aa9b49ce8a60dc779e2362b9ac0d44a9e23f228ee00828313f595525b96bae
|
7
|
+
data.tar.gz: 7ec088cc98671dd0964e33e8a7034c278b1ce3d1230620b51e90a93632c6115e4cb02a66cfd58331d2a497b436ccce18248449e09150d3db01bf892773eb0955
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2018
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,293 @@
|
|
1
|
+
# Time Travel
|
2
|
+
|
3
|
+
The time travel gem adds in-table version control to your data. It lets you see, correct and update records at any point in time, but preserves the entire history of corrections and updates to your data so you can drill-down and find out exactly what happened when something goes wrong.
|
4
|
+
|
5
|
+
# How It Works
|
6
|
+
|
7
|
+
Lets say that we're a new bank and we're planning to maintain our customers' cash balances in a table called `balance`.
|
8
|
+
|
9
|
+
We first add time travel fields to the model with
|
10
|
+
```
|
11
|
+
> bundle exec rake generate time_travel balance
|
12
|
+
> bundle exec rake db:migrate
|
13
|
+
```
|
14
|
+
include the Time Travel helper in the `balance` model
|
15
|
+
|
16
|
+
```
|
17
|
+
class Balance < ActiveRecord::Base
|
18
|
+
include TimeTravel::TimelineHelper
|
19
|
+
|
20
|
+
...
|
21
|
+
```
|
22
|
+
and define the `timeline_fields` method to return the fields that uniquely identify each timeline in our model(in our case, the cash account id)
|
23
|
+
```
|
24
|
+
...
|
25
|
+
def self.timeline_fields
|
26
|
+
:cash_account_id
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
Then we start off our operations
|
31
|
+
|
32
|
+
## Day 1 - 6th Septermber - New Account
|
33
|
+
|
34
|
+
The operations team informs us that a new customer created our bank's first account and deposited $500 5 days ago
|
35
|
+
|
36
|
+
We record this info with
|
37
|
+
|
38
|
+
```
|
39
|
+
> timeline=balance.timeline(cash_account_id: 1)
|
40
|
+
> timeline.create(amount: 500, effective_from: Time.now - 5.days)
|
41
|
+
```
|
42
|
+
|
43
|
+
After a few minutes, they ping us again and tells us that the customer also submitted an addition $200 two days ago,
|
44
|
+
so we record that as well with
|
45
|
+
|
46
|
+
```
|
47
|
+
> timeline.update(amount: 700, effective_from: Time.now - 2.days)
|
48
|
+
```
|
49
|
+
|
50
|
+
## Day 2 - 7th September - Corrections
|
51
|
+
|
52
|
+
An operations guy walks in hurriedly and tells us that they are extremely sorry but the amounts deposited were recorded wrong, it was $600 and $300 and not $500 and $200
|
53
|
+
|
54
|
+
We cross-check with the team and record the updates
|
55
|
+
```
|
56
|
+
> timeline.update(amount: 600, effective_from: Time.now - 6 days)
|
57
|
+
> timeline.update(amount: 900, effective_from: Time.now - 3.days)
|
58
|
+
```
|
59
|
+
|
60
|
+
## Day 3 - 8th September - Reconcilliation
|
61
|
+
|
62
|
+
On day 3, the customer walks in and tells us that something is wrong with our systems and that the balances were different yesterday and day-before even though he didn't deposit or withdraw any money
|
63
|
+
|
64
|
+
So our support team starts with checking the current balance first
|
65
|
+
|
66
|
+
To decipher what happened, the team looks at two time ranges in each record. The effective time range(`effective_from` and `effective_till`) tells them what period the data was recorded for, while the valid time range(`valid_from` and `valid_till`) tells them when the data was recorded. An infinite end date(1-1-3000) tells them that the record is currently effective or currently valid depending on which time range it shows up on.
|
67
|
+
|
68
|
+
```
|
69
|
+
> timeline.at(Time.now)
|
70
|
+
{
|
71
|
+
"id"=>6,
|
72
|
+
"cash_account_id"=>1,
|
73
|
+
"amount"=>900,
|
74
|
+
"reference_id"=>nil,
|
75
|
+
"effective_from"=>"2021-09-04T18:30:00.000Z",
|
76
|
+
"effective_till"=>"3000-01-01T00:00:00.000Z",
|
77
|
+
"valid_from"=>"2021-09-07T18:30:00.000Z",
|
78
|
+
"valid_till"=>"3000-01-01T00:00:00.000Z"
|
79
|
+
}
|
80
|
+
```
|
81
|
+
The above record tells them that the customer's balance changed to $900 on the 4th of September and is currently effective, and that the amount was recorded on the 7th and is currently valid.
|
82
|
+
|
83
|
+
They check if the customer expects the balance to be $900 and he confirms this
|
84
|
+
|
85
|
+
Great, atleast the current balance in order, so they start digging in deeper to check what the balance was 2 days ago
|
86
|
+
|
87
|
+
```
|
88
|
+
> timeline.at(Time.now - 2.days, as_of: Time.now - 2.days)
|
89
|
+
{
|
90
|
+
"id"=>3,
|
91
|
+
"cash_account_id"=>1,
|
92
|
+
"amount"=>700,
|
93
|
+
"reference_id"=>nil,
|
94
|
+
"effective_from"=>"2021-09-04T18:30:00.000Z",
|
95
|
+
"effective_till"=>"3000-01-01T00:00:00.000Z",
|
96
|
+
"valid_from"=>"2021-09-06T18:30:00.000Z",
|
97
|
+
"valid_till"=>"2021-09-07T18:30:00.000Z"
|
98
|
+
}
|
99
|
+
```
|
100
|
+
They realize from this record is that the balance was indeed different two days ago, and it reflected $700. Additionally the valid time range tells them that there was a correction made to the balance a day later since the valid time range ends on the 7th.
|
101
|
+
|
102
|
+
They inform the customer that two days ago, he might have seen a balance of $700, and he confirms this.
|
103
|
+
|
104
|
+
They then check further and compare balance data recorded two days ago to find out what happened
|
105
|
+
|
106
|
+
```
|
107
|
+
> timeline.as_of(Time.now - 2.days)
|
108
|
+
[
|
109
|
+
{
|
110
|
+
"id"=>2,
|
111
|
+
"cash_account_id"=>1,
|
112
|
+
"amount"=>500,
|
113
|
+
"reference_id"=>nil,
|
114
|
+
"effective_from"=>"2021-09-01T18:30:00.000Z",
|
115
|
+
"effective_till"=>"2021-09-04T18:30:00.000Z",
|
116
|
+
"valid_from"=>"2021-09-06T18:30:00.000Z",
|
117
|
+
"valid_till"=>"2021-09-07T18:30:00.000Z"
|
118
|
+
},
|
119
|
+
{
|
120
|
+
"id"=>3,
|
121
|
+
"cash_account_id"=>1,
|
122
|
+
"amount"=>700,
|
123
|
+
"reference_id"=>nil,
|
124
|
+
"effective_from"=>"2021-09-04T18:30:00.000Z",
|
125
|
+
"effective_till"=>"3000-01-01T00:00:00.000Z",
|
126
|
+
"valid_from"=>"2021-09-06T18:30:00.000Z",
|
127
|
+
"valid_till"=>"2021-09-07T18:30:00.000Z"
|
128
|
+
}
|
129
|
+
]
|
130
|
+
|
131
|
+
> timeline.as_of(Time.now)
|
132
|
+
[
|
133
|
+
{
|
134
|
+
"id"=>5,
|
135
|
+
"cash_account_id"=>1,
|
136
|
+
"amount"=>600,
|
137
|
+
"reference_id"=>nil,
|
138
|
+
"effective_from"=>"2021-09-01T18:30:00.000Z",
|
139
|
+
"effective_till"=>"2021-09-04T18:30:00.000Z",
|
140
|
+
"valid_from"=>"2021-09-07T18:30:00.000Z",
|
141
|
+
"valid_till"=>"3000-01-01T00:00:00.000Z"
|
142
|
+
},
|
143
|
+
{
|
144
|
+
"id"=>6,
|
145
|
+
"cash_account_id"=>1,
|
146
|
+
"amount"=>900,
|
147
|
+
"reference_id"=>nil,
|
148
|
+
"effective_from"=>"2021-09-04T18:30:00.000Z",
|
149
|
+
"effective_till"=>"3000-01-01T00:00:00.000Z",
|
150
|
+
"valid_from"=>"2021-09-07T18:30:00.000Z",
|
151
|
+
"valid_till"=>"3000-01-01T00:00:00.000Z"
|
152
|
+
}
|
153
|
+
]
|
154
|
+
```
|
155
|
+
|
156
|
+
From the time ranges, they understand that the balance dates were correct and not altered, but the amounts were corrected a day after the amounts were initially recorded.
|
157
|
+
|
158
|
+
They inform the customer about exactly what happened with his accounts in the last two days.
|
159
|
+
|
160
|
+
The customer, wanting to ensure that everything is right, asks them when the additional $300 was recorded. and they inform the customer that the date of the second deposit is 4 days go, but the correct amount was updated yesterday.
|
161
|
+
|
162
|
+
The customer feels satisfied that all the changes were tracked accurately, thanks us and leaves
|
163
|
+
|
164
|
+
## Installation
|
165
|
+
|
166
|
+
To install the gem, reference this git repository in your `Gemfile`
|
167
|
+
|
168
|
+
git "https://<your-personal-access-token>:x-oauth-basic@github.com/planarinv/elder-wand.git" do
|
169
|
+
gem 'time_travel'
|
170
|
+
end
|
171
|
+
|
172
|
+
Then run:
|
173
|
+
|
174
|
+
bundle install
|
175
|
+
|
176
|
+
## Usage
|
177
|
+
|
178
|
+
### Creating a new model that tracks history
|
179
|
+
|
180
|
+
To create a new model which will track history, use the `time_travel` generator to create a scaffold
|
181
|
+
|
182
|
+
bundle exec rake generate time_travel <NewModel> <fields>
|
183
|
+
|
184
|
+
Then, include `TimeTravel::TimelineHelper` in your ActiveRecord Model, and define which fields identify a unique timeline in your model for which changes and corrections need to be tracked
|
185
|
+
|
186
|
+
In the example below. a `CashBalance` model has a `:cash_account_id` field which uniquely identifies the account for which the cash balance needs to be tracked.
|
187
|
+
|
188
|
+
class CashBalance < ActiveRecord::Base
|
189
|
+
include TimeTravel::TimelineHelper
|
190
|
+
|
191
|
+
def self.timeline_fields
|
192
|
+
:cash_account_id
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
### Adding history tracking to an existing model
|
197
|
+
|
198
|
+
#### _Adding fields for history tracking_
|
199
|
+
|
200
|
+
To add history tracking to an existing model, you can use the `time_travel` generator, specifying the name of a model that already exists.
|
201
|
+
|
202
|
+
bundle exec rake generate time_travel <ExistingModel>
|
203
|
+
|
204
|
+
#### _Migrating existing data_
|
205
|
+
|
206
|
+
To migrate existing data, you'll need to populate the effective and valid time ranges in each record in your model with a custom script of your own. An easy way to do this for a table that has a single date field is to order the records by the field and chain the dates from subsequent records to create the effective time range. The valid time range can be set to the current date onwards if you don't care about history prior to the migration.
|
207
|
+
|
208
|
+
### Manipulating data in a Time Travel model
|
209
|
+
|
210
|
+
To apply changes to a timeline, first create a timeline object
|
211
|
+
|
212
|
+
timeline=balance.timeline(cash_account_id: 1)
|
213
|
+
|
214
|
+
Then use the `create`, `update` or `terminate` methods to modify the timeline.
|
215
|
+
|
216
|
+
In case you're not sure if you need to create a new timeline or update it, you can always call `create_or_update` to do the dirty work for you.
|
217
|
+
|
218
|
+
You can pass in `effective_from` and `effective_till` dates to indicate the period during which you want to create or update records. This is especially useful if you want to correct an older record.
|
219
|
+
|
220
|
+
The `valid_from` and `valid_till` fields are managed by the gem, based on when records are added or corrected and cannot be modified explicitly on the timeline.
|
221
|
+
|
222
|
+
Here are some examples of operations:
|
223
|
+
|
224
|
+
# create account with balance of Rs. 500, effective from now onwards
|
225
|
+
timeline.create({amount: 500})
|
226
|
+
# create account with balance Rs. 500, effective from 1st of August
|
227
|
+
timeline.create({amount: 500}, effective_from: Date.parse("01/09/2018").beginning_of_day))
|
228
|
+
# update account with balance Rs. 1000, effective from now onwards
|
229
|
+
timeline.update({amount: 1000})
|
230
|
+
# update account with balance Rs. 1500, effective from 20th of August
|
231
|
+
timeline.update({amount: 1500}, effective_from: Date.parse("20/09/2018").beginning_of_day)
|
232
|
+
# correct account balance to Rs. 2000 between 5th and 22nd of August
|
233
|
+
timeline.update({amount: 2000},
|
234
|
+
effective_from: Date.parse("05/09/2018").begining_of_day,
|
235
|
+
effective_till: Date.parse("22/09/2018").begining_of_day)
|
236
|
+
# close account now
|
237
|
+
timeline.terminate()
|
238
|
+
# close account, effective from 30th August timeline.terminate(effective_from: Date.parse("30/09/2018"))
|
239
|
+
|
240
|
+
Updates can be applied in bulk by supplying attributes in an array and using the `bulk_update` method
|
241
|
+
|
242
|
+
### Accessing the timelines
|
243
|
+
|
244
|
+
To access the records in the timelines, use the `at` and `as_of` methods.
|
245
|
+
|
246
|
+
The `at` method returns a single record at a point in the timelines.
|
247
|
+
|
248
|
+
```
|
249
|
+
# retrieve a currently valid record, effective 2 days ago
|
250
|
+
timeline.at(Time.now - 2 days)
|
251
|
+
# retrieve record which was valid and effective 2 days ago
|
252
|
+
timeline.at(Time.now - 2.days, as_of: Time.now - 2 days)
|
253
|
+
```
|
254
|
+
|
255
|
+
The `as_of` method returns the entire history of records which were valid on a given date
|
256
|
+
|
257
|
+
```
|
258
|
+
# the currently valid set of records
|
259
|
+
timeline.as_of(Time.now)
|
260
|
+
# the set of records valid two days ago
|
261
|
+
timeline.as_of(Time.now - 2.days)
|
262
|
+
```
|
263
|
+
|
264
|
+
### Updating data directly
|
265
|
+
|
266
|
+
Data on any record can be directly updated by using ActiveRecord methods on your model.
|
267
|
+
|
268
|
+
You might want to do this for fields which you don't want to track on the timelines.
|
269
|
+
|
270
|
+
Avoid updating the time ranges directly though, unless you really know what you're doing.
|
271
|
+
|
272
|
+
We allow updates to the time ranges since you might need to migrate an existing model to add `time_travel` functionality.
|
273
|
+
|
274
|
+
### SQL and Native modes
|
275
|
+
|
276
|
+
By default time_travel applies updates using native ruby logic.
|
277
|
+
|
278
|
+
For performance, the sql mode is also available with support for Postgres.
|
279
|
+
|
280
|
+
To switch modes, create an initializer as follows
|
281
|
+
|
282
|
+
TimeTravel.configure do
|
283
|
+
update_mode="sql"
|
284
|
+
end
|
285
|
+
|
286
|
+
and install the postgres plsql function with
|
287
|
+
|
288
|
+
rake time_travel:create_postgres_function
|
289
|
+
|
290
|
+
## Behind the Scenes
|
291
|
+
|
292
|
+
The time travel gem uses bi-temporal modelling to track changes and corrections. There's a lot of material online that covers it in-case you want to dig deeper into what makes the gem work.
|
293
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'TimeTravel'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
require 'bundler/gem_tasks'
|
18
|
+
|
19
|
+
require 'rake/testtask'
|
20
|
+
|
21
|
+
Rake::TestTask.new(:test) do |t|
|
22
|
+
t.libs << 'test'
|
23
|
+
t.pattern = 'test/**/*_test.rb'
|
24
|
+
t.verbose = false
|
25
|
+
end
|
26
|
+
|
27
|
+
task default: :test
|
28
|
+
|
29
|
+
import "./lib/tasks/create_postgres_function.rake"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def change
|
3
|
+
change_table :<%= table_name %> do |t|
|
4
|
+
t.column :effective_from, :datetime
|
5
|
+
t.column :effective_till, :datetime
|
6
|
+
t.column :valid_from, :datetime
|
7
|
+
t.column :valid_till, :datetime
|
8
|
+
|
9
|
+
add_index :<%= table_name %>, :effective_from
|
10
|
+
add_index :<%= table_name %>, :effective_till
|
11
|
+
add_index :<%= table_name %>, :valid_from
|
12
|
+
add_index :<%= table_name %>, :valid_till
|
13
|
+
|
14
|
+
# TODO: Migrate existing data and add a not null constraint for effective_from and valid_till
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def change
|
3
|
+
create_table :<%= table_name %> do |t|
|
4
|
+
<% attributes.each do |attribute| -%>
|
5
|
+
t.<%= attribute.type %> :<%= attribute.name %>
|
6
|
+
<% end -%>
|
7
|
+
t.column :effective_from, :datetime, null: false
|
8
|
+
t.column :effective_till, :datetime
|
9
|
+
t.column :valid_from, :datetime, null: false
|
10
|
+
t.column :valid_till, :datetime
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
add_index :<%= table_name %>, :effective_from
|
15
|
+
add_index :<%= table_name %>, :effective_till
|
16
|
+
add_index :<%= table_name %>, :valid_from
|
17
|
+
add_index :<%= table_name %>, :valid_till
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require "rails/generators/active_record"
|
2
|
+
|
3
|
+
class TimeTravelGenerator < ActiveRecord::Generators::Base
|
4
|
+
desc "Create a migration to add history tracking fields to your model. "+
|
5
|
+
"The only argument this generator takes is the model on which the history tracking needs to be applied"
|
6
|
+
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
7
|
+
|
8
|
+
def self.source_root
|
9
|
+
@source_root ||= File.expand_path('../templates', __FILE__)
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate_migration
|
13
|
+
if (behavior == :invoke && model_exists?)
|
14
|
+
migration_template("time_travel_migration_existing.rb.erb",
|
15
|
+
"db/migrate/#{migration_file_name}",
|
16
|
+
migration_version: migration_version)
|
17
|
+
else
|
18
|
+
migration_template("time_travel_migration_new.rb.erb",
|
19
|
+
"db/migrate/#{migration_file_name}",
|
20
|
+
migration_version: migration_version)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def model_exists?
|
25
|
+
File.exist?(File.join(destination_root, model_path))
|
26
|
+
end
|
27
|
+
|
28
|
+
def model_path
|
29
|
+
@model_path ||= File.join("app", "models", "#{file_path}.rb")
|
30
|
+
end
|
31
|
+
|
32
|
+
def migration_name
|
33
|
+
"add_time_travel_to_#{name.underscore.pluralize}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def migration_file_name
|
37
|
+
"#{migration_name}.rb"
|
38
|
+
end
|
39
|
+
|
40
|
+
def migration_class_name
|
41
|
+
migration_name.camelize
|
42
|
+
end
|
43
|
+
|
44
|
+
def migration_version
|
45
|
+
if Rails.version.start_with? "5"
|
46
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|