joshnabbott-active-record-versionable 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
@@ -0,0 +1,5 @@
1
+ *.sw?
2
+ .DS_Store
3
+ coverage
4
+ rdoc
5
+ pkg
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Josh N. Abbott
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.
@@ -0,0 +1,168 @@
1
+ = ActiveRecord Versionable
2
+ ==== Version control ActiveRecord model data using Git
3
+
4
+ === WTF?
5
+
6
+ Versionable is sort of a prototype gem I wrote to version control ActiveRecord model data using Git.
7
+
8
+ About a year and a half ago I wrote the 'acts_as_loggable' (http://github.com/joshnabbott/acts_as_loggable) plugin which would basically create a row in the database each time a record was saved or edited with the currently logged in user's id and a time-stamp. While I had planned on adding features to store the changes using ActiveRecord's dirty objects, I never got around to doing it (if you would like to fork the project and add that functionality - you're more than welcome to! Let me know so I can apply the feature.)
9
+
10
+ So a year and a half later we've been given some requirements to build a reporting platform that would allow the business to query on all sorts of data as it was during a certain period of time. For example: generate a report of all products that we were selling for < $100 between March 1, 2005 - Present. Or a report that reflects how many products were active on the site between January 1, 2008 - December 31, 2008.
11
+
12
+ After thinking about these requirements a bit I really thought it sounded more like we needed a way to "version control" our model data. Effectively taking snapshots of data each time it is modified so we would:
13
+ * Have a record of what was changed, how it was changed, and when it was changed
14
+ * Be able to query across certain time frames and see the data reflected as it was during those time frames
15
+ * Have the ability to instantiate an instance of the object from any snapshot so the object would be exactly what it was at that point in time.
16
+
17
+ Among other things, all of those combined really added up to writing an SCM but for model data. And since I'm, well, lazy, I got to thinking: why spend months developing some sort of platform that allows us to use a database to store "snapshots" of model data, writing a bunch of utility methods that allow us to query quickly and accurately across all the data, and then actually finding the time to code the damn thing when I could just use Git?
18
+
19
+ Obviously, I decided that was dumb and went for the easy way out.
20
+
21
+ === Enter Versionable
22
+
23
+ Enter Versionable. It's a Ruby gem that basically uses `after_save` and `after_destroy` hooks to convert the object to yaml, write it to a file, then make a commit with the latest version of the object all with a very clear and concise commit message.
24
+
25
+ By default Versionable files are stored in RAILS_ROOT + /versions, but you can specify another directory. See that below in Examples.
26
+
27
+ Within the /versions directory (or whatever you end up naming your versions directory), a directory is created for each model that is Versionable. For example, if you have two models using Versionable named Article and User, you would see something like this in the versioning directory:
28
+ |-- versions
29
+ |-- articles
30
+ | |-- articles.yml
31
+ | |-- article-1.yml
32
+ | |-- article-2.yml
33
+ | `-- article-3.yml
34
+ `-- users
35
+ |-- users.yml
36
+ |-- user-1.yml
37
+ |-- user-2.yml
38
+ |-- user-3.yml
39
+ `-- user-4.yml
40
+
41
+ As you may have guessed, the &lt;plural-name&gt;.yml is the most recent snapshot of each model record in yaml format. Think of it sort of like the `index` action. I'm honestly not 100% sure what this would be used for, but it seemed to make sense at the time I was writing the gem. An example of what you would see in this file is:
42
+ ---
43
+ - !ruby/object:Person
44
+ attributes:
45
+ updated_at: 2009-09-23 20:57:51
46
+ birthdate: "1978-01-17"
47
+ id: "4"
48
+ is_living: "0"
49
+ age: "31"
50
+ name_first: Joshua
51
+ name_last: Abbott
52
+ created_at: 2009-09-23 04:15:01
53
+ attributes_cache: {}
54
+
55
+ - !ruby/object:Person
56
+ attributes:
57
+ updated_at: 2009-09-23 04:35:02
58
+ birthdate: "1982-05-12"
59
+ id: "5"
60
+ is_living: "1"
61
+ age: "27"
62
+ name_first: Amber
63
+ name_last: Jarvis
64
+ created_at: 2009-09-23 04:35:02
65
+ attributes_cache: {}
66
+
67
+ - !ruby/object:Person
68
+ attributes:
69
+ updated_at: 2009-09-23 06:14:22
70
+ birthdate: "2002-05-12"
71
+ id: "5"
72
+ is_living: "1"
73
+ age: "6"
74
+ name_first: Hayden
75
+ name_last: Roberts
76
+ created_at: 2009-09-23 06:14:22
77
+ attributes_cache: {}
78
+
79
+ The &lt;singular_name-id&gt;.yml is the yaml representation of the current model object. An example of what you would find here is:
80
+ --- &id001 !ruby/object:Person
81
+ attributes:
82
+ updated_at: 2009-09-23 20:57:51.206652 Z
83
+ birthdate: 2009-01-17
84
+ id: "4"
85
+ is_living: "0"
86
+ age: "31"
87
+ name_first: Joshua
88
+ name_last: Abbott
89
+ created_at: 2009-09-23 04:15:01
90
+ attributes_cache: {}
91
+
92
+ changed_attributes:
93
+ updated_at: 2009-09-23 20:13:51 Z
94
+ is_living: true
95
+ errors: !ruby/object:ActiveRecord::Errors
96
+ base: *id001
97
+ errors: {}
98
+
99
+ I chose to store data in YAML format because of how easy it is to use across many different programming language and platform (not the least of which is Ruby). YAML is dirt simple to read and edit and makes it easy to instantiate new ActiveRecord objects by simply loading it using `YAML::load`.
100
+
101
+ === Dependencies
102
+
103
+ * Git (obviously) - http://git-scm.com/
104
+
105
+ === Installation
106
+
107
+ * sudo gem install joshnabbott-active_record_versionable
108
+ * "Versionify" your model:
109
+ class User < ActiveRecord::Base
110
+ versionify
111
+ end
112
+
113
+ * When the model is first loaded, Versionable checks to make sure the version directories are there and that a Git repository has been initialized in the versioning directory. If directories are missing they will be created, and if there is no Git repository in the versioning directory, one will be created.
114
+ * Don't forget to .gitignore the versioning directory unless you it and its contents being tracked by your application's SCM. It's easy to do - just type `mate .gitignore` from the root of the app and add:
115
+ versions/*
116
+ * Make sure to add directory that you specified to store the Versionable versions if you're not using the default.
117
+
118
+ That's it! Now you've got a versioning directory and the next time you add/edit/delete a record, you will see the version files. Versionable automatically makes your commits so you don't have to worry about a thing. In fact, unless you are dinking with the version files, you should be able to `cd` into the versioning directory at any given time, run `git status` and you should always see:
119
+ # On branch master
120
+ nothing to commit (working directory clean)
121
+
122
+ === So how about actually accessing these old 'versions' of my model data?
123
+
124
+ Since the versioning directory is a separate Git repository from the one that you're using for your app (assuming you did in fact .gitignore the versioning directory), you can run any of the regular Git commands from within this directory and see your versioning data. I haven't written any additional functionality beyond just writing the model data to a file and committing it. Honestly, I don't need it yet. I know my way around Git enough that I am more than happy to just use it to play with my version controlled model data.
125
+
126
+ === Good methods to know about
127
+
128
+ Custom versioning directory (please keep in mind that RAILS_ROOT is appended to this directory):
129
+ class Article
130
+ versionify :version_directory => 'tmp/versions'
131
+ end
132
+
133
+ You can ask the class what it's version directory name is, or even get the path:
134
+ Article.version_dir #=> "/path/to/rails_app/tmp/versions"
135
+
136
+ Article.version_dir_name #=> versions
137
+
138
+ You can also ask your ActiveRecord object for its version file like so:
139
+ Article.create.version_file #=> "/path/to/rails_app/tmp/versions/articles/article-4.yml"
140
+
141
+ === Some cool examples
142
+
143
+ Let's say that for whatever reason you would like to instantiate an Article object from several revisions ago (let's just say you wan to do that).
144
+
145
+ From command line:
146
+ cd /path/to/rails_app/tmp/versions/articles/
147
+ git checkout 123456 articles/article-4.yml
148
+
149
+ From console:
150
+ article = Article.find(4) #=> ActiveRecord object
151
+ revision_file = article.version_file #=> "/path/to/rails_app/tmp/versions/articles/article-4.yml"
152
+ old_article = YAML::load(File.open(revision_file)) #=> ActiveRecord object from older version
153
+
154
+ Viola! It lives. Loading an older version of the record makes it easy to see how things have changed, or what the state of a record was during a certain time.
155
+
156
+ === Parting thoughts
157
+
158
+ So I realize this may or may not be the best way to write an SCM for ActiveRecord data, but for whatever reason I really wanted to do it just because I thought it would be a blast. And it has been. I'm really happy with how it's turned out so far. I haven't really got to use it in a real-life scenario yet, but I'm planning on implementing it in the next few days. It could end up turning out that this is just a total abuse of what Git should be. There are bound to be some downsides of version controlling model data this way. For one: Versionable does make heavy use of executing shell commands (for making commits and whatnot), not to mention the sheer size the Versionable repository could end up being. That really depends on how many models are being versioned, and how often data changes.
159
+
160
+ It goes without saying knowing Git is sort of a pre-requisite for digging around in the revisions. Luckily, there are tons and tons of great resources on the internet that can help you become the Git ninja we all want to be. I highly recommend learning the hell out of `git log` and `git diff` if you really want to do some impressive stuff.
161
+
162
+ == Contributing
163
+
164
+ I'd love all the help I can get on Versionable. If you have any really cool ideas, please feel free to fork the source and let me know when you have a feature you'd like me to check out!
165
+
166
+ == License
167
+
168
+ Copyright (c) 2009 Josh N. Abbott (http://iammrjoshua.com), released under the MIT license
@@ -0,0 +1,55 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "active-record-versionable"
8
+ gem.summary = %Q{Version control ActiveRecord model data}
9
+ gem.description = %Q{Makes version controlling ActiveRecord model data easy and powerful.}
10
+ gem.email = "joshnabbott@gmail.com"
11
+ gem.homepage = "http://github.com/joshnabbott/active-record-versionable"
12
+ gem.authors = ["Josh N. Abbott"]
13
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
14
+ end
15
+ rescue LoadError
16
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
17
+ end
18
+
19
+ require 'rake/testtask'
20
+ Rake::TestTask.new(:test) do |test|
21
+ test.libs << 'lib' << 'test'
22
+ test.pattern = 'test/**/*_test.rb'
23
+ test.verbose = true
24
+ end
25
+
26
+ begin
27
+ require 'rcov/rcovtask'
28
+ Rcov::RcovTask.new do |test|
29
+ test.libs << 'test'
30
+ test.pattern = 'test/**/*_test.rb'
31
+ test.verbose = true
32
+ end
33
+ rescue LoadError
34
+ task :rcov do
35
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
36
+ end
37
+ end
38
+
39
+ task :test => :check_dependencies
40
+
41
+ task :default => :test
42
+
43
+ require 'rake/rdoctask'
44
+ Rake::RDocTask.new do |rdoc|
45
+ if File.exist?('VERSION')
46
+ version = File.read('VERSION')
47
+ else
48
+ version = ""
49
+ end
50
+
51
+ rdoc.rdoc_dir = 'rdoc'
52
+ rdoc.title = "active-record-versionable #{version}"
53
+ rdoc.rdoc_files.include('README*')
54
+ rdoc.rdoc_files.include('lib/**/*.rb')
55
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.1
@@ -0,0 +1,52 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec`
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{active-record-versionable}
8
+ s.version = "0.1.1"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Josh N. Abbott"]
12
+ s.date = %q{2009-09-23}
13
+ s.description = %q{Makes version controlling ActiveRecord model data easy and powerful.}
14
+ s.email = %q{joshnabbott@gmail.com}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE",
17
+ "README.rdoc"
18
+ ]
19
+ s.files = [
20
+ ".document",
21
+ ".gitignore",
22
+ "LICENSE",
23
+ "README.rdoc",
24
+ "Rakefile",
25
+ "VERSION",
26
+ "active-record-versionable.gemspec",
27
+ "init.rb",
28
+ "lib/active_record_versionable.rb",
29
+ "rails/init.rb",
30
+ "test/active_record_versionable_test.rb",
31
+ "test/test_helper.rb"
32
+ ]
33
+ s.homepage = %q{http://github.com/joshnabbott/active-record-versionable}
34
+ s.rdoc_options = ["--charset=UTF-8"]
35
+ s.require_paths = ["lib"]
36
+ s.rubygems_version = %q{1.3.5}
37
+ s.summary = %q{Version control ActiveRecord model data}
38
+ s.test_files = [
39
+ "test/active_record_versionable_test.rb",
40
+ "test/test_helper.rb"
41
+ ]
42
+
43
+ if s.respond_to? :specification_version then
44
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
45
+ s.specification_version = 3
46
+
47
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
48
+ else
49
+ end
50
+ else
51
+ end
52
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'rails/init'
@@ -0,0 +1,140 @@
1
+ module ActiveRecordVersionable
2
+ def self.included(base)
3
+ base.extend(ClassMethods)
4
+ end
5
+
6
+ module ClassMethods
7
+ def versionify(options = {})
8
+ options.reverse_merge!(:version_directory => "#{RAILS_ROOT}/versions")
9
+ setup!(options)
10
+ send(:include, ActiveRecord::Versionable::InstanceMethods)
11
+ after_destroy :version!
12
+ after_save :version!
13
+
14
+ instance_eval do
15
+ def commit!(message = Time.now.to_s)
16
+ response = `cd #{self.version_dir} && git add . && git commit -am '#{message}'`
17
+
18
+ logger.debug("#### START VERSIONING ####")
19
+ logger.debug(response)
20
+ # logger.debug $?
21
+ logger.debug("#### END VERSIONING ####")
22
+ end
23
+
24
+ def version
25
+ File.open(self.version_file, 'w+') { |file| file.write(self.all.to_yaml) }
26
+ end
27
+
28
+ def version_dir
29
+ @@version_dir
30
+ end
31
+
32
+ def version_dir=(directory)
33
+ @@version_dir = directory
34
+ end
35
+
36
+ def version_dir_name
37
+ @@version_dir_name
38
+ end
39
+
40
+ def version_dir_name=(directory_name)
41
+ @@version_dir_name = directory_name
42
+ end
43
+
44
+ def version_file
45
+ File.join(self.version_dir, self.class_name.tableize, self.version_file_name)
46
+ end
47
+
48
+ def version_file_name
49
+ "#{self.class_name.tableize}.yml"
50
+ end
51
+
52
+ def unversion!
53
+ File.delete(version_file)
54
+ self.all.map(&:unversion!)
55
+ self.commit!("No longer version controlling #{self.class_name}.")
56
+ end
57
+
58
+ private
59
+ def create_version_directories!
60
+ Dir.mkdir(self.version_dir) unless File.exists?(self.version_dir)
61
+ Dir.mkdir(File.join(self.version_dir, self.class_name.tableize)) unless File.exists?(File.join(self.version_dir, self.class_name.tableize))
62
+ end
63
+
64
+ def initialize_git_repository!
65
+ unless File.exists?(File.join(self.version_dir, '.git'))
66
+ response = `cd #{self.version_dir} && git init`
67
+
68
+ logger.debug("#### START INITIALIZING GIT REPOSITORY ####")
69
+ logger.debug(response)
70
+ # logger.debug $?
71
+ logger.debug("#### END INITIALIZING GIT REPOSITORY ####")
72
+ end
73
+ end
74
+
75
+ def setup_complete?
76
+ File.exists?(self.version_dir) &&
77
+ File.exists?(File.join(self.version_dir, self.class_name.tableize)) &&
78
+ File.exists?(File.join(self.version_dir, '.git'))
79
+ end
80
+
81
+ # Create the necessary directories and files, along with initializing a new git repo when needed
82
+ def setup!(options)
83
+ self.version_dir = options[:version_directory]
84
+ self.version_dir_name = options[:version_directory].split('/').last
85
+ unless setup_complete?
86
+ create_version_directories!
87
+ initialize_git_repository!
88
+ STDOUT.puts "***************** IMPORTANT *****************"
89
+ STDOUT.puts "Versionable has created a new direcotry in #{self.version_dir} where files will be written to and stored."
90
+ STDOUT.puts "Since you may not want Git tracking the files Versionable creates, you may want to add #{self.version_dir_name}/* to your .gitignore file."
91
+ STDOUT.puts "If you don't already have a .gitignore file in #{RAILS_ROOT} (#{File.exists?(File.join(RAILS_ROOT, '.gitignore')) ? "and you do" : "which you don't"}) you may want to create one now."
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ module InstanceMethods
99
+ def version!
100
+ self.class.version
101
+ self.version
102
+ self.class.commit!(calculate_commit_message)
103
+ end
104
+
105
+ def version
106
+ File.open(self.version_file, 'w+') { |file| file.write(self.to_yaml) }
107
+ end
108
+
109
+ def version_file
110
+ File.join(self.class.version_dir, self.class.class_name.tableize, version_file_name)
111
+ end
112
+
113
+ def version_file_name
114
+ "#{self.class.class_name.downcase.underscore}-#{self.id}.yml"
115
+ end
116
+
117
+ def unversion!
118
+ File.delete(version_file)
119
+ end
120
+
121
+ protected
122
+ def calculate_commit_message
123
+ # Using after_save and after_destroy means there are three possibilities:
124
+ # The record was either inserted, updated, or deleted from the db.
125
+ # We can find out if it was deleted by simply seeing if the record still exists in the db.
126
+ # However, finding out whether the record was inserted or updated is trickier since Class#new_record? returns
127
+ # false after save.
128
+ # The best thing I came up with was checking to see if the instance variable @new_record is still hanging around for this instance
129
+ # If it is, that means the record was just created. If it's not, it was updated.
130
+ action = if !self.instance_variable_get("@new_record").nil?
131
+ 'Created'
132
+ elsif !self.class.exists?(self.id)
133
+ 'Deleted'
134
+ else
135
+ 'Updated'
136
+ end
137
+ "#{action} #{self.class.class_name}[#{self.id}]: #{Time.now.to_s(:db)}"
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,2 @@
1
+ require 'active_record_versionable'
2
+ ActiveRecord::Base.instance_eval { include ActiveRecordVersionable }
@@ -0,0 +1,7 @@
1
+ require 'test_helper'
2
+
3
+ class ActiveRecordVersionableTest < Test::Unit::TestCase
4
+ should "probably rename this file and start testing for real" do
5
+ flunk "hey buddy, you should probably rename this file and start testing for real"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
7
+ require 'active_record_versionable'
8
+
9
+ class Test::Unit::TestCase
10
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: joshnabbott-active-record-versionable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Josh N. Abbott
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-09-23 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Makes version controlling ActiveRecord model data easy and powerful.
17
+ email: joshnabbott@gmail.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - LICENSE
24
+ - README.rdoc
25
+ files:
26
+ - .document
27
+ - .gitignore
28
+ - LICENSE
29
+ - README.rdoc
30
+ - Rakefile
31
+ - VERSION
32
+ - active-record-versionable.gemspec
33
+ - init.rb
34
+ - lib/active_record_versionable.rb
35
+ - rails/init.rb
36
+ - test/active_record_versionable_test.rb
37
+ - test/test_helper.rb
38
+ has_rdoc: false
39
+ homepage: http://github.com/joshnabbott/active-record-versionable
40
+ licenses:
41
+ post_install_message:
42
+ rdoc_options:
43
+ - --charset=UTF-8
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: "0"
51
+ version:
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: "0"
57
+ version:
58
+ requirements: []
59
+
60
+ rubyforge_project:
61
+ rubygems_version: 1.3.5
62
+ signing_key:
63
+ specification_version: 3
64
+ summary: Version control ActiveRecord model data
65
+ test_files:
66
+ - test/active_record_versionable_test.rb
67
+ - test/test_helper.rb