chrono_model 0.9.2 → 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|