chrono_model 0.9.2 → 0.10.0
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.
- checksums.yaml +4 -4
- data/.travis.yml +18 -5
- data/README.md +83 -63
- data/chrono_model.gemspec +3 -2
- data/gemfiles/rails_4.2.gemfile +5 -0
- data/gemfiles/rails_5.0.gemfile +5 -0
- data/lib/active_record/connection_adapters/chronomodel_adapter.rb +6 -1
- data/lib/chrono_model/adapter.rb +47 -12
- data/lib/chrono_model/patches.rb +46 -0
- data/lib/chrono_model/time_machine.rb +12 -3
- data/lib/chrono_model/utils.rb +5 -4
- data/lib/chrono_model/version.rb +1 -1
- data/lib/chrono_model.rb +8 -1
- data/spec/adapter_spec.rb +10 -0
- data/spec/json_ops_spec.rb +5 -5
- data/spec/spec_helper.rb +5 -2
- data/spec/support/matchers/table.rb +3 -3
- data/spec/time_machine_spec.rb +24 -2
- data/spec/utils_spec.rb +43 -0
- metadata +29 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 67617b7e3802061ce8bff217ddbed05e33dea83e
|
4
|
+
data.tar.gz: 0e0172b002fe5f355461acb4c643ce8867394058
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f1b8ad21da3c8a9856c7865f1586a45760552c4d8b60e2999ff98ba5ea91d62f75ecf573ba0fdc3df5c7c048b7f9245b2a0427d2d2d5c1c005a12d84315a45c3
|
7
|
+
data.tar.gz: 1b94a4c721bc99472464b6160fd99c3e4e7ca66fddb893bfc538d83863dbd1ecdd30624f49ce020643dddce44f6d79d7ecbe1d0d10f415f18eb91a47a1710721
|
data/.travis.yml
CHANGED
@@ -1,8 +1,16 @@
|
|
1
1
|
rvm:
|
2
|
-
- 2.
|
3
|
-
- 2.
|
4
|
-
- 2.
|
5
|
-
|
2
|
+
- 2.2.7
|
3
|
+
- 2.3
|
4
|
+
- 2.4
|
5
|
+
|
6
|
+
gemfile:
|
7
|
+
- gemfiles/rails_4.2.gemfile
|
8
|
+
- gemfiles/rails_5.0.gemfile
|
9
|
+
|
10
|
+
matrix:
|
11
|
+
exclude:
|
12
|
+
- rvm: 2.4
|
13
|
+
gemfile: gemfiles/rails_4.2.gemfile
|
6
14
|
|
7
15
|
sudo: false
|
8
16
|
|
@@ -13,9 +21,14 @@ addons:
|
|
13
21
|
postgresql: "9.4"
|
14
22
|
apt:
|
15
23
|
packages: postgresql-plpython-9.4
|
24
|
+
code_climate:
|
25
|
+
repo_token: dedfb7472ee410eec459bff3681d9a8fd8dd237e9bd7e8675a7c8eb7e253bba9
|
16
26
|
|
17
27
|
before_script:
|
18
28
|
- psql -c "CREATE DATABASE chronomodel;" -U postgres
|
19
29
|
|
20
30
|
script:
|
21
|
-
- bundle exec rake TEST_CONFIG=./spec/config.travis.yml
|
31
|
+
- bundle exec rake TEST_CONFIG=./spec/config.travis.yml
|
32
|
+
|
33
|
+
after_success:
|
34
|
+
- bundle exec codeclimate-test-reporter
|
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# Temporal database system on PostgreSQL using [updatable views][], [table inheritance][] and [INSTEAD OF triggers][].
|
1
|
+
# Temporal database system on PostgreSQL using [updatable views][pg-updatable-views], [table inheritance][pg-table-inheritance] and [INSTEAD OF triggers][pg-instead-of-triggers].
|
2
2
|
|
3
3
|
[![Build Status][build-status-badge]][build-status]
|
4
4
|
[![Dependency Status][deps-status-badge]][deps-status]
|
@@ -25,21 +25,23 @@ beneath.
|
|
25
25
|
|
26
26
|
The application model is backed by an updatable view in the default `public`
|
27
27
|
schema that behaves like a plain table to any database client. When data in
|
28
|
-
manipulated on it, INSTEAD OF [triggers][] redirect the manipulations
|
29
|
-
concrete tables.
|
28
|
+
manipulated on it, INSTEAD OF [triggers][pg-triggers] redirect the manipulations
|
29
|
+
to concrete tables.
|
30
30
|
|
31
|
-
Current data is
|
32
|
-
|
33
|
-
the
|
31
|
+
*Current* data is held in a table in the `temporal` [schema][pg-schema], while
|
32
|
+
*History* is held in a table in the `history` schema that [inherits][pg-table-inheritance]
|
33
|
+
from the *Current* one, to get automated schema updates for free and other
|
34
|
+
benefits.
|
34
35
|
|
35
|
-
The current time is taken using [`current_timestamp`][], so
|
36
|
-
manipulations in the same transaction on the same records
|
37
|
-
single history entry (they are _squashed_ together).
|
36
|
+
The current time is taken using [`current_timestamp`][pg-current-timestamp], so
|
37
|
+
that multiple data manipulations in the same transaction on the same records
|
38
|
+
always create a single history entry (they are _squashed_ together).
|
38
39
|
|
39
|
-
[Partitioning][] of history is also possible: this design [fits the
|
40
|
-
requirements][partitioning-excl-constraints] but it's not implemented yet.
|
40
|
+
[Partitioning][pg-partitioning] of history is also possible: this design [fits the
|
41
|
+
requirements][pg-partitioning-excl-constraints] but it's not implemented yet.
|
41
42
|
|
42
|
-
See [README.sql][] for a SQL example defining the machinery for a
|
43
|
+
See [README.sql][cm-readme-sql] for a SQL example defining the machinery for a
|
44
|
+
simple table.
|
43
45
|
|
44
46
|
|
45
47
|
## Active Record integration
|
@@ -52,9 +54,10 @@ Data extraction at a single point in time and even `JOIN`s between temporal and
|
|
52
54
|
non-temporal data is implemented using sub-selects and a `WHERE` generated by
|
53
55
|
the provided `TimeMachine` module to be included in your models.
|
54
56
|
|
55
|
-
The `WHERE` is optimized using [GiST indexes][] on the
|
56
|
-
record validity. Overlapping history is prevented
|
57
|
-
constraints][] and the
|
57
|
+
The `WHERE` is optimized using [GiST indexes][pg-gist-indexes] on the
|
58
|
+
`tsrange` defining record validity. Overlapping history is prevented
|
59
|
+
through [exclusion constraints][pg-exclusion-constraints] and the
|
60
|
+
[btree_gist][pg-btree-gist] extension.
|
58
61
|
|
59
62
|
All timestamps are _forcibly_ stored in as UTC, bypassing the
|
60
63
|
`default_timezone` setting.
|
@@ -62,10 +65,11 @@ All timestamps are _forcibly_ stored in as UTC, bypassing the
|
|
62
65
|
|
63
66
|
## Requirements
|
64
67
|
|
65
|
-
* Ruby >= 2.
|
66
|
-
* Active Record
|
68
|
+
* Ruby >= 2.2 (1.9 to 2.1 could still work, but aren't supported).
|
69
|
+
* Active Record 4.2 or 5.0 (4.2 doesn't work with Ruby 2.4, though)
|
67
70
|
* PostgreSQL >= 9.3
|
68
|
-
* The `btree_gist`
|
71
|
+
* The `btree_gist` PostgreSQL extension
|
72
|
+
* The `plpython` PostgreSQL extension if you have *JSON* (*not* JSONB) columns
|
69
73
|
|
70
74
|
With Homebrew:
|
71
75
|
|
@@ -79,7 +83,7 @@ With Apt:
|
|
79
83
|
|
80
84
|
Add this line to your application's Gemfile:
|
81
85
|
|
82
|
-
gem 'chrono_model'
|
86
|
+
gem 'chrono_model'
|
83
87
|
|
84
88
|
And then execute:
|
85
89
|
|
@@ -147,6 +151,7 @@ change_table :your_table, :temporal => true, :copy_data => true, :validity => '1
|
|
147
151
|
Please note that `change_table` requires you to use *old_style* `up` and
|
148
152
|
`down` migrations. It cannot work with Rails 3-style `change` migrations.
|
149
153
|
|
154
|
+
|
150
155
|
## Selective Journaling
|
151
156
|
|
152
157
|
By default UPDATEs only to the `updated_at` field are not recorded in the
|
@@ -159,8 +164,8 @@ options to `create_table`:
|
|
159
164
|
* `:no_journal => %w( fld1 fld2 .. )` - do not record changes to the specified fields
|
160
165
|
* `:full_journal => true` - record changes to *all* fields, including `updated_at`.
|
161
166
|
|
162
|
-
These options are stored as JSON in the [COMMENT][] area of the
|
163
|
-
alongside with the ChronoModel version that created them.
|
167
|
+
These options are stored as JSON in the [COMMENT][pg-comment] area of the
|
168
|
+
public view, alongside with the ChronoModel version that created them.
|
164
169
|
|
165
170
|
This is visible in `psql` if you issue a `\d+`. Example after a test run:
|
166
171
|
|
@@ -246,12 +251,15 @@ SELECT "countries".* FROM (
|
|
246
251
|
) AS "compositions" ON compositions.country_id = countries.id
|
247
252
|
```
|
248
253
|
|
249
|
-
More methods are provided, see the [TimeMachine][] source for
|
254
|
+
More methods are provided, see the [TimeMachine][cm-timemachine] source for
|
255
|
+
more information.
|
256
|
+
|
250
257
|
|
251
258
|
## History manipulation
|
252
259
|
|
253
260
|
History objects can be changed and `.save`d just like any other record.
|
254
261
|
|
262
|
+
|
255
263
|
## Running tests
|
256
264
|
|
257
265
|
You need a running PostgreSQL >= 9.3 instance. Create `spec/config.yml` with the
|
@@ -263,20 +271,21 @@ You need to connect as a database superuser, because specs need to create the
|
|
263
271
|
Run `rake`. SQL queries are logged to `spec/debug.log`. If you want to see them
|
264
272
|
in your output, use `rake VERBOSE=true`.
|
265
273
|
|
266
|
-
## Usage with JSON columns
|
267
274
|
|
268
|
-
|
275
|
+
## Usage with JSON (*not* JSONB) columns
|
276
|
+
|
277
|
+
[JSON][pg-json-type] does not provide an [equality operator][pg-json-func].
|
269
278
|
As both unnecessary update suppression and selective journaling require
|
270
279
|
comparing the OLD and NEW rows fields, this fails by default.
|
271
280
|
|
272
281
|
ChronoModel provides a naive JSON equality operator using a naive
|
273
|
-
comparison of JSON objects [implemented in pl/python][json-opclass].
|
282
|
+
comparison of JSON objects [implemented in pl/python][pg-json-opclass].
|
274
283
|
|
275
284
|
To load the opclass you can use the `ChronoModel::Json.create`
|
276
285
|
convenience method. If you don't use JSON don't bother doing this.
|
277
286
|
|
278
|
-
If you are on Postgres 9.4, you are strongly encouraged to use JSONB
|
279
|
-
|
287
|
+
If you are on Postgres 9.4, you are **strongly encouraged to use JSONB**,
|
288
|
+
as it has an equality operator built-in, it's faster and stricter, and
|
280
289
|
offers many more indexing abilities and better performance than JSON.
|
281
290
|
|
282
291
|
## Caveats
|
@@ -293,15 +302,13 @@ offers many more indexing abilities and better performance than JSON.
|
|
293
302
|
`db:structure:load`.
|
294
303
|
Two helper tasks are also added, `db:data:dump` and `db:data:load`.
|
295
304
|
|
296
|
-
*
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
past][chronomodel-cte-impl], because the resulting queries were more
|
304
|
-
readable, and do not inhibit using `.from()` on the `AR::Relation`.
|
305
|
+
* The choice of using subqueries instead of [Common Table Expressions]
|
306
|
+
[pg-ctes] was dictated by the fact that CTEs [currently act as an
|
307
|
+
optimization fence][pg-cte-optimization-fence].
|
308
|
+
If it will be possible [to opt-out of the fence][pg-cte-opt-out-fence]
|
309
|
+
in the future, they will be probably be used again as they were [in the
|
310
|
+
past][cm-cte-impl], because the resulting queries were more readable,
|
311
|
+
and do not inhibit using `.from()` on the `AR::Relation`.
|
305
312
|
|
306
313
|
|
307
314
|
## Contributing
|
@@ -313,6 +320,19 @@ offers many more indexing abilities and better performance than JSON.
|
|
313
320
|
5. Create new Pull Request
|
314
321
|
|
315
322
|
|
323
|
+
## Special mention
|
324
|
+
|
325
|
+
An special mention has to be made to [Paolo Zaccagnini][gh-pzac]
|
326
|
+
for all his effort in highlighting the improvements and best decisions
|
327
|
+
taken over the life cycle of the design and implementation of Chronomodel
|
328
|
+
while using it in many important projects.
|
329
|
+
|
330
|
+
|
331
|
+
## Denominazione d'Origine Controllata
|
332
|
+
|
333
|
+
This software is Made in Italy :it: :smile:.
|
334
|
+
|
335
|
+
|
316
336
|
[build-status]: https://travis-ci.org/ifad/chronomodel
|
317
337
|
[build-status-badge]: https://travis-ci.org/ifad/chronomodel.svg
|
318
338
|
[deps-status]: https://gemnasium.com/ifad/chronomodel
|
@@ -327,34 +347,34 @@ offers many more indexing abilities and better performance than JSON.
|
|
327
347
|
[chronos-image]: http://i.imgur.com/8NObYiZl.jpg
|
328
348
|
[rebelle-society]: http://www.rebellesociety.com/2012/10/11/the-writers-way-week-two-facing-procrastination/chronos_oeuvre_grand1/
|
329
349
|
|
330
|
-
[updatable views]: http://www.postgresql.org/docs/9.4/static/sql-createview.html#SQL-CREATEVIEW-UPDATABLE-VIEWS
|
331
|
-
[table inheritance]: http://www.postgresql.org/docs/9.4/static/ddl-inherit.html
|
332
|
-
[INSTEAD OF triggers]: http://www.postgresql.org/docs/9.4/static/sql-createtrigger.html
|
333
350
|
[wp-scd-2]: http://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2
|
334
351
|
[wp-scd-4]: http://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_4
|
335
|
-
[triggers]: http://www.postgresql.org/docs/9.4/static/trigger-definition.html
|
336
|
-
[schema]: http://www.postgresql.org/docs/9.4/static/ddl-schemas.html
|
337
|
-
[inherits]: http://www.postgresql.org/docs/9.4/static/ddl-inherit.html
|
338
|
-
[`current_timestamp`]: http://www.postgresql.org/docs/9.4/interactive/functions-datetime.html#FUNCTIONS-DATETIME-TABLE
|
339
|
-
|
340
|
-
[Partitioning]: http://www.postgresql.org/docs/9.4/static/ddl-partitioning.html)
|
341
|
-
[partitioning-excl-constraints]: http://www.postgresql.org/docs/9.4/static/ddl-partitioning.html#DDL-PARTITIONING-CONSTRAINT-EXCLUSION
|
342
|
-
[README.sql]: https://github.com/ifad/chronomodel/blob/master/README.sql
|
343
|
-
[GiST indexes]: http://www.postgresql.org/docs/9.4/static/gist.html
|
344
|
-
[exclusion constraints]: http://www.postgresql.org/docs/9.4/static/sql-createtable.html#SQL-CREATETABLE-EXCLUDE
|
345
|
-
[btree_gist]: http://www.postgresql.org/docs/9.4/static/btree-gist.html
|
346
|
-
[COMMENT]: http://www.postgresql.org/docs/9.4/static/sql-comment.html
|
347
|
-
[TimeMachine]: https://github.com/ifad/chronomodel/blob/master/lib/chrono_model/time_machine.rb
|
348
352
|
|
349
|
-
[
|
350
|
-
[
|
353
|
+
[pg-updatable-views]: http://www.postgresql.org/docs/9.4/static/sql-createview.html#SQL-CREATEVIEW-UPDATABLE-VIEWS
|
354
|
+
[pg-table-inheritance]: http://www.postgresql.org/docs/9.4/static/ddl-inherit.html
|
355
|
+
[pg-instead-of-triggers]: http://www.postgresql.org/docs/9.4/static/sql-createtrigger.html
|
356
|
+
[pg-triggers]: http://www.postgresql.org/docs/9.4/static/trigger-definition.html
|
357
|
+
[pg-schema]: http://www.postgresql.org/docs/9.4/static/ddl-schemas.html
|
358
|
+
[pg-current-timestamp]: http://www.postgresql.org/docs/9.4/interactive/functions-datetime.html#FUNCTIONS-DATETIME-TABLE
|
359
|
+
[pg-partitioning]: http://www.postgresql.org/docs/9.4/static/ddl-partitioning.html
|
360
|
+
[pg-partitioning-excl-constraints]: http://www.postgresql.org/docs/9.4/static/ddl-partitioning.html#DDL-PARTITIONING-CONSTRAINT-EXCLUSION
|
361
|
+
[pg-gist-indexes]: http://www.postgresql.org/docs/9.4/static/gist.html
|
362
|
+
[pg-exclusion-constraints]: http://www.postgresql.org/docs/9.4/static/sql-createtable.html#SQL-CREATETABLE-EXCLUDE
|
363
|
+
[pg-btree-gist]: http://www.postgresql.org/docs/9.4/static/btree-gist.html
|
364
|
+
[pg-comment]: http://www.postgresql.org/docs/9.4/static/sql-comment.html
|
351
365
|
[pg-tsrange-and-ruby]: https://bugs.ruby-lang.org/issues/6864
|
352
|
-
[
|
353
|
-
[
|
354
|
-
[cte-
|
355
|
-
[
|
356
|
-
[
|
357
|
-
|
358
|
-
|
359
|
-
[
|
360
|
-
[
|
366
|
+
[pg-ctes]: http://www.postgresql.org/docs/9.4/static/queries-with.html
|
367
|
+
[pg-cte-optimization-fence]: http://archives.postgresql.org/pgsql-hackers/2012-09/msg00700.php
|
368
|
+
[pg-cte-opt-out-fence]: http://archives.postgresql.org/pgsql-hackers/2012-10/msg00024.php
|
369
|
+
[pg-json-type]: http://www.postgresql.org/docs/9.4/static/datatype-json.html
|
370
|
+
[pg-json-func]: http://www.postgresql.org/docs/9.4/static/functions-json.html
|
371
|
+
[pg-json-opclass]: https://github.com/ifad/chronomodel/blob/master/sql/json_ops.sql
|
372
|
+
|
373
|
+
[r4-tsrange-broken]: https://github.com/rails/rails/pull/13793#issuecomment-34608093
|
374
|
+
[r4-tsrange-incomplete]: https://github.com/rails/rails/issues/14010
|
375
|
+
|
376
|
+
[cm-readme-sql]: https://github.com/ifad/chronomodel/blob/master/README.sql
|
377
|
+
[cm-timemachine]: https://github.com/ifad/chronomodel/blob/master/lib/chrono_model/time_machine.rb
|
378
|
+
[cm-cte-impl]: https://github.com/ifad/chronomodel/commit/18f4c4b
|
379
|
+
|
380
|
+
[gh-pzac]: https://github.com/pzac
|
data/chrono_model.gemspec
CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |gem|
|
|
15
15
|
gem.require_paths = ["lib"]
|
16
16
|
gem.version = ChronoModel::VERSION
|
17
17
|
|
18
|
-
gem.add_dependency
|
18
|
+
gem.add_dependency 'activerecord', '>= 4.2.0', '< 5.1.0'
|
19
19
|
gem.add_dependency "pg"
|
20
20
|
gem.add_dependency "multi_json"
|
21
21
|
|
@@ -25,5 +25,6 @@ Gem::Specification.new do |gem|
|
|
25
25
|
gem.add_development_dependency 'rspec'
|
26
26
|
gem.add_development_dependency 'rake'
|
27
27
|
gem.add_development_dependency 'fuubar'
|
28
|
+
gem.add_development_dependency 'simplecov'
|
28
29
|
gem.add_development_dependency 'codeclimate-test-reporter'
|
29
|
-
end
|
30
|
+
end
|
@@ -17,7 +17,12 @@ module ActiveRecord
|
|
17
17
|
conn_params[:dbname] = conn_params.delete(:database) if conn_params[:database]
|
18
18
|
|
19
19
|
# Forward only valid config params to PGconn.connect.
|
20
|
-
|
20
|
+
valid_conn_param_keys = if ActiveRecord::VERSION::MAJOR == 4
|
21
|
+
VALID_CONN_PARAMS
|
22
|
+
else
|
23
|
+
PGconn.conndefaults_hash.keys + [:requiressl]
|
24
|
+
end
|
25
|
+
conn_params.slice!(*valid_conn_param_keys)
|
21
26
|
|
22
27
|
# The postgres drivers don't allow the creation of an unconnected PGconn object,
|
23
28
|
# so just pass a nil connection object for the time being.
|
data/lib/chrono_model/adapter.rb
CHANGED
@@ -71,9 +71,9 @@ module ChronoModel
|
|
71
71
|
end
|
72
72
|
end
|
73
73
|
|
74
|
-
|
74
|
+
|
75
|
+
# Rename indexes on history schema
|
75
76
|
#
|
76
|
-
pkey = primary_key(new_name)
|
77
77
|
_on_history_schema do
|
78
78
|
standard_index_names = %w(
|
79
79
|
inherit_pkey instance_history pkey
|
@@ -90,15 +90,31 @@ module ChronoModel
|
|
90
90
|
end
|
91
91
|
end
|
92
92
|
|
93
|
-
# Rename
|
93
|
+
# Rename indexes on temporal schema
|
94
|
+
#
|
95
|
+
_on_temporal_schema do
|
96
|
+
temporal_indexes = indexes(new_name)
|
97
|
+
temporal_indexes.map(&:name).each do |old_idx_name|
|
98
|
+
if old_idx_name =~ /^index_#{name}_on_(?<columns>.+)/
|
99
|
+
new_idx_name = "index_#{new_name}_on_#{$~['columns']}"
|
100
|
+
execute "ALTER INDEX #{old_idx_name} RENAME TO #{new_idx_name}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Drop view
|
106
|
+
#
|
107
|
+
execute "DROP VIEW #{name}"
|
108
|
+
|
109
|
+
# Drop functions
|
94
110
|
#
|
95
111
|
%w( insert update delete ).each do |func|
|
96
|
-
execute "
|
112
|
+
execute "DROP FUNCTION chronomodel_#{name}_#{func}()"
|
97
113
|
end
|
98
114
|
|
99
|
-
#
|
115
|
+
# Create view and functions
|
100
116
|
#
|
101
|
-
|
117
|
+
chrono_create_view_for(new_name)
|
102
118
|
|
103
119
|
TableCache.del! name
|
104
120
|
TableCache.add! new_name
|
@@ -447,8 +463,8 @@ module ChronoModel
|
|
447
463
|
#
|
448
464
|
def is_chrono?(table)
|
449
465
|
TableCache.fetch(table) do
|
450
|
-
_on_temporal_schema {
|
451
|
-
_on_history_schema {
|
466
|
+
_on_temporal_schema { chrono_data_source_exists?(table) } &&
|
467
|
+
_on_history_schema { chrono_data_source_exists?(table) }
|
452
468
|
end
|
453
469
|
|
454
470
|
rescue ActiveRecord::StatementInvalid => e
|
@@ -630,7 +646,7 @@ module ChronoModel
|
|
630
646
|
def chrono_metadata_for(table)
|
631
647
|
comment = select_value(
|
632
648
|
"SELECT obj_description(#{quote(table)}::regclass)",
|
633
|
-
"ChronoModel metadata for #{table}") if
|
649
|
+
"ChronoModel metadata for #{table}") if chrono_data_source_exists?(table)
|
634
650
|
|
635
651
|
MultiJson.load(comment || '{}').with_indifferent_access
|
636
652
|
end
|
@@ -693,7 +709,7 @@ module ChronoModel
|
|
693
709
|
|
694
710
|
# SELECT - return only current data
|
695
711
|
#
|
696
|
-
execute "DROP VIEW #{table}" if
|
712
|
+
execute "DROP VIEW #{table}" if chrono_data_source_exists? table
|
697
713
|
execute "CREATE VIEW #{table} AS SELECT * FROM ONLY #{current}"
|
698
714
|
|
699
715
|
# Set default values on the view (closes #12)
|
@@ -701,10 +717,19 @@ module ChronoModel
|
|
701
717
|
chrono_metadata_set(table, options.merge(:chronomodel => VERSION))
|
702
718
|
|
703
719
|
columns(table).each do |column|
|
704
|
-
default = column.default.nil?
|
720
|
+
default = if column.default.nil?
|
721
|
+
column.default_function
|
722
|
+
else
|
723
|
+
if ActiveRecord::VERSION::MAJOR == 4
|
724
|
+
quote(column.default, column)
|
725
|
+
else # Rails 5 and beyond
|
726
|
+
quote(column.default)
|
727
|
+
end
|
728
|
+
end
|
729
|
+
|
705
730
|
next if column.name == pk || default.nil?
|
706
731
|
|
707
|
-
execute "ALTER VIEW #{table} ALTER COLUMN #{column.name} SET DEFAULT #{default}"
|
732
|
+
execute "ALTER VIEW #{table} ALTER COLUMN #{quote_column_name(column.name)} SET DEFAULT #{default}"
|
708
733
|
end
|
709
734
|
|
710
735
|
columns = columns(table).map {|c| quote_column_name(c.name)}
|
@@ -889,6 +914,16 @@ module ChronoModel
|
|
889
914
|
end
|
890
915
|
end
|
891
916
|
|
917
|
+
def chrono_data_source_exists?(table_name)
|
918
|
+
if ActiveRecord::VERSION::MAJOR >= 5
|
919
|
+
data_source_exists?(table_name)
|
920
|
+
else
|
921
|
+
# On Rails 4, table_exists? has the same behaviour, checking if both
|
922
|
+
# a view or table exists
|
923
|
+
table_exists?(table_name)
|
924
|
+
end
|
925
|
+
end
|
926
|
+
|
892
927
|
def _on_temporal_schema(nesting = true, &block)
|
893
928
|
on_schema(TEMPORAL_SCHEMA, nesting, &block)
|
894
929
|
end
|
data/lib/chrono_model/patches.rb
CHANGED
@@ -55,6 +55,52 @@ module ChronoModel
|
|
55
55
|
|
56
56
|
end
|
57
57
|
end
|
58
|
+
|
59
|
+
def build_preloader
|
60
|
+
ActiveRecord::Associations::Preloader.new(as_of_time: as_of_time)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Patches ActiveRecord::Associations::Preloader to add support for
|
65
|
+
# temporal associations. This is tying itself to Rails internals
|
66
|
+
# and it is ugly :-(.
|
67
|
+
#
|
68
|
+
module Preloader
|
69
|
+
attr_reader :options
|
70
|
+
|
71
|
+
NULL_RELATION = ActiveRecord::Associations::Preloader::NULL_RELATION
|
72
|
+
AS_OF_PRELOAD_SCOPE = Struct.new(:as_of_time, *NULL_RELATION.members)
|
73
|
+
|
74
|
+
def initialize(options = {})
|
75
|
+
@options = options.freeze
|
76
|
+
end
|
77
|
+
|
78
|
+
def preload(records, associations, given_preload_scope = nil)
|
79
|
+
if (as_of_time = options[:as_of_time])
|
80
|
+
preload_scope = AS_OF_PRELOAD_SCOPE.new
|
81
|
+
|
82
|
+
preload_scope.as_of_time = as_of_time
|
83
|
+
given_preload_scope ||= NULL_RELATION
|
84
|
+
|
85
|
+
NULL_RELATION.members.each do |member|
|
86
|
+
preload_scope[member] = given_preload_scope[member]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
super records, associations, preload_scope
|
91
|
+
end
|
92
|
+
|
93
|
+
module Association
|
94
|
+
def build_scope
|
95
|
+
scope = super
|
96
|
+
|
97
|
+
if preload_scope.respond_to?(:as_of_time)
|
98
|
+
scope = scope.as_of(preload_scope.as_of_time)
|
99
|
+
end
|
100
|
+
|
101
|
+
return scope
|
102
|
+
end
|
103
|
+
end
|
58
104
|
end
|
59
105
|
|
60
106
|
# Patches ActiveRecord::Associations::Association to add support for
|
@@ -201,6 +201,16 @@ module ChronoModel
|
|
201
201
|
|
202
202
|
# Define the History constant inside the subclass
|
203
203
|
subclass.const_set :History, history
|
204
|
+
|
205
|
+
history.instance_eval do
|
206
|
+
# Monkey patch of ActiveRecord::Inheritance.
|
207
|
+
# STI fails when a Foo::History record has Foo as type in the
|
208
|
+
# inheritance column; AR expects the type to be an instance of the
|
209
|
+
# current class or a descendant (or self).
|
210
|
+
def find_sti_class(type_name)
|
211
|
+
super(type_name + "::History")
|
212
|
+
end
|
213
|
+
end
|
204
214
|
end
|
205
215
|
|
206
216
|
# Returns a read-only representation of this record as it was +time+ ago.
|
@@ -392,9 +402,8 @@ module ChronoModel
|
|
392
402
|
if t == :now || t == :today
|
393
403
|
now_for_column(column)
|
394
404
|
else
|
395
|
-
|
396
|
-
|
397
|
-
].join('::')
|
405
|
+
quoted_t = connection.quote(connection.quoted_date(t))
|
406
|
+
[quoted_t, primitive_type_for_column(column)].join('::')
|
398
407
|
end
|
399
408
|
end
|
400
409
|
|
data/lib/chrono_model/utils.rb
CHANGED
@@ -3,12 +3,12 @@ module ChronoModel
|
|
3
3
|
module Conversions
|
4
4
|
extend self
|
5
5
|
|
6
|
-
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(
|
6
|
+
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d+))?\z/
|
7
7
|
|
8
8
|
def string_to_utc_time(string)
|
9
9
|
if string =~ ISO_DATETIME
|
10
|
-
|
11
|
-
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i,
|
10
|
+
usec = $7.nil? ? '000000' : $7.ljust(6, '0') # .1 is .100000, not .000001
|
11
|
+
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, usec.to_i
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
@@ -51,7 +51,8 @@ module ChronoModel
|
|
51
51
|
|
52
52
|
connection.execute %[
|
53
53
|
UPDATE #{quoted_table_name}
|
54
|
-
SET "validity" = tsrange(#{connection.quote(from)}, #{connection.quote(to)})
|
54
|
+
SET "validity" = tsrange(#{connection.quote(from)}, #{connection.quote(to)}),
|
55
|
+
"recorded_at" = #{connection.quote(from)}
|
55
56
|
WHERE "hid" = #{hid.to_i}
|
56
57
|
]
|
57
58
|
end
|
data/lib/chrono_model/version.rb
CHANGED
data/lib/chrono_model.rb
CHANGED
@@ -14,7 +14,6 @@ if defined?(Rails)
|
|
14
14
|
require 'chrono_model/railtie'
|
15
15
|
end
|
16
16
|
|
17
|
-
|
18
17
|
ActiveRecord::Associations::Association.instance_eval do
|
19
18
|
prepend ChronoModel::Patches::Association
|
20
19
|
end
|
@@ -22,3 +21,11 @@ end
|
|
22
21
|
ActiveRecord::Relation.instance_eval do
|
23
22
|
prepend ChronoModel::Patches::Relation
|
24
23
|
end
|
24
|
+
|
25
|
+
ActiveRecord::Associations::Preloader.instance_eval do
|
26
|
+
prepend ChronoModel::Patches::Preloader
|
27
|
+
end
|
28
|
+
|
29
|
+
ActiveRecord::Associations::Preloader::Association.instance_eval do
|
30
|
+
prepend ChronoModel::Patches::Preloader::Association
|
31
|
+
end
|
data/spec/adapter_spec.rb
CHANGED
@@ -93,12 +93,22 @@ describe ChronoModel::Adapter do
|
|
93
93
|
context ':temporal => true' do
|
94
94
|
before :all do
|
95
95
|
adapter.create_table table, :temporal => true, &columns
|
96
|
+
adapter.add_index table, :test
|
97
|
+
adapter.add_index table, [:foo, :bar]
|
96
98
|
|
97
99
|
adapter.rename_table table, renamed
|
98
100
|
end
|
99
101
|
after(:all) { adapter.drop_table(renamed) }
|
100
102
|
|
101
103
|
it_should_behave_like 'temporal table'
|
104
|
+
|
105
|
+
it 'renames indexes' do
|
106
|
+
new_index_names = adapter.indexes(renamed).map(&:name)
|
107
|
+
expected_index_names = [[:test], [:foo, :bar]].map do |idx_cols|
|
108
|
+
"index_#{renamed}_on_#{idx_cols.join('_and_')}"
|
109
|
+
end
|
110
|
+
expect(new_index_names.to_set).to eq expected_index_names.to_set
|
111
|
+
end
|
102
112
|
end
|
103
113
|
|
104
114
|
context ':temporal => false' do
|
data/spec/json_ops_spec.rb
CHANGED
@@ -14,11 +14,11 @@ describe 'JSON equality operator' do
|
|
14
14
|
ChronoModel::Json.drop
|
15
15
|
end
|
16
16
|
|
17
|
-
it { expect(adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a":1}'::json ])).to eq
|
18
|
-
it { expect(adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a" : 1}'::json ])).to eq
|
19
|
-
it { expect(adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a":2}'::json ])).to eq
|
20
|
-
it { expect(adapter.select_value(%[ SELECT '{"a":1,"b":2}'::json = '{"b":2,"a":1}'::json ])).to eq
|
21
|
-
it { expect(adapter.select_value(%[ SELECT '{"a":1,"b":2,"x":{"c":4,"d":5}}'::json = '{"b":2, "x": { "d": 5, "c": 4}, "a":1}'::json ])).to eq
|
17
|
+
it { expect(adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a":1}'::json ])).to eq AR_TRUE }
|
18
|
+
it { expect(adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a" : 1}'::json ])).to eq AR_TRUE }
|
19
|
+
it { expect(adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a":2}'::json ])).to eq AR_FALSE }
|
20
|
+
it { expect(adapter.select_value(%[ SELECT '{"a":1,"b":2}'::json = '{"b":2,"a":1}'::json ])).to eq AR_TRUE }
|
21
|
+
it { expect(adapter.select_value(%[ SELECT '{"a":1,"b":2,"x":{"c":4,"d":5}}'::json = '{"b":2, "x": { "d": 5, "c": 4}, "a":1}'::json ])).to eq AR_TRUE }
|
22
22
|
|
23
23
|
context 'on a temporal table' do
|
24
24
|
before :all do
|
data/spec/spec_helper.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
2
2
|
#
|
3
|
-
require '
|
4
|
-
|
3
|
+
require 'simplecov'
|
4
|
+
SimpleCov.start
|
5
5
|
|
6
6
|
require 'chrono_model'
|
7
7
|
|
@@ -11,6 +11,9 @@ require 'support/matchers/table'
|
|
11
11
|
require 'support/matchers/column'
|
12
12
|
require 'support/matchers/index'
|
13
13
|
|
14
|
+
# Rails 5 returns a True/FalseClass
|
15
|
+
AR_TRUE, AR_FALSE = ActiveRecord::VERSION::MAJOR == 4 ? ['t', 'f'] : [true, false]
|
16
|
+
|
14
17
|
RSpec.configure do |config|
|
15
18
|
config.include(ChronoTest::Matchers::Schema)
|
16
19
|
config.include(ChronoTest::Matchers::Table)
|
@@ -12,7 +12,7 @@ module ChronoTest::Matchers
|
|
12
12
|
schema = options[:in]
|
13
13
|
kind = options[:kind] == :view ? 'v' : 'r'
|
14
14
|
|
15
|
-
select_value(<<-SQL, [ table, schema ], 'Check table exists') ==
|
15
|
+
select_value(<<-SQL, [ table, schema ], 'Check table exists') == AR_TRUE
|
16
16
|
SELECT EXISTS (
|
17
17
|
SELECT 1
|
18
18
|
FROM pg_class c
|
@@ -129,7 +129,7 @@ module ChronoTest::Matchers
|
|
129
129
|
def inherits_from_temporal?
|
130
130
|
binds = ["#{history_schema}.#{table}", "#{temporal_schema}.#{table}"]
|
131
131
|
|
132
|
-
@inheritance = select_value(<<-SQL, binds, 'Check inheritance') ==
|
132
|
+
@inheritance = select_value(<<-SQL, binds, 'Check inheritance') == AR_TRUE
|
133
133
|
SELECT EXISTS (
|
134
134
|
SELECT 1 FROM pg_catalog.pg_inherits
|
135
135
|
WHERE inhrelid = ?::regclass::oid
|
@@ -172,7 +172,7 @@ module ChronoTest::Matchers
|
|
172
172
|
attname: connection.primary_key(table)
|
173
173
|
}
|
174
174
|
|
175
|
-
@constraint = select_value(<<-SQL, binds, 'Check Consistency Constraint') ==
|
175
|
+
@constraint = select_value(<<-SQL, binds, 'Check Consistency Constraint') == AR_TRUE
|
176
176
|
SELECT EXISTS (
|
177
177
|
SELECT 1 FROM pg_catalog.pg_constraint
|
178
178
|
WHERE conname = :conname
|
data/spec/time_machine_spec.rb
CHANGED
@@ -24,6 +24,8 @@ describe ChronoModel::TimeMachine do
|
|
24
24
|
#
|
25
25
|
baz = Baz.create :name => 'baz', :bar => bar
|
26
26
|
|
27
|
+
# Specs start here
|
28
|
+
#
|
27
29
|
describe '.chrono?' do
|
28
30
|
subject { model.chrono? }
|
29
31
|
|
@@ -69,8 +71,6 @@ describe ChronoModel::TimeMachine do
|
|
69
71
|
it { is_expected.to include(Publication) }
|
70
72
|
end
|
71
73
|
|
72
|
-
# Specs start here
|
73
|
-
#
|
74
74
|
describe '.chrono_models' do
|
75
75
|
subject { ChronoModel::TimeMachine.chrono_models }
|
76
76
|
|
@@ -134,6 +134,28 @@ describe ChronoModel::TimeMachine do
|
|
134
134
|
it { expect(bar.as_of(bar.ts[3]).foo.name).to eq 'new foo' }
|
135
135
|
end
|
136
136
|
|
137
|
+
describe 'supports historical queries with includes()' do
|
138
|
+
it { expect(Foo.as_of(foo.ts[0]).includes(:bars).first.bars).to eq [] }
|
139
|
+
it { expect(Foo.as_of(foo.ts[1]).includes(:bars).first.bars).to eq [] }
|
140
|
+
it { expect(Foo.as_of(foo.ts[2]).includes(:bars).first.bars).to eq [bar] }
|
141
|
+
|
142
|
+
it { expect(Foo.as_of(bar.ts[0]).includes(:bars).first.bars.first.name).to eq 'bar' }
|
143
|
+
it { expect(Foo.as_of(bar.ts[1]).includes(:bars).first.bars.first.name).to eq 'foo bar' }
|
144
|
+
it { expect(Foo.as_of(bar.ts[2]).includes(:bars).first.bars.first.name).to eq 'bar bar' }
|
145
|
+
it { expect(Foo.as_of(bar.ts[3]).includes(:bars).first.bars.first.name).to eq 'new bar' }
|
146
|
+
|
147
|
+
|
148
|
+
it { expect(Bar.as_of(bar.ts[0]).includes(:foo).first.foo).to eq foo }
|
149
|
+
it { expect(Bar.as_of(bar.ts[1]).includes(:foo).first.foo).to eq foo }
|
150
|
+
it { expect(Bar.as_of(bar.ts[2]).includes(:foo).first.foo).to eq foo }
|
151
|
+
it { expect(Bar.as_of(bar.ts[3]).includes(:foo).first.foo).to eq foo }
|
152
|
+
|
153
|
+
it { expect(Bar.as_of(bar.ts[0]).includes(:foo).first.foo.name).to eq 'foo bar' }
|
154
|
+
it { expect(Bar.as_of(bar.ts[1]).includes(:foo).first.foo.name).to eq 'foo bar' }
|
155
|
+
it { expect(Bar.as_of(bar.ts[2]).includes(:foo).first.foo.name).to eq 'new foo' }
|
156
|
+
it { expect(Bar.as_of(bar.ts[3]).includes(:foo).first.foo.name).to eq 'new foo' }
|
157
|
+
end
|
158
|
+
|
137
159
|
it 'doesn\'t raise RecordNotFound when no history records are found' do
|
138
160
|
expect { foo.as_of(1.minute.ago) }.to_not raise_error
|
139
161
|
expect(foo.as_of(1.minute.ago)).to be(nil)
|
data/spec/utils_spec.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ChronoModel::Conversions do
|
4
|
+
|
5
|
+
describe 'string_to_utc_time' do
|
6
|
+
subject { described_class.string_to_utc_time(string) }
|
7
|
+
|
8
|
+
context 'given a valid UTC time string' do
|
9
|
+
let(:string) { '2017-02-06 09:46:31.129626' }
|
10
|
+
|
11
|
+
it { is_expected.to be_a(Time) }
|
12
|
+
it { expect(subject.year).to eq 2017 }
|
13
|
+
it { expect(subject.month).to eq 2 }
|
14
|
+
it { expect(subject.day).to eq 6 }
|
15
|
+
it { expect(subject.hour).to eq 9 }
|
16
|
+
it { expect(subject.min).to eq 46 }
|
17
|
+
it { expect(subject.sec).to eq 31 }
|
18
|
+
it { expect(subject.usec).to eq 129626 } # Ref Issue #32
|
19
|
+
end
|
20
|
+
|
21
|
+
context 'given a valid UTC string without least significant zeros' do
|
22
|
+
let(:string) { '2017-02-06 09:46:31.129' }
|
23
|
+
|
24
|
+
it { is_expected.to be_a(Time) }
|
25
|
+
it { expect(subject.usec).to eq 129000 } # Ref Issue #32
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'given an invalid UTC time string' do
|
29
|
+
let(:string) { 'foobar' }
|
30
|
+
|
31
|
+
it { is_expected.to be(nil) }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe 'time_to_utc_string' do
|
36
|
+
subject { described_class.time_to_utc_string(time) }
|
37
|
+
|
38
|
+
let(:time) { Time.utc(1981, 4, 11, 2, 42, 10, 123456) }
|
39
|
+
|
40
|
+
it { is_expected.to eq '1981-04-11 02:42:10.123456' }
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chrono_model
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.10.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Marcello Barnaba
|
@@ -9,22 +9,28 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2017-
|
12
|
+
date: 2017-07-05 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
16
16
|
requirement: !ruby/object:Gem::Requirement
|
17
17
|
requirements:
|
18
|
-
- - "
|
18
|
+
- - ">="
|
19
19
|
- !ruby/object:Gem::Version
|
20
20
|
version: 4.2.0
|
21
|
+
- - "<"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 5.1.0
|
21
24
|
type: :runtime
|
22
25
|
prerelease: false
|
23
26
|
version_requirements: !ruby/object:Gem::Requirement
|
24
27
|
requirements:
|
25
|
-
- - "
|
28
|
+
- - ">="
|
26
29
|
- !ruby/object:Gem::Version
|
27
30
|
version: 4.2.0
|
31
|
+
- - "<"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 5.1.0
|
28
34
|
- !ruby/object:Gem::Dependency
|
29
35
|
name: pg
|
30
36
|
requirement: !ruby/object:Gem::Requirement
|
@@ -137,6 +143,20 @@ dependencies:
|
|
137
143
|
- - ">="
|
138
144
|
- !ruby/object:Gem::Version
|
139
145
|
version: '0'
|
146
|
+
- !ruby/object:Gem::Dependency
|
147
|
+
name: simplecov
|
148
|
+
requirement: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
type: :development
|
154
|
+
prerelease: false
|
155
|
+
version_requirements: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
140
160
|
- !ruby/object:Gem::Dependency
|
141
161
|
name: codeclimate-test-reporter
|
142
162
|
requirement: !ruby/object:Gem::Requirement
|
@@ -169,6 +189,8 @@ files:
|
|
169
189
|
- README.sql
|
170
190
|
- Rakefile
|
171
191
|
- chrono_model.gemspec
|
192
|
+
- gemfiles/rails_4.2.gemfile
|
193
|
+
- gemfiles/rails_5.0.gemfile
|
172
194
|
- lib/active_record/connection_adapters/chronomodel_adapter.rb
|
173
195
|
- lib/chrono_model.rb
|
174
196
|
- lib/chrono_model/adapter.rb
|
@@ -194,6 +216,7 @@ files:
|
|
194
216
|
- spec/support/matchers/table.rb
|
195
217
|
- spec/time_machine_spec.rb
|
196
218
|
- spec/time_query_spec.rb
|
219
|
+
- spec/utils_spec.rb
|
197
220
|
- sql/json_ops.sql
|
198
221
|
- sql/uninstall-json_ops.sql
|
199
222
|
homepage: https://github.com/ifad/chronomodel
|
@@ -215,7 +238,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
215
238
|
version: '0'
|
216
239
|
requirements: []
|
217
240
|
rubyforge_project:
|
218
|
-
rubygems_version: 2.
|
241
|
+
rubygems_version: 2.5.1
|
219
242
|
signing_key:
|
220
243
|
specification_version: 4
|
221
244
|
summary: Temporal extensions (SCD Type II) for Active Record
|
@@ -234,3 +257,4 @@ test_files:
|
|
234
257
|
- spec/support/matchers/table.rb
|
235
258
|
- spec/time_machine_spec.rb
|
236
259
|
- spec/time_query_spec.rb
|
260
|
+
- spec/utils_spec.rb
|