snaptime 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 105979aad445d0e6f274857977310cd72e6b43c0
4
+ data.tar.gz: 8230ef5d4b9f5454000d5f9297ea9243fc3e0b09
5
+ SHA512:
6
+ metadata.gz: 6c8f2cd32556bc1cabf50bdde6b47fc26112864e01e6f12a0a6d0d86e3e8cb73c5597c4be5ad92c21957ce19267df89e57c0d8f856757012a5cc7d353a1732dd
7
+ data.tar.gz: 15ebe2cee56f8c14281124e00a2bb0c712083f9fa46e1c44bf48909e3661e2c7c92f55d8422f0ca20c0985c6f174b48b847e27a7b02f009f71f75b03087e1a27
data/.gitignore ADDED
@@ -0,0 +1,50 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ ## Specific to RubyMotion:
17
+ .dat*
18
+ .repl_history
19
+ build/
20
+ *.bridgesupport
21
+ build-iPhoneOS/
22
+ build-iPhoneSimulator/
23
+
24
+ ## Specific to RubyMotion (use of CocoaPods):
25
+ #
26
+ # We recommend against adding the Pods directory to your .gitignore. However
27
+ # you should judge for yourself, the pros and cons are mentioned at:
28
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
29
+ #
30
+ # vendor/Pods/
31
+
32
+ ## Documentation cache and generated files:
33
+ /.yardoc/
34
+ /_yardoc/
35
+ /doc/
36
+ /rdoc/
37
+
38
+ ## Environment normalization:
39
+ /.bundle/
40
+ /vendor/bundle
41
+ /lib/bundler/man/
42
+
43
+ # for a library or gem, you might want to ignore these files since the code is
44
+ # intended to run in multiple environments; otherwise, check them in:
45
+ # Gemfile.lock
46
+ # .ruby-version
47
+ # .ruby-gemset
48
+
49
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
50
+ .rvmrc
data/.releaser_config ADDED
@@ -0,0 +1,3 @@
1
+ version_file: VERSION
2
+ always_from_master: true
3
+ gem_style: github
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify gem dependencies in the .gemspec file
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,67 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ snaptime (0.0.1)
5
+ activerecord
6
+ activesupport
7
+ request_store
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ activemodel (5.2.3)
13
+ activesupport (= 5.2.3)
14
+ activerecord (5.2.3)
15
+ activemodel (= 5.2.3)
16
+ activesupport (= 5.2.3)
17
+ arel (>= 9.0)
18
+ activesupport (5.2.3)
19
+ concurrent-ruby (~> 1.0, >= 1.0.2)
20
+ i18n (>= 0.7, < 2)
21
+ minitest (~> 5.1)
22
+ tzinfo (~> 1.1)
23
+ arel (9.0.0)
24
+ ast (2.4.0)
25
+ benchmark-ips (2.7.2)
26
+ concurrent-ruby (1.1.5)
27
+ i18n (1.6.0)
28
+ concurrent-ruby (~> 1.0)
29
+ minitest (5.11.3)
30
+ mysql2 (0.5.2)
31
+ parallel (1.17.0)
32
+ parser (2.6.2.0)
33
+ ast (~> 2.4.0)
34
+ powerpack (0.1.2)
35
+ rack (2.0.7)
36
+ rainbow (2.2.2)
37
+ rake
38
+ rake (12.3.2)
39
+ request_store (1.4.1)
40
+ rack (>= 1.4)
41
+ rubocop (0.51.0)
42
+ parallel (~> 1.10)
43
+ parser (>= 2.3.3.1, < 3.0)
44
+ powerpack (~> 0.1)
45
+ rainbow (>= 2.2.2, < 3.0)
46
+ ruby-progressbar (~> 1.7)
47
+ unicode-display_width (~> 1.0, >= 1.0.1)
48
+ ruby-progressbar (1.10.0)
49
+ thread_safe (0.3.6)
50
+ tzinfo (1.2.5)
51
+ thread_safe (~> 0.1)
52
+ unicode-display_width (1.5.0)
53
+
54
+ PLATFORMS
55
+ ruby
56
+
57
+ DEPENDENCIES
58
+ benchmark-ips
59
+ bundler (~> 2.0)
60
+ minitest
61
+ mysql2
62
+ rake
63
+ rubocop (= 0.51.0)
64
+ snaptime!
65
+
66
+ BUNDLED WITH
67
+ 2.0.1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Sitrox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # snaptime
2
+
3
+ **This Gem is still in an early stage of development. Please not use this in production yet.**
4
+
5
+ Snaptime lets you versionize Active Record models using single table versioning.
6
+ Records are identified by a `natural_id` and their validity is controlled via
7
+ `valid_from` and `valid_to`. Snaptime also supports associations between
8
+ versioned models as well as associations to and from unversioned models.
9
+
10
+ ## Reasons for snaptime
11
+
12
+ * Most of Snaptime's operation is transparent, so you won't even notice that
13
+ a certain model is versioned if you don't explicitely want to.
14
+
15
+ * Associations between, to and from versioned models are automatically handeled.
16
+ This lets Snaptime version entire, complex hierarchies.
17
+
18
+ * It is very lightweight.
19
+
20
+ * It is non-intrusive. It does not overwrite a single Rails method without
21
+ you explicitly telling it to do so.
22
+
23
+ * It can be easily extended to support other database adapters.
24
+
25
+ ## Basic concept
26
+
27
+ Snaptime can be enabled on a per-model basis. Using dedicated migration methods,
28
+ each versioned table is complemented with the following fields:
29
+
30
+ - `natural_id`
31
+
32
+ This is the ID that does not change between versions. This is also the ID
33
+ that is referenced when pointing to a versioned model.
34
+
35
+ - `valid_from`, `valid_to`
36
+
37
+ These are UTC timestamps in milliseconds that specify a specific version's
38
+ validity. The field `valid_from` always specifies the point in time at which
39
+ a certain version has been created, while `valid_to` says when the version
40
+ got outdated. This can happen by deleting the record (which does not actually
41
+ delete it but just sets `valid_to`) or when a new version arises. There can't
42
+ be any gaps between the validity fields of a version string.
43
+
44
+ Snaptime works by hooking into your versioned models at *creation*, *update* and
45
+ *deletion*:
46
+
47
+ - At creation, it automatically generates a new `natural_id` and sets
48
+ `valid_from` to the current time.
49
+
50
+ - At update, `valid_from` is again set to the current time and the record is
51
+ updated as usual. But before the record gets updated, Snaptime creates a copy
52
+ of the record's current state and sets `valid_from` and `valid_to`
53
+ accordingly. The copy is created in-db (using `insert ... select`) and does
54
+ not call any application side logic.
55
+
56
+ - At deletion, all it does is setting `valid_to` of the current record to the
57
+ current time again.
58
+
59
+ What this means is that the original record always stays the newest one. This
60
+ has many advantages, as the record itself can be updated as usual and if another
61
+ model would ever point at the `id` instead the `natural_id`, it would always
62
+ point to the newest one.
63
+
64
+ ## Basic setup
65
+
66
+ ### Gemfile
67
+
68
+ Add the following to your application's Gemfile:
69
+
70
+ ```ruby
71
+ gem :snaptime
72
+ ```
73
+
74
+ You can also specify a fixed version:
75
+
76
+ ```ruby
77
+ gem :snaptime, '~> 1.0.0'
78
+ ```
79
+
80
+ ### Setup task
81
+
82
+ Snaptime needs to generate a `natural_id` next to your `id`. In some adapters,
83
+ this can be done using database sequences, while other databases require a
84
+ custom setup (i.e. MySQL let's you workaround this by adding a dedicated
85
+ sequence table as well as a custom procedure).
86
+
87
+ Using the following rake task, Snaptime automatically generates the required
88
+ migrations. Note that this migration only performs a basic setup and does not
89
+ enable any of your models to be versioned. This happens using separate
90
+ migrations as described in the following chapters.
91
+
92
+ ```bash
93
+ rake snaptime:setup -- <adapter-name>
94
+ ```
95
+
96
+ Replace `<adapter-name>` with the name of your database adapter. Spell it
97
+ exactly as the `adapter` setting of your database configuration reads. If the
98
+ specific adapter does not require any setup, you will get a respective message.
99
+
100
+ ## Versionizing a model
101
+
102
+ ### Performing the database migrations
103
+
104
+ TODO
105
+
106
+ ### Include the versionize module
107
+
108
+ ```ruby
109
+ class YourModel < ActiveRecord::Base
110
+ include Snaptime::Versioned
111
+ end
112
+ ```
113
+
114
+ ## Caveats
115
+
116
+ - Do not use `def self.default_scope` but `default_scope do` in versioned models
117
+ if you need to extend the default scope.
118
+ - `.unscoped` also removes the default scope added by snaptime.
data/Rakefile ADDED
@@ -0,0 +1,34 @@
1
+ task :gemspec do
2
+ gemspec = Gem::Specification.new do |spec|
3
+ spec.name = 'snaptime'
4
+ spec.version = IO.read('VERSION').chomp
5
+ spec.authors = ['Sitrox']
6
+ spec.summary = %(
7
+ Multi-threaded job backend with database queuing for ruby.
8
+ )
9
+ spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
10
+ spec.executables = []
11
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
12
+ spec.require_paths = ['lib']
13
+
14
+ spec.add_development_dependency 'bundler', '~> 2.0'
15
+ spec.add_development_dependency 'rake'
16
+ spec.add_development_dependency 'rubocop', '0.51.0'
17
+ spec.add_development_dependency 'minitest'
18
+ spec.add_development_dependency 'mysql2'
19
+ spec.add_development_dependency 'benchmark-ips'
20
+ spec.add_dependency 'activesupport'
21
+ spec.add_dependency 'activerecord'
22
+ spec.add_dependency 'request_store'
23
+ end
24
+
25
+ File.open('snaptime.gemspec', 'w') { |f| f.write(gemspec.to_ruby.strip) }
26
+ end
27
+
28
+ require 'rake/testtask'
29
+
30
+ Rake::TestTask.new do |t|
31
+ t.pattern = 'test/snaptime/**/*_test.rb'
32
+ t.verbose = false
33
+ t.libs << 'test/lib'
34
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
data/lib/snaptime.rb ADDED
@@ -0,0 +1,121 @@
1
+ require 'snaptime/migration_helpers'
2
+ require 'snaptime/record_cloner'
3
+ require 'snaptime/harvester'
4
+ require 'snaptime/versioned/scopes'
5
+ require 'snaptime/exceptions'
6
+ require 'snaptime/relations_builder'
7
+ require 'snaptime/railtie'
8
+ require 'snaptime/ar_hooks'
9
+ require 'snaptime/base_ar_mixin'
10
+ require 'snaptime/relations'
11
+ require 'snaptime/virtual_models/snaptime'
12
+ require 'snaptime/versioned'
13
+
14
+ module Snptime
15
+ SNAPTIME_REQUEST_STORE_KEY = :snaptime_snaptime
16
+ CURRENT_NOW_REQUEST_STORE_KEY = :snaptime_current_now
17
+ CLONED_RECORDS_STORE_KEY = :snaptime_cloned_records
18
+ RECORD_CLONING_SWITCH_REQUEST_STORE_KEY = :snaptime_record_cloning
19
+ SMALLEST_TIME_UNIT = 0.001
20
+
21
+ @consolidation_fields = ActiveSupport::OrderedHash.new
22
+
23
+ def self.register_consolidation_field(name, aggregate_with: nil, default: Arel.sql('null'))
24
+ @consolidation_fields[name] = { aggregate_with: aggregate_with, default: default }
25
+ end
26
+
27
+ def self.consolidation_fields
28
+ @consolidation_fields
29
+ end
30
+
31
+ @model_class = Snaptime::VirtualModels::Snaptime
32
+
33
+ def self.model_class=(model_class)
34
+ @model_class = model_class
35
+ end
36
+
37
+ def self.model_class
38
+ @model_class
39
+ end
40
+
41
+ def self.with_snaptime(snaptime, &_block)
42
+ previous_snaptime = RequestStore.store[SNAPTIME_REQUEST_STORE_KEY]
43
+
44
+ RequestStore.store[SNAPTIME_REQUEST_STORE_KEY] = snaptime
45
+
46
+ begin
47
+ yield
48
+ ensure
49
+ RequestStore.store[SNAPTIME_REQUEST_STORE_KEY] = previous_snaptime
50
+ end
51
+ end
52
+
53
+ def self.without_record_cloning(&_block)
54
+ previous_setting = RequestStore.store[RECORD_CLONING_SWITCH_REQUEST_STORE_KEY]
55
+
56
+ RequestStore.store[RECORD_CLONING_SWITCH_REQUEST_STORE_KEY] = false
57
+
58
+ begin
59
+ yield
60
+ ensure
61
+ RequestStore.store[RECORD_CLONING_SWITCH_REQUEST_STORE_KEY] = previous_setting
62
+ end
63
+ end
64
+
65
+ def self.record_cloning_enabled?
66
+ RequestStore.store[RECORD_CLONING_SWITCH_REQUEST_STORE_KEY] != false
67
+ end
68
+
69
+ def self.snaptime
70
+ RequestStore.store[SNAPTIME_REQUEST_STORE_KEY]
71
+ end
72
+
73
+ def self.current_now
74
+ RequestStore.store[CURRENT_NOW_REQUEST_STORE_KEY] ||= Time.now.utc
75
+ end
76
+
77
+ # Override the "current now" used for creating new versions. Only use this
78
+ # method for testing purposes and make sure you use `reset_current_now` if
79
+ # necessary. Use `with_fake_current_now` whenever possible.
80
+ def self.fake_current_now(time)
81
+ RequestStore.store[CURRENT_NOW_REQUEST_STORE_KEY] ||= time.utc
82
+ end
83
+
84
+ # Override the "current now" used for creating new versions. Only use this
85
+ # method for testing purposes.
86
+ def self.with_fake_current_now(time, &_block)
87
+ fake_current_now time
88
+
89
+ begin
90
+ yield
91
+ ensure
92
+ reset_current_now
93
+ end
94
+ end
95
+
96
+ def self.reset_current_now
97
+ RequestStore.store[CURRENT_NOW_REQUEST_STORE_KEY] = nil
98
+ end
99
+
100
+ def self.record_cloned_in_current_tx(record)
101
+ RequestStore.store[CLONED_RECORDS_STORE_KEY] ||= {}
102
+ RequestStore.store[CLONED_RECORDS_STORE_KEY][record.class.table_name] ||= {}
103
+ RequestStore.store[CLONED_RECORDS_STORE_KEY][record.class.table_name][record.send(record.class.primary_key)] = true
104
+ end
105
+
106
+ def self.record_cloned_in_current_tx?(record)
107
+ RequestStore.store
108
+ .try(:[], CLONED_RECORDS_STORE_KEY)
109
+ .try(:[], record.class.table_name)
110
+ .try(:[], record.send(record.class.primary_key)) || false
111
+ end
112
+
113
+ def self.reset_records_cloned_in_current_tx
114
+ RequestStore.store[CLONED_RECORDS_STORE_KEY] = nil
115
+ end
116
+
117
+ def self.after_commit_or_rollback
118
+ reset_current_now
119
+ reset_records_cloned_in_current_tx
120
+ end
121
+ end
@@ -0,0 +1,38 @@
1
+ module Snaptime
2
+ module ArHooks
3
+ def self.before_create(record)
4
+ if record.natural_id.nil?
5
+ record.valid_from = Snaptime.current_now
6
+ ActiveRecord::Base.uncached do
7
+ record.natural_id = record.class.connection.next_sequence_value(record.class.sequence_name)
8
+ end
9
+ end
10
+ end
11
+
12
+ def self.after_create(record)
13
+ Snaptime.record_cloned_in_current_tx(record)
14
+ end
15
+
16
+ def self.before_update(record)
17
+ return unless Snaptime.record_cloning_enabled?
18
+
19
+ if record.valid_to.nil? && record.changed? && !Snaptime.record_cloned_in_current_tx?(record)
20
+ record.valid_from = Snaptime.current_now
21
+
22
+ Snaptime::RecordCloner.clone_record!(
23
+ record,
24
+ override_attributes: { valid_to: record.valid_from - SMALLEST_TIME_UNIT },
25
+ return_record: false
26
+ )
27
+
28
+ Snaptime.record_cloned_in_current_tx(record)
29
+ end
30
+ end
31
+
32
+ def self.destroy(record)
33
+ record.deleted = true
34
+ record.version_is_minor = true if record.respond_to?(:version_is_minor=)
35
+ record.save!
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ module Snaptime
2
+ module BaseArMixin
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def versioned?
7
+ false
8
+ end
9
+ end
10
+
11
+ def natural_id_or_id
12
+ self.class.versioned? ? natural_id : id
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Snaptime
2
+ module Exceptions
3
+ class DeleteMethodsAreNotAvailable < StandardError
4
+ def initialize
5
+ super('Versionized records only support the `destroy` methods.')
6
+ end
7
+ end
8
+
9
+ class AssociationTargetNotVersioned < StandardError
10
+ def initialize(target_class)
11
+ super("Association target #{target_class.inspect} does not appear to be versioned.")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,195 @@
1
+ module Snaptime
2
+ module Harvester
3
+ def self.harvest_for(record)
4
+ # ---------------------------------------------------------------
5
+ # Build individual selects for each combination of table,
6
+ # key fields and values.
7
+ # ---------------------------------------------------------------
8
+ selects = []
9
+
10
+ queries = snaptime_queries(record.class, [record.natural_id], nil)
11
+
12
+ queries.each do |klass, keys_and_values|
13
+ keys_and_values.each do |key, values|
14
+ selects << select_for(klass, key, values)
15
+ end
16
+ end
17
+
18
+ # ---------------------------------------------------------------
19
+ # Build master select that unions all of the above queries
20
+ # TODO: Probably add a separate option for coalesce to avoid
21
+ # doubling the code for max and min.
22
+ # ---------------------------------------------------------------
23
+ rel = Snaptime.model_class
24
+ rel = rel.from(
25
+ "#{union_selects(*selects)} inner_snaptimes"
26
+ )
27
+
28
+ # TODO: Move to DB-specific adapter
29
+ rel = rel.select(Arel.sql("LISTAGG(record_lookups, ';') WITHIN GROUP(ORDER BY record_lookups)").as('record_lookups'))
30
+
31
+ Snaptime.consolidation_fields.each do |field, options|
32
+ if options[:aggregate_with].nil?
33
+ rel = rel.select(field)
34
+ elsif options[:aggregate_with] == :max
35
+ rel = rel.select(Arel.sql(field.to_s).maximum.as(field.to_s))
36
+ elsif options[:aggregate_with] == :max_coalesce_0
37
+ rel = rel.select(
38
+ Arel.sql(
39
+ Arel::Nodes::NamedFunction.new('coalesce', [Arel.sql(field.to_s), Arel.sql(0.to_s)]).to_sql
40
+ ).maximum.as(field.to_s)
41
+ )
42
+ elsif options[:aggregate_with] == :min
43
+ rel = rel.select(Arel.sql(field.to_s).minimum.as(field.to_s))
44
+ elsif options[:aggregate_with] == :min_coalesce_0
45
+ rel = rel.select(
46
+ Arel.sql(
47
+ Arel::Nodes::NamedFunction.new('coalesce', [Arel.sql(field.to_s), Arel.sql(0.to_s)]).to_sql
48
+ ).minimum.as(field.to_s)
49
+ )
50
+ elsif options[:aggregate_with] == :sum
51
+ rel = rel.select(Arel.sql(field.to_s).sum.as(field.to_s))
52
+ end
53
+ end
54
+
55
+ grouping_fields = Snaptime.consolidation_fields.select { |_k, v| v[:aggregate_with].nil? }.keys.collect(&:to_s)
56
+ # rel = rel.order('inner_snaptimes.valid_from desc')
57
+ rel = rel.order(Arel::Table.new(:inner_snaptimes)[:valid_from].desc)
58
+ rel = rel.group(*grouping_fields)
59
+
60
+ # ---------------------------------------------------------------
61
+ # Wrap master select in another select so that outer orders,
62
+ # wheres and counts work out-of-the-box.
63
+ # ---------------------------------------------------------------
64
+ all_keys = [:record_lookups] + Snaptime.consolidation_fields.keys
65
+
66
+ all_fields = all_keys.collect do |key|
67
+ Arel::Table.new(:snaptimes)[key]
68
+ end
69
+
70
+ outer_rel = Snaptime.model_class.select(all_fields).from("(#{rel.to_sql}) snaptimes")
71
+
72
+ return outer_rel
73
+ end
74
+
75
+ def self.select_for(klass, key, values)
76
+ table = klass.arel_table
77
+
78
+ select = Arel::SelectManager.new(ActiveRecord::Base)
79
+ select.from table
80
+
81
+ concat = Arel::Nodes::NamedFunction.new('concat', [
82
+ Arel.sql(klass.connection.quote("#{klass.name},")),
83
+ table[klass.primary_key]
84
+ ])
85
+ select.project(
86
+ concat.as('record_lookups')
87
+ )
88
+
89
+ Snaptime.consolidation_fields.each do |field_key, options|
90
+ if klass.column_names.include?(field_key.to_s)
91
+ select.project(table[field_key.to_s])
92
+ else
93
+ unless options[:default].is_a?(Arel::Nodes::SqlLiteral)
94
+ fail 'Option :default must be an Arel::Nodes::SqlLiteral.'
95
+ end
96
+ select.project(options[:default].as(field_key.to_s))
97
+ end
98
+ end
99
+
100
+ select.where(table[key].in(values))
101
+
102
+ return select
103
+ end
104
+
105
+ private_class_method :select_for
106
+
107
+ def self.snaptime_queries(klass, natural_ids, association, visited_natural_ids_by_assoc = {})
108
+ queries = {}
109
+
110
+ # ---------------------------------------------------------------
111
+ # Abort if all natural IDs have already been processed for the
112
+ # given association.
113
+ # ---------------------------------------------------------------
114
+ visited_natural_ids_by_assoc[association] ||= []
115
+ return {} if (natural_ids - visited_natural_ids_by_assoc[association]).empty?
116
+ visited_natural_ids_by_assoc[association] += natural_ids
117
+
118
+ my_natural_ids = natural_ids
119
+
120
+ if association.nil? || association.is_a?(ActiveRecord::Reflection::BelongsToReflection)
121
+ # Either:
122
+ # I'm the root of the query, return all valid_from from my versions,
123
+ # which are identified by my natural_id.
124
+ #
125
+ # Or:
126
+ # I'm target of a belongs_to, I'm getting all natural_ids that my
127
+ # peer is pointing to with his foreign key. So I will return all
128
+ # valid_from that belong to these natural_ids.
129
+ queries[klass] ||= {}
130
+ queries[klass][:natural_id] ||= []
131
+ queries[klass][:natural_id] += natural_ids
132
+ elsif association.is_a?(ActiveRecord::Reflection::HasOneReflection) || association.is_a?(ActiveRecord::Reflection::HasManyReflection)
133
+ # I'm target of a has_one / has_many, I'm getting my peer's
134
+ # natural_id and have to return all valid_from of records that are
135
+ # pointing to it.
136
+ queries[klass] ||= {}
137
+ queries[klass][association.foreign_key] ||= []
138
+ queries[klass][association.foreign_key] += natural_ids
139
+
140
+ # My own natural_ids for going further are the one's of the records that
141
+ # are pointing to my peer.
142
+ my_natural_ids = klass.unscoped.select(:natural_id).where(association.foreign_key => natural_ids).collect(&:natural_id)
143
+ end
144
+
145
+ klass.versioned_associations.each do |_name, nested_association|
146
+ if nested_association.is_a?(ActiveRecord::Reflection::BelongsToReflection)
147
+ # I have an association that I am pointing to. I'll have to supply
148
+ # them with all the natural_ids I'm pointing to.
149
+
150
+ table = Arel::Table.new(klass.table_name)
151
+
152
+ natural_fks = klass
153
+ .unscoped
154
+ .select(nested_association.foreign_key)
155
+ .where(natural_id: my_natural_ids)
156
+ .where(table[nested_association.foreign_key].not_eq(nil))
157
+ .collect(&nested_association.foreign_key.to_sym)
158
+
159
+ queries = merge_queries(
160
+ queries,
161
+ snaptime_queries(nested_association.klass, natural_fks, nested_association, visited_natural_ids_by_assoc)
162
+ )
163
+ elsif nested_association.is_a?(ActiveRecord::Reflection::HasOneReflection) || nested_association.is_a?(ActiveRecord::Reflection::HasManyReflection)
164
+ # I have an association that points to me. I'll have to supply my
165
+ # natural_ids of interest.
166
+ queries = merge_queries(
167
+ queries,
168
+ snaptime_queries(nested_association.klass, my_natural_ids, nested_association, visited_natural_ids_by_assoc)
169
+ )
170
+ else
171
+ fail "Unsupported relation type #{nested_association.class}."
172
+ end
173
+ end
174
+
175
+ return queries
176
+ end
177
+
178
+ private_class_method :snaptime_queries
179
+
180
+ def self.merge_queries(a, b)
181
+ a.deep_merge b do |_key, val_a, val_b|
182
+ (val_a + val_b).uniq
183
+ end
184
+ end
185
+
186
+ private_class_method :merge_queries
187
+
188
+ def self.union_selects(*selects)
189
+ stmt = selects.collect(&:to_sql).collect { |sql| "(#{sql})" }.join("\nUNION\n")
190
+ return "(#{stmt})"
191
+ end
192
+
193
+ private_class_method :union_selects
194
+ end
195
+ end
@@ -0,0 +1,54 @@
1
+ module Snaptime
2
+ module MigrationHelpers
3
+ def self.load
4
+ ActiveRecord::ConnectionAdapters::Table.class_eval do
5
+ include SchemaStatements::Table
6
+ end
7
+
8
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.module_eval do
9
+ include SchemaStatements::TopLevel
10
+ end
11
+ end
12
+
13
+ module SchemaStatements
14
+ module Table
15
+ def versionize
16
+ column :natural_id, :integer
17
+ column :valid_from, :timestamp, precision: 3
18
+ column :valid_to, :timestamp, precision: 3
19
+ column :deleted, :boolean, null: false, default: 0
20
+
21
+ index :natural_id
22
+ index :valid_from
23
+ index :valid_to
24
+
25
+ index %i(natural_id valid_to), unique: true
26
+
27
+ @base.execute %(
28
+ ALTER TABLE "#{name.to_s.upcase}"
29
+ ADD CONSTRAINT "#{name.to_s.upcase}_VCVD" CHECK (
30
+ VALID_TO IS NULL OR VALID_FROM <= VALID_TO
31
+ )
32
+ )
33
+ end
34
+
35
+ def unversionize
36
+ remove :natural_id
37
+ remove :valid_from
38
+ remove :valid_to
39
+ remove :deleted
40
+ end
41
+ end
42
+
43
+ module TopLevel
44
+ def versionize_table(table_name)
45
+ change_table table_name, &:versionize
46
+ end
47
+
48
+ def unversionize_table(table_name)
49
+ change_table table_name, &:unversionize
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,23 @@
1
+ module Snaptime
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :snaptime
4
+
5
+ initializer :snaptime do
6
+ ActiveSupport.on_load :active_record do
7
+ Snaptime::MigrationHelpers.load
8
+ end
9
+
10
+ ActiveRecord::Base.send :include, Snaptime::BaseArMixin
11
+
12
+ ActiveRecord::Base.send :after_commit do
13
+ Snaptime.after_commit_or_rollback
14
+ end
15
+
16
+ ActiveRecord::Base.send :after_rollback do
17
+ Snaptime.after_commit_or_rollback
18
+ end
19
+
20
+ Snaptime.register_consolidation_field(:valid_from)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,72 @@
1
+ module Snaptime
2
+ class RecordCloner
3
+ def self.clone_record!(record, override_attributes: {}, return_record: true)
4
+ override_attribute_keys = override_attributes.keys
5
+
6
+ table = record.class.arel_table
7
+
8
+ cloned_column_names = record.class.column_names - [record.class.primary_key] - override_attribute_keys.collect(&:to_s)
9
+
10
+ # ---------------------------------------------------------------
11
+ # Prepare select statement
12
+ # ---------------------------------------------------------------
13
+ select = Arel::SelectManager.new(record.class)
14
+ select.from table
15
+
16
+ # Add primary key
17
+ clone_id = next_id_for(record)
18
+ select.project clone_id
19
+
20
+ # Project custom column values
21
+ override_attribute_keys.each do |key|
22
+ select.project record.class.connection.quote(override_attributes[key])
23
+ end
24
+
25
+ # Project remaining (cloned) column values
26
+ cloned_column_names.each do |col|
27
+ select.project table[col.to_sym]
28
+ end
29
+
30
+ # Where statement for selecting the original record
31
+ select.where(table[record.class.primary_key.to_sym].eq(record.send(record.class.primary_key)))
32
+
33
+ # ---------------------------------------------------------------
34
+ # Prepare insert statement
35
+ # ---------------------------------------------------------------
36
+ insert = Arel::InsertManager.new
37
+ insert.into table
38
+ insert.select select
39
+
40
+ # Add primary key column name
41
+ insert.columns << table[record.class.primary_key.to_sym]
42
+
43
+ # Add override column names
44
+ override_attribute_keys.each do |attr|
45
+ insert.columns << table[attr.to_sym]
46
+ end
47
+
48
+ # Add remaining (cloned) column names
49
+ cloned_column_names.each do |c|
50
+ insert.columns << table[c.to_sym]
51
+ end
52
+
53
+ # ---------------------------------------------------------------
54
+ # Execute statement
55
+ # ---------------------------------------------------------------
56
+ record.class.connection.execute(insert.to_sql)
57
+
58
+ if return_record
59
+ return record.class.find(clone_id)
60
+ else
61
+ return nil
62
+ end
63
+ end
64
+
65
+ def self.next_id_for(record)
66
+ # See https://github.com/rsim/oracle-enhanced/issues/1733
67
+ ActiveRecord::Base.uncached do
68
+ record.class.connection.next_sequence_value(record.class.sequence_name)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,26 @@
1
+ # rubocop: disable Style/PredicateName
2
+
3
+ module Snaptime
4
+ module Relations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :versioned_associations
9
+ self.versioned_associations = {}.freeze
10
+ end
11
+
12
+ module ClassMethods
13
+ def has_one_versioned(name, scope = nil, options = {})
14
+ RelationsBuilder.build_versioned_relation(self, :has_one, name, scope, options)
15
+ end
16
+
17
+ def has_many_versioned(name, scope = nil, options = {}, &extension)
18
+ RelationsBuilder.build_versioned_relation(self, :has_many, name, scope, options, &extension)
19
+ end
20
+
21
+ def belongs_to_versioned(name, scope = nil, options = {})
22
+ RelationsBuilder.build_versioned_relation(self, :belongs_to, name, scope, options)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # rubocop: disable Metrics/ParameterLists
2
+
3
+ module Snaptime
4
+ module RelationsBuilder
5
+ def self.build_versioned_relation(klass, macro, name, scope = nil, options = {}, &extension)
6
+ if scope.is_a?(Hash)
7
+ options = scope
8
+ scope = nil
9
+ end
10
+
11
+ options[:primary_key] ||= (klass.versioned? ? :natural_id : klass.primary_key)
12
+
13
+ versioned_scope = proc do
14
+ rel = spawn.unscope(where: %i(valid_from valid_to))
15
+ rel = rel.merge(scope) unless scope.nil?
16
+ rel._at_explicit_snaptime(Snaptime.snaptime)
17
+ end
18
+
19
+ klass.send(macro, name, versioned_scope, options, &extension)
20
+
21
+ reflection = klass.reflect_on_association(name)
22
+
23
+ unless reflection.klass.versioned?
24
+ fail Exceptions::AssociationTargetNotVersioned, reflection.klass
25
+ end
26
+
27
+ klass.versioned_associations = klass.versioned_associations.merge(
28
+ name => reflection
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ module Snaptime
2
+ module Versioned
3
+ extend ActiveSupport::Concern
4
+
5
+ include Scopes
6
+ include Relations
7
+
8
+ module ClassMethods
9
+ def delete(*_args)
10
+ fail Exceptions::DeleteMethodsAreNotAvailable
11
+ end
12
+
13
+ def delete_all(*_args)
14
+ fail Exceptions::DeleteMethodsAreNotAvailable
15
+ end
16
+
17
+ def versioned?
18
+ true
19
+ end
20
+ end
21
+
22
+ def _run_create_callbacks(*args, &block)
23
+ super do
24
+ ArHooks.before_create(self)
25
+ yield
26
+ ArHooks.after_create(self)
27
+ end
28
+ end
29
+
30
+ # To make sure our before_update always runs after all other before_update
31
+ # methods, we override {_run_update_callbacks}. This prevents cases where an
32
+ # after_update callback changes the record after it has already been
33
+ # detected as no-changed. In this case, no shadow clone would be created.
34
+ def _run_update_callbacks(*args, &block)
35
+ super do
36
+ ArHooks.before_update(self)
37
+ yield
38
+ end
39
+ end
40
+
41
+ def destroy
42
+ ArHooks.destroy(self)
43
+ end
44
+
45
+ def delete
46
+ fail Exceptions::DeleteMethodsAreNotAvailable
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,64 @@
1
+ module Snaptime
2
+ module Versioned
3
+ module Scopes
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ default_scope do
8
+ current_version
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def current_version
14
+ snaptime = Snaptime.snaptime
15
+ _at_explicit_snaptime(snaptime)
16
+ end
17
+
18
+ def _at_explicit_snaptime(snaptime = nil)
19
+ if snaptime.nil?
20
+ where(valid_to: nil, deleted: false)
21
+ else
22
+ where(
23
+ arel_table[:valid_from].lteq(snaptime).and(
24
+ arel_table[:valid_to].eq(nil).or(
25
+ arel_table[:valid_to].gteq(snaptime)
26
+ )
27
+ ).and(
28
+ arel_table[:deleted].eq(false)
29
+ )
30
+ )
31
+ end
32
+ end
33
+
34
+ def at_snaptime
35
+ _at_explicit_snaptime Snaptime.snaptime
36
+ end
37
+ end
38
+
39
+ def snaptimes
40
+ Harvester.harvest_for(self)
41
+ end
42
+
43
+ def with_snaptime(snaptime = nil)
44
+ Snaptime.with_snaptime(snaptime) do
45
+ yield at_snaptime
46
+ end
47
+ end
48
+
49
+ def at_snaptime
50
+ _at_explicit_snaptime Snaptime.snaptime
51
+ end
52
+
53
+ def all_versions
54
+ self.class.unscoped.where('natural_id = ?', natural_id)
55
+ end
56
+
57
+ private
58
+
59
+ def _at_explicit_snaptime(snaptime = nil)
60
+ all_versions._at_explicit_snaptime(snaptime).first
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,52 @@
1
+ module Snaptime
2
+ module VirtualModels
3
+ class Snaptime < ActiveRecord::Base
4
+ def self.load_schema
5
+ columns_hash
6
+ end
7
+
8
+ def self.columns
9
+ [
10
+ ActiveRecord::ConnectionAdapters::Column.new('valid_from', nil, ActiveRecord::Base.connection.send(:lookup_cast_type, :timestamp))
11
+ ]
12
+ end
13
+
14
+ def self.columns_hash
15
+ Hash[columns.map { |c| [c.name, c] }]
16
+ end
17
+
18
+ self.primary_key = :valid_from
19
+
20
+ def record_lookups
21
+ read_attribute(:record_lookups).split(';')
22
+ end
23
+
24
+ def records
25
+ to_fetch = {}
26
+
27
+ record_lookups.collect do |identifier|
28
+ klass_name, id = identifier.split(',')
29
+
30
+ to_fetch[klass_name] ||= Set.new
31
+ to_fetch[klass_name] << id
32
+ end
33
+
34
+ records = []
35
+
36
+ to_fetch.each do |klass_name, ids|
37
+ records += klass_name.constantize.unscoped.find(ids.to_a)
38
+ end
39
+
40
+ return records
41
+ end
42
+
43
+ def model_names
44
+ read_attribute(:model_names).split(',')
45
+ end
46
+
47
+ def models
48
+ model_names.collect(&:constantize)
49
+ end
50
+ end
51
+ end
52
+ end
data/snaptime.gemspec ADDED
@@ -0,0 +1,51 @@
1
+ # -*- encoding: utf-8 -*-
2
+ # stub: snaptime 0.0.1 ruby lib
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "snaptime".freeze
6
+ s.version = "0.0.1"
7
+
8
+ s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
9
+ s.require_paths = ["lib".freeze]
10
+ s.authors = ["Sitrox".freeze]
11
+ s.date = "2019-04-03"
12
+ s.files = [".gitignore".freeze, ".releaser_config".freeze, "Gemfile".freeze, "Gemfile.lock".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "VERSION".freeze, "lib/snaptime.rb".freeze, "lib/snaptime/ar_hooks.rb".freeze, "lib/snaptime/base_ar_mixin.rb".freeze, "lib/snaptime/exceptions.rb".freeze, "lib/snaptime/harvester.rb".freeze, "lib/snaptime/migration_helpers.rb".freeze, "lib/snaptime/railtie.rb".freeze, "lib/snaptime/record_cloner.rb".freeze, "lib/snaptime/relations.rb".freeze, "lib/snaptime/relations_builder.rb".freeze, "lib/snaptime/versioned.rb".freeze, "lib/snaptime/versioned/scopes.rb".freeze, "lib/snaptime/virtual_models/snaptime.rb".freeze, "snaptime.gemspec".freeze]
13
+ s.rubygems_version = "2.5.2.3".freeze
14
+ s.summary = "Multi-threaded job backend with database queuing for ruby.".freeze
15
+
16
+ if s.respond_to? :specification_version then
17
+ s.specification_version = 4
18
+
19
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
20
+ s.add_development_dependency(%q<bundler>.freeze, ["~> 2.0"])
21
+ s.add_development_dependency(%q<rake>.freeze, [">= 0"])
22
+ s.add_development_dependency(%q<rubocop>.freeze, ["= 0.51.0"])
23
+ s.add_development_dependency(%q<minitest>.freeze, [">= 0"])
24
+ s.add_development_dependency(%q<mysql2>.freeze, [">= 0"])
25
+ s.add_development_dependency(%q<benchmark-ips>.freeze, [">= 0"])
26
+ s.add_runtime_dependency(%q<activesupport>.freeze, [">= 0"])
27
+ s.add_runtime_dependency(%q<activerecord>.freeze, [">= 0"])
28
+ s.add_runtime_dependency(%q<request_store>.freeze, [">= 0"])
29
+ else
30
+ s.add_dependency(%q<bundler>.freeze, ["~> 2.0"])
31
+ s.add_dependency(%q<rake>.freeze, [">= 0"])
32
+ s.add_dependency(%q<rubocop>.freeze, ["= 0.51.0"])
33
+ s.add_dependency(%q<minitest>.freeze, [">= 0"])
34
+ s.add_dependency(%q<mysql2>.freeze, [">= 0"])
35
+ s.add_dependency(%q<benchmark-ips>.freeze, [">= 0"])
36
+ s.add_dependency(%q<activesupport>.freeze, [">= 0"])
37
+ s.add_dependency(%q<activerecord>.freeze, [">= 0"])
38
+ s.add_dependency(%q<request_store>.freeze, [">= 0"])
39
+ end
40
+ else
41
+ s.add_dependency(%q<bundler>.freeze, ["~> 2.0"])
42
+ s.add_dependency(%q<rake>.freeze, [">= 0"])
43
+ s.add_dependency(%q<rubocop>.freeze, ["= 0.51.0"])
44
+ s.add_dependency(%q<minitest>.freeze, [">= 0"])
45
+ s.add_dependency(%q<mysql2>.freeze, [">= 0"])
46
+ s.add_dependency(%q<benchmark-ips>.freeze, [">= 0"])
47
+ s.add_dependency(%q<activesupport>.freeze, [">= 0"])
48
+ s.add_dependency(%q<activerecord>.freeze, [">= 0"])
49
+ s.add_dependency(%q<request_store>.freeze, [">= 0"])
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,190 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: snaptime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Sitrox
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-04-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '='
46
+ - !ruby/object:Gem::Version
47
+ version: 0.51.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '='
53
+ - !ruby/object:Gem::Version
54
+ version: 0.51.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: mysql2
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: benchmark-ips
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: activesupport
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: activerecord
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: request_store
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description:
140
+ email:
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files: []
144
+ files:
145
+ - ".gitignore"
146
+ - ".releaser_config"
147
+ - Gemfile
148
+ - Gemfile.lock
149
+ - LICENSE
150
+ - README.md
151
+ - Rakefile
152
+ - VERSION
153
+ - lib/snaptime.rb
154
+ - lib/snaptime/ar_hooks.rb
155
+ - lib/snaptime/base_ar_mixin.rb
156
+ - lib/snaptime/exceptions.rb
157
+ - lib/snaptime/harvester.rb
158
+ - lib/snaptime/migration_helpers.rb
159
+ - lib/snaptime/railtie.rb
160
+ - lib/snaptime/record_cloner.rb
161
+ - lib/snaptime/relations.rb
162
+ - lib/snaptime/relations_builder.rb
163
+ - lib/snaptime/versioned.rb
164
+ - lib/snaptime/versioned/scopes.rb
165
+ - lib/snaptime/virtual_models/snaptime.rb
166
+ - snaptime.gemspec
167
+ homepage:
168
+ licenses: []
169
+ metadata: {}
170
+ post_install_message:
171
+ rdoc_options: []
172
+ require_paths:
173
+ - lib
174
+ required_ruby_version: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ required_rubygems_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: '0'
184
+ requirements: []
185
+ rubyforge_project:
186
+ rubygems_version: 2.5.2.3
187
+ signing_key:
188
+ specification_version: 4
189
+ summary: Multi-threaded job backend with database queuing for ruby.
190
+ test_files: []