mcfly 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. data/.gitignore +21 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +4 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +113 -0
  6. data/Rakefile +27 -0
  7. data/lib/mcfly/controller.rb +21 -0
  8. data/lib/mcfly/delete_trig.sql +18 -0
  9. data/lib/mcfly/has_mcfly.rb +63 -0
  10. data/lib/mcfly/insert_trig.sql +27 -0
  11. data/lib/mcfly/migration.rb +27 -0
  12. data/lib/mcfly/update_append_only_trig.sql +25 -0
  13. data/lib/mcfly/update_trig.sql +62 -0
  14. data/lib/mcfly/version.rb +3 -0
  15. data/lib/mcfly.rb +36 -0
  16. data/lib/tasks/mcfly_tasks.rake +4 -0
  17. data/mcfly.gemspec +27 -0
  18. data/spec/dummy/.rspec +1 -0
  19. data/spec/dummy/README.rdoc +261 -0
  20. data/spec/dummy/Rakefile +7 -0
  21. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  22. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  23. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  24. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  25. data/spec/dummy/app/mailers/.gitkeep +0 -0
  26. data/spec/dummy/app/models/.gitkeep +0 -0
  27. data/spec/dummy/app/models/market_price.rb +26 -0
  28. data/spec/dummy/app/models/security_instrument.rb +15 -0
  29. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  30. data/spec/dummy/config/application.rb +65 -0
  31. data/spec/dummy/config/boot.rb +10 -0
  32. data/spec/dummy/config/database.yml +17 -0
  33. data/spec/dummy/config/environment.rb +5 -0
  34. data/spec/dummy/config/environments/development.rb +37 -0
  35. data/spec/dummy/config/environments/production.rb +67 -0
  36. data/spec/dummy/config/environments/test.rb +37 -0
  37. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  38. data/spec/dummy/config/initializers/inflections.rb +15 -0
  39. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  40. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  41. data/spec/dummy/config/initializers/session_store.rb +8 -0
  42. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  43. data/spec/dummy/config/locales/en.yml +5 -0
  44. data/spec/dummy/config/routes.rb +58 -0
  45. data/spec/dummy/config.ru +4 -0
  46. data/spec/dummy/db/migrate/001_create_security_instruments.rb +8 -0
  47. data/spec/dummy/db/migrate/002_create_market_prices.rb +14 -0
  48. data/spec/dummy/db/schema.rb +37 -0
  49. data/spec/dummy/lib/assets/.gitkeep +0 -0
  50. data/spec/dummy/log/.gitkeep +0 -0
  51. data/spec/dummy/log/development.log +3 -0
  52. data/spec/dummy/public/404.html +26 -0
  53. data/spec/dummy/public/422.html +26 -0
  54. data/spec/dummy/public/500.html +25 -0
  55. data/spec/dummy/public/favicon.ico +0 -0
  56. data/spec/dummy/script/rails +6 -0
  57. data/spec/model_spec.rb +206 -0
  58. data/spec/spec_helper.rb +51 -0
  59. metadata +184 -0
data/.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *~
19
+ *.komodoproject
20
+ .rvmrc
21
+ spec/dummy/log
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in delorean.gemspec
4
+ gemspec
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2013 Arman Bostani
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Mcfly
2
+
3
+ Mcfly is a database table versioning system. It's useful for tracking
4
+ and auditing changes to database tables. It's also very easy to
5
+ access the current state of Mcfly tables at any point in time.
6
+
7
+ ![](http://i.imgur.com/IG77ww0.jpg)
8
+
9
+ ## Features
10
+
11
+ * All row versions are stored in the same table.
12
+
13
+ * Different row versions are accessed through scoping.
14
+
15
+ * Applications can use Mcfly to time-warp all tables to previous
16
+ points in time.
17
+
18
+ * Table queries for points in time are symmetric. i.e. queries to
19
+ access data in the present look just like queries available in any
20
+ particular point in time.
21
+
22
+ * Implemented as database triggers. So, the versioning system is
23
+ language/platform agnostic.
24
+
25
+ ## Installation
26
+
27
+ $ gem install mcfly
28
+
29
+ Or add it to your `Gemfile`, etc.
30
+
31
+ ## Usage
32
+
33
+ To create Mcfly enabled tables, they need to be created using
34
+ `McFly::McFlyMigration` or `McFly::McFlyAppendOnlyMigration` instead
35
+ of the usual `ActiveRecord::Migration`.
36
+
37
+ class CreateSecurityInstruments < McFlyAppendOnlyMigration
38
+ def change
39
+ create_table :security_instruments do |t|
40
+ t.string :name, null: false
41
+ t.string :settlement_class, limit: 1, null: false
42
+ end
43
+ end
44
+ end
45
+
46
+ class CreateMarketPrices < McFlyMigration
47
+ def change
48
+ create_table :market_prices do |t|
49
+ t.references :security_instrument, null: false
50
+ t.decimal :coupon, null: false
51
+ t.integer :settlement_mm, null: false
52
+ t.integer :settlement_yy, null: false
53
+ # NULL indicates unknown price
54
+ t.decimal :price
55
+ end
56
+ end
57
+ end
58
+
59
+ These migration add the necessary versioning triggers for INSERT,
60
+ UPDATE and DELETE operations. The append-only migration disallows
61
+ updates. As such, append-only Mcfly tables rows to be INSERTed or
62
+ DELETEed, but not modified.
63
+
64
+ When you declare `has_mcfly` in your model, Mcfly adds some basic
65
+ functionality to the class.
66
+
67
+ class SecurityInstrument < ActiveRecord::Base
68
+ has_mcfly append_only: true
69
+
70
+ attr_accessible :name, :settlement_class
71
+ validates_presence_of :name, :settlement_class
72
+ mcfly_validates_uniqueness_of :name
73
+
74
+ mcfly_lookup :lookup, sig: 2 do
75
+ |pt, name|
76
+ find_by_name(name)
77
+ end
78
+
79
+ mcfly_lookup :lookup_all, sig: 1 do
80
+ |pt| all
81
+ end
82
+ end
83
+
84
+ The `has_mcfly` declaration provides the `mcfly_lookup` generator
85
+ which is scopes queries to the proper timeline. Also,
86
+ `mcfly_validates_uniqueness_of` is Mcfly's scoped version of
87
+ ActiveRecord's `validates_uniqueness_of`.
88
+
89
+ ... TODO ... show examples of adding rows and accessing versions of
90
+ data ...
91
+
92
+ ## Implementation
93
+
94
+ TODO
95
+
96
+ ## Limitations
97
+
98
+ Currently, Mcfly only works with PostgreSQL databases.
99
+
100
+ ## History
101
+
102
+ The database table versioning mechanism used in Mcfly was originally
103
+ developed at [TWINSUN][]. It has since been modified and enhanced at
104
+ [PENNYMAC][].
105
+
106
+ ## License
107
+
108
+ Delorean has been released under the MIT license. Please check the
109
+ [LICENSE][] file for more details.
110
+
111
+ [license]: https://github.com/rubygems/rubygems.org/blob/master/MIT-LICENSE
112
+ [pennymac]: http://www.pennymacusa.com
113
+ [twinsun]: http://www.twinsun.com
data/Rakefile ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'Mcfly'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+
24
+
25
+
26
+ Bundler::GemHelper.install_tasks
27
+
@@ -0,0 +1,21 @@
1
+ module Mcfly
2
+ module Controller
3
+ def self.included(base)
4
+ base.before_filter :set_mcfly_whodunnit
5
+ end
6
+
7
+ # Returns the user who is responsible for any changes that occur.
8
+ # By default this calls `current_user` and returns the result.
9
+ #
10
+ # Override this method in your controller to call a different
11
+ # method, e.g. `current_person`, or anything you like.
12
+ def user_for_mcfly
13
+ current_user rescue nil
14
+ end
15
+
16
+ # Tells Mcfly who is responsible for any changes that occur.
17
+ def set_mcfly_whodunnit
18
+ ::Mcfly.whodunnit = user_for_mcfly
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ CREATE OR REPLACE FUNCTION "%{table}_delete" ()
2
+ RETURNS TRIGGER
3
+ AS $$
4
+
5
+ BEGIN
6
+ IF OLD.obsoleted_dt <> 'infinity' THEN
7
+ RAISE EXCEPTION 'can not delete old row version';
8
+ END IF;
9
+
10
+ UPDATE "%{table}" SET "obsoleted_dt" = 'now()' WHERE id = OLD.id;
11
+
12
+ RETURN NULL; -- the row is not actually deleted
13
+ END;
14
+ $$ LANGUAGE plpgsql;
15
+
16
+ DROP TRIGGER IF EXISTS %{table}_delete ON %{table};
17
+ CREATE TRIGGER "%{table}_delete" BEFORE DELETE ON "%{table}" FOR EACH ROW
18
+ EXECUTE PROCEDURE "%{table}_delete"();
@@ -0,0 +1,63 @@
1
+ require 'delorean_lang'
2
+
3
+ module McFly
4
+ module Model
5
+
6
+ def self.included(base)
7
+ base.send :extend, ClassMethods
8
+ end
9
+
10
+ module ClassMethods
11
+ def has_mcfly(options = {})
12
+ # FIXME: this methods gets a append_only option sometimes. It
13
+ # needs to add model level validations which prevent update
14
+ # when this option is present. Note that we need to allow
15
+ # delete. Deletion of McFly objects obsoletes them by setting
16
+ # obsoleted_dt.
17
+
18
+ send :include, InstanceMethods
19
+ after_initialize :record_init
20
+ # FIXME: :created_dt should also be readonly. However, we set
21
+ # it for debugging purposes. Should consider making this
22
+ # readonly once we're in production.
23
+ attr_readonly :group_id, :obsoleted_dt, :user_id
24
+ end
25
+
26
+ def mcfly_lookup(name, options = {}, &block)
27
+ delorean_fn(name, options) do |t, *args|
28
+ raise "time cannot be nil" if t.nil?
29
+ ts = (t == Float::INFINITY) ? 'infinity' : t
30
+ self.where("obsoleted_dt >= ? AND created_dt < ?", ts, ts).scoping do
31
+ block.call(t, *args)
32
+ end
33
+ end
34
+ end
35
+
36
+ def mcfly_validates_uniqueness_of(*attr_names)
37
+ # add :obsoleted_dt to the uniqueness scope
38
+
39
+ attr_names << {} unless attr_names.last.is_a?(Hash)
40
+
41
+ attr_names.last[:scope] ||= []
42
+ attr_names.last[:scope] << :obsoleted_dt
43
+
44
+ # Set uniqueness error message if not set. FIXME: need to
45
+ # figure out how to change the base message. It still
46
+ # prepends the pluralized main attr.
47
+ attr_names.last[:message] ||= "- record must be unique"
48
+
49
+ validates_uniqueness_of(*attr_names)
50
+ end
51
+ end
52
+
53
+ module InstanceMethods
54
+ def record_init
55
+ # Set obsoleted_dt to non NIL to ensure constraints are properly
56
+ # constructed
57
+ self.obsoleted_dt = 'infinity' unless self.obsoleted_dt
58
+ self.user_id ||= Mcfly.whodunnit.try(:id)
59
+ end
60
+ end
61
+
62
+ end
63
+ end
@@ -0,0 +1,27 @@
1
+ CREATE OR REPLACE FUNCTION "%{table}_insert" ()
2
+ RETURNS TRIGGER
3
+ AS $$
4
+ BEGIN
5
+ -- "obsoleted_dt" is set when a history row is created by
6
+ -- UPDATE. Leave it alone.
7
+ IF NEW.obsoleted_dt <> 'infinity' THEN
8
+ RETURN NEW;
9
+ END IF;
10
+
11
+ NEW.obsoleted_dt = 'infinity';
12
+ NEW.group_id = NEW.id;
13
+
14
+ -- FIXME: Handle cases where created_dt is sent in on creation. This
15
+ -- is only useful for debugging. Consider removing the surronding
16
+ -- IF for production version.
17
+ IF NEW.created_dt IS NULL THEN
18
+ NEW.created_dt = 'now()';
19
+ END IF;
20
+
21
+ RETURN NEW;
22
+ END;
23
+ $$ LANGUAGE plpgsql;
24
+
25
+ DROP TRIGGER IF EXISTS %{table}_insert ON %{table};
26
+ CREATE TRIGGER "%{table}_insert" BEFORE INSERT ON "%{table}" FOR EACH ROW
27
+ EXECUTE PROCEDURE "%{table}_insert"();
@@ -0,0 +1,27 @@
1
+ class McFlyMigration < ActiveRecord::Migration
2
+ INSERT_TRIG, UPDATE_TRIG, UPDATE_APPEND_ONLY_TRIG, DELETE_TRIG =
3
+ %w{insert_trig update_trig update_append_only_trig delete_trig}.map { |f|
4
+ File.read(File.dirname(__FILE__) + "/#{f}.sql")
5
+ }
6
+
7
+ TRIGS = [INSERT_TRIG, UPDATE_TRIG, DELETE_TRIG]
8
+
9
+ def create_table(table_name, options = {}, &block)
10
+ super { |t|
11
+ t.integer :group_id, null: false
12
+ # can't use created_at/updated_at as those are automatically
13
+ # filled by ActiveRecord.
14
+ t.timestamp :created_dt, null: false
15
+ t.timestamp :obsoleted_dt, null: false
16
+ t.references :user, null: false
17
+ block.call(t)
18
+ }
19
+
20
+ self.class::TRIGS.each {|sql| execute sql % {table: table_name}}
21
+ end
22
+ end
23
+
24
+ class McFlyAppendOnlyMigration < McFlyMigration
25
+ # append-only update trigger disallows updates
26
+ TRIGS = [INSERT_TRIG, UPDATE_APPEND_ONLY_TRIG, DELETE_TRIG]
27
+ end
@@ -0,0 +1,25 @@
1
+ CREATE OR REPLACE FUNCTION "%{table}_update" ()
2
+ RETURNS TRIGGER
3
+ AS $$
4
+ DECLARE
5
+
6
+ BEGIN
7
+ IF OLD.obsoleted_dt <> 'infinity' THEN
8
+ RAISE EXCEPTION 'can not update obsoleted append-only row';
9
+ END IF;
10
+
11
+ -- If obsoleted_dt is being set, assume that the row is being
12
+ -- obsoleted. We return the OLD row so that other field updates are
13
+ -- ignored. This is used by DELETE.
14
+ IF NEW.obsoleted_dt <> 'infinity' THEN
15
+ OLD.obsoleted_dt = NEW.obsoleted_dt;
16
+ return OLD;
17
+ END IF;
18
+
19
+ RAISE EXCEPTION 'can not update append-only row';
20
+ END;
21
+ $$ LANGUAGE plpgsql;
22
+
23
+ DROP TRIGGER IF EXISTS %{table}_update ON %{table};
24
+ CREATE TRIGGER "%{table}_update" BEFORE UPDATE ON "%{table}" FOR EACH ROW
25
+ EXECUTE PROCEDURE "%{table}_update"();
@@ -0,0 +1,62 @@
1
+ CREATE OR REPLACE FUNCTION "%{table}_update" ()
2
+ RETURNS TRIGGER
3
+ AS $$
4
+ DECLARE
5
+ rec "%{table}";
6
+ new_id INT4;
7
+ now timestamp;
8
+
9
+ BEGIN
10
+ IF OLD.obsoleted_dt <> 'infinity' THEN
11
+ RAISE EXCEPTION 'can not modify old row version';
12
+ END IF;
13
+
14
+ -- If obsoleted_dt is being set, assume that the row is being
15
+ -- obsoleted. We return the OLD row so that other field updates are
16
+ -- ignored. This is used by DELETE.
17
+ IF NEW.obsoleted_dt <> 'infinity' THEN
18
+ OLD.obsoleted_dt = NEW.obsoleted_dt;
19
+ return OLD;
20
+ END IF;
21
+
22
+ -- copy old version of the row into rec
23
+ SELECT INTO rec * FROM "%{table}" WHERE "id" = NEW.id;
24
+
25
+ -- new_id is a new primary key that we'll use for the obsoleted row.
26
+ SELECT nextval('"%{table}_id_seq"') INTO new_id;
27
+
28
+ -- not sure if PGSQL will return the same value for now() in the
29
+ -- same transaction. So, use the same variable to be sure.
30
+ now = 'now()';
31
+
32
+ rec.id = new_id;
33
+ rec.group_id = NEW.id;
34
+
35
+ -- FIXME: The following IF/ELSE handles cases where created_dt is
36
+ -- sent in on update. This is only useful for debugging. Consider
37
+ -- removing the surronding IF (and ELSE part) for production
38
+ -- version.
39
+ IF NEW.created_dt = OLD.created_dt THEN
40
+ -- Set the modified row's created_dt. The obsoleted_dt field was
41
+ -- already infinity, so we don't need to set it.
42
+ NEW.created_dt = now;
43
+ rec.obsoleted_dt = now;
44
+ ELSE
45
+ IF NEW.created_dt <= OLD.created_dt THEN
46
+ RAISE EXCEPTION 'new created_dt must be greater than old';
47
+ END IF;
48
+
49
+ rec.obsoleted_dt = NEW.created_dt;
50
+ END IF;
51
+
52
+ -- insert rec, note that the insert trigger will get called. The
53
+ -- obsoleted_dt is set so INSERT should not do anything with this row.
54
+ INSERT INTO "%{table}" VALUES (rec.*);
55
+
56
+ RETURN NEW;
57
+ END;
58
+ $$ LANGUAGE plpgsql;
59
+
60
+ DROP TRIGGER IF EXISTS %{table}_update ON %{table};
61
+ CREATE TRIGGER "%{table}_update" BEFORE UPDATE ON "%{table}" FOR EACH ROW
62
+ EXECUTE PROCEDURE "%{table}_update"();
@@ -0,0 +1,3 @@
1
+ module Mcfly
2
+ VERSION = "0.0.1"
3
+ end
data/lib/mcfly.rb ADDED
@@ -0,0 +1,36 @@
1
+ require 'mcfly/migration'
2
+ require 'mcfly/has_mcfly'
3
+ require 'mcfly/controller'
4
+ require 'active_support'
5
+
6
+ module Mcfly
7
+ # ATTRIBUTION: some of the code in this project has been shamelessly
8
+ # lifted form paper_trail.
9
+
10
+ # Sets who is responsible for any changes that occur. You would
11
+ # normally use this in a migration or on the console, when working
12
+ # with models directly.
13
+ def self.whodunnit=(value)
14
+ mcfly_store[:whodunnit] = value
15
+ end
16
+
17
+ def self.whodunnit
18
+ mcfly_store[:whodunnit]
19
+ end
20
+
21
+ private
22
+
23
+ # Thread-safe hash to hold Mcfly's data.
24
+ def self.mcfly_store
25
+ Thread.current[:mcfly] ||= {}
26
+ end
27
+ end
28
+
29
+ ActiveSupport.on_load(:active_record) do
30
+ include Delorean::Model
31
+ include McFly::Model
32
+ end
33
+
34
+ ActiveSupport.on_load(:action_controller) do
35
+ include Mcfly::Controller
36
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :mcfly do
3
+ # # Task goes here
4
+ # end
data/mcfly.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+
3
+ require "mcfly/version"
4
+
5
+ # Describe your gem and declare its dependencies:
6
+ Gem::Specification.new do |s|
7
+ s.name = "mcfly"
8
+ s.version = Mcfly::VERSION
9
+ s.authors = ["Arman Bostani"]
10
+ s.email = ["arman.bostani@pnmac.com"]
11
+ s.homepage = "https://github.com/arman000/mcfly"
12
+ s.summary = %q{A database table versioning system.}
13
+ s.description = s.summary
14
+ s.files = `git ls-files`.split($\)
15
+
16
+ s.require_paths = ["lib"]
17
+
18
+ s.add_dependency "rails", "~> 3.2.11"
19
+ s.add_dependency "pg"
20
+
21
+ # FIXME: Delorean is added here for historical reasons. It should
22
+ # be removed as a dependency.
23
+ s.add_dependency "delorean_lang"
24
+
25
+ s.add_development_dependency "rspec"
26
+ s.add_development_dependency "rspec-rails"
27
+ end
data/spec/dummy/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color