mcfly 0.0.1

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