From da6f07ff537b96da0bbc99423a64770bd9de2003 Mon Sep 17 00:00:00 2001 From: Paul Dowman Date: Tue, 9 Nov 2010 16:22:56 -0500 Subject: [PATCH] Pushing to GitHub, squash all previous messy commits. --- .document | 5 + .gitignore | 7 + .rspec | 1 + .rvmrc | 1 + Gemfile | 5 + Gemfile.lock | 54 ++++ LICENSE | 20 ++ README.md | 109 ++++++++ Rakefile | 3 + autotest/discover.rb | 1 + gitmodel.gemspec | 36 +++ lib/gitmodel.rb | 76 ++++++ lib/gitmodel/errors.rb | 25 ++ lib/gitmodel/persistable.rb | 269 ++++++++++++++++++ lib/gitmodel/transaction.rb | 62 +++++ spec/gitmodel/persistable_spec.rb | 436 ++++++++++++++++++++++++++++++ spec/gitmodel/transaction_spec.rb | 59 ++++ spec/gitmodel_spec.rb | 39 +++ spec/spec_helper.rb | 12 + spec/support/setup.rb | 10 + 20 files changed, 1230 insertions(+) create mode 100644 .document create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rvmrc create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 autotest/discover.rb create mode 100644 gitmodel.gemspec create mode 100644 lib/gitmodel.rb create mode 100644 lib/gitmodel/errors.rb create mode 100644 lib/gitmodel/persistable.rb create mode 100644 lib/gitmodel/transaction.rb create mode 100644 spec/gitmodel/persistable_spec.rb create mode 100644 spec/gitmodel/transaction_spec.rb create mode 100644 spec/gitmodel_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/setup.rb diff --git a/.document b/.document new file mode 100644 index 0000000..ecf3673 --- /dev/null +++ b/.document @@ -0,0 +1,5 @@ +README.rdoc +lib/**/*.rb +bin/* +features/**/*.feature +LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..516e4b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.gem +.DS_Store +.bundle +.gitmodel-data +coverage +pkg +rdoc diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..53607ea --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--colour diff --git a/.rvmrc b/.rvmrc new file mode 100644 index 0000000..156f533 --- /dev/null +++ b/.rvmrc @@ -0,0 +1 @@ +rvm use ruby-1.9.2@gitmodel diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..1e2e256 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# Edit this Gemfile to bundle your application's dependencies. +source :rubygems + +gemspec + diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..a87a6df --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,54 @@ +PATH + remote: . + specs: + gitmodel (0.0.0) + activemodel (>= 3.0.1) + activesupport (>= 3.0.1) + grit (>= 2.3.0) + lockfile (>= 1.4.3) + +GEM + remote: http://rubygems.org/ + specs: + ZenTest (4.4.0) + activemodel (3.0.1) + activesupport (= 3.0.1) + builder (~> 2.1.2) + i18n (~> 0.4.1) + activesupport (3.0.1) + autotest (4.4.1) + autotest-fsevent (0.2.3) + sys-uname + builder (2.1.2) + diff-lcs (1.1.2) + grit (2.3.0) + diff-lcs (~> 1.1) + mime-types (~> 1.15) + i18n (0.4.1) + lockfile (1.4.3) + mime-types (1.16) + rspec (2.0.1) + rspec-core (~> 2.0.1) + rspec-expectations (~> 2.0.1) + rspec-mocks (~> 2.0.1) + rspec-core (2.0.1) + rspec-expectations (2.0.1) + diff-lcs (>= 1.1.2) + rspec-mocks (2.0.1) + rspec-core (~> 2.0.1) + rspec-expectations (~> 2.0.1) + sys-uname (0.8.4) + +PLATFORMS + ruby + +DEPENDENCIES + ZenTest (>= 4.4.0) + activemodel (>= 3.0.1) + activesupport (>= 3.0.1) + autotest (>= 4.4.1) + autotest-fsevent (>= 0.2.3) + gitmodel! + grit (>= 2.3.0) + lockfile (>= 1.4.3) + rspec (>= 2.0.1) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1cead7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2009 Paul Dowman + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18ee44f --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +GitModel: distributed, versioned NoSQL for Ruby +--------------------------------------------------- + +GitModel is an +[ActiveModel](http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/)-compliant +persistence framework for Ruby that uses [Git](http://git-scm.com/) for +versioning and remote syncing. + +GitModel persists Ruby objects using Git as a data storage engine. It's an +ActiveModel implementation so it works stand-alone or in Rails 3 as a drop-in +replacement for ActiveRecord or DataMapper. + +Because the database is a Git repository it can be synced across multiple +machines, manipulated with standard Git client tools, can be branched and +merged, and of course keeps the history of all changes. + + +Status +------ + +_It is nowhere near production ready but I'm working on it. Please feel free to +contribute tests and/or code to help!_ + + +Why it's awesome +---------------- + +* Schema-less NoSQL data store +* Each record is a normal Ruby object, attributes are any Ruby type or large + chunks of binary data +* Never lose data, history is kept forever and can be restored simply using + standard Git tools +* Branch and merge your production data + * GitModel can actually work with different branches + * Branch or tag snapshots of your data + * Experiment on production data using branches, for example to test a + migration +* Distributed (synced using standard Git push/pull) +* Transactions +* Metadata for all database changes (Git commit messages, date & time, etc.) +* The database is simply files and directores stored in a Git repository. + GitModel uses the Git repo directly (rather than Git's checked-out "working + copy") but you can do a "git checkout" to view and manipulate the database + contents, and then "git commit" +* Test-driven development and excellent test coverage +* Clean and easy-to-use API + + +Database file structure +----------------------- + +Each type of object is stored in a top-level directory (this is analogous to +ActiveRecord tables), and each object is stored in a subdirectory which is +named using the object's id (i.e. the primary key). Attributes that are Ruby +types (strings, numbers, hashes, arrays, whatever) are stored in a file named +attributes.json and large binary attributes ("blobs") are stored in their own +files. + +For example, a database for a blogging app with three Post objects and five +Comment objects might have a directory structure that looks like this: + +* db-root + * comments + * 2010-01-03-328 + * _attributes.json_ + * 2010-05-29-742 + * _attributes.json_ + * 2010-10-09-934 + * _attributes.json_ + * 2010-10-12-132 + * _attributes.json_ + * 2010-10-12-665 + * _attributes.json_ + * posts + * hotdog-eating-contest + * _attributes.json_ + * _hotdogs.jpg_ + * _the-aftermath.jpg_ + * lessons-learned + * _attributes.json_ + * _summary.xls_ + * running-with-scissors + * _attributes.json_ + * _oops.jpg_ + * _speedy.jpg_ + +In the above example _attributes.json_ holds the attributes which are +represented by Ruby types, and binary data "blobs" are stored in files. + +To Do +----- + +* Querying + * Use AREL? +* Transactions + * allow blocks to execute within a transaction so multiple changes occur in + one Git commit +* Finish some pending specs +* Associations +* API documentation +* Rails integration + * rake tasks + * generators +* Performance + * Haven't optimized for performance yet. + * Some places where we do blatently stupid things have been marked with + PERFORMANCE comments. + + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..e20b37a --- /dev/null +++ b/Rakefile @@ -0,0 +1,3 @@ +require 'bundler' +Bundler::GemHelper.install_tasks + diff --git a/autotest/discover.rb b/autotest/discover.rb new file mode 100644 index 0000000..cd6892c --- /dev/null +++ b/autotest/discover.rb @@ -0,0 +1 @@ +Autotest.add_discovery { "rspec2" } diff --git a/gitmodel.gemspec b/gitmodel.gemspec new file mode 100644 index 0000000..3aeca94 --- /dev/null +++ b/gitmodel.gemspec @@ -0,0 +1,36 @@ +Gem::Specification.new do |s| + s.name = 'gitmodel' + s.version = '0.0.0' + s.platform = Gem::Platform::RUBY + + s.authors = ["Paul Dowman"] + s.email = 'paul@pauldowman.com' + s.homepage = 'http://github.com/pauldowman/gitmodel' + + s.summary = %q{An ActiveModel-compliant persistence framework for Ruby that uses Git for versioning and remote syncing.} + s.description = <<-DESC.strip.gsub(/\n\s+/, " ") + GitModel persists Ruby objects using Git as a data storage engine. It's an + ActiveModel implementation so it works stand-alone or in Rails 3 as a drop-in + replacement for ActiveRecord or DataMapper. Because the database is a Git + repository it can be synced across multiple machines, manipulated with standard + Git client tools, can be branched and merged, and of course keeps the history + of all changes. + DESC + + s.add_dependency 'activemodel', '>= 3.0.1' + s.add_dependency 'activesupport', '>= 3.0.1' + s.add_dependency 'grit', '>= 2.3.0' + s.add_dependency 'lockfile', '>= 1.4.3' + + s.add_development_dependency 'ZenTest', '>= 4.4.0' + s.add_development_dependency 'autotest', '>= 4.4.1' + s.add_development_dependency 'autotest-fsevent', '>= 0.2.3' if RUBY_PLATFORM.downcase.include?("darwin") # OS X only + s.add_development_dependency 'rspec', '>= 2.0.1' + + s.files = `git ls-files`.split("\n") + s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + s.require_paths = ["lib"] + +end + diff --git a/lib/gitmodel.rb b/lib/gitmodel.rb new file mode 100644 index 0000000..ea6aa8b --- /dev/null +++ b/lib/gitmodel.rb @@ -0,0 +1,76 @@ +require 'rubygems' +require 'bundler/setup' + +require 'active_model' +require 'active_support/all' # TODO we don't really want all here, clean this up +require 'grit' +require 'json' +require 'lockfile' +require 'pp' + +$:.unshift(File.dirname(__FILE__)) +require 'gitmodel/errors' +require 'gitmodel/persistable' +require 'gitmodel/transaction' + +module GitModel + + # db_root must be an existing git repo. (It can be created with create_db!) + # Bare repositories aren't supported yet, it must be a normal git repo with a + # working directory and a '.git' subdirectory. + mattr_accessor :db_root + self.db_root = './gitmodel-data' + + mattr_accessor :default_branch + self.default_branch = 'master' + + mattr_accessor :logger + self.logger = ::Logger.new(STDERR) + self.logger.level = ::Logger::WARN + + mattr_accessor :git_user_name + mattr_accessor :git_user_email + + def self.repo + @@repo = Grit::Repo.new(GitModel.db_root) + end + + # Create the database defined in db_root. Raises an exception if it exists. + def self.create_db! + raise "Database #{db_root} already exists!" if File.exist? db_root + if db_root =~ /.+\.git/ + #logger.info "Creating database (bare): #{db_root}" + #Grit::Repo.init_bare db_root + logger.error "Bare repositories aren't supported yet" + else + logger.info "Creating database: #{db_root}" + Grit::Repo.init db_root + end + end + + # Delete and re-create the database defined in db_root. Dangerous! + def self.recreate_db! + logger.info "Deleting database #{db_root}!!" + FileUtils.rm_rf db_root + create_db! + end + + def self.last_commit(branch = nil) + branch ||= default_branch + # PERFORMANCE Cache this somewhere and update it on commit? + # (Need separate instance per branch) + + return nil unless repo.commits(branch).any? + + # We should be able to use just repo.commits(branch).first here but + # this is a workaround for this bug: + # http://github.com/mojombo/grit/issues/issue/38 + GitModel.repo.commits("#{branch}^..#{branch}").first || GitModel.repo.commits(branch).first + end + + def self.current_tree(branch = nil) + c = last_commit(branch) + c ? c.tree : nil + end + +end diff --git a/lib/gitmodel/errors.rb b/lib/gitmodel/errors.rb new file mode 100644 index 0000000..7e2f6b2 --- /dev/null +++ b/lib/gitmodel/errors.rb @@ -0,0 +1,25 @@ +module GitModel + + # Generic GitModel exception class. + class GitModelError < StandardError + end + + # Raised when GitModel cannot find record by given id or set of ids. + class RecordNotFound < GitModelError + end + + # Raised by GitModel::Persistable.save! and GitModel::Persistable.create! methods when record cannot be + # saved because record is invalid. + class RecordNotSaved < GitModelError + end + + class RecordExists < GitModelError + end + + class RecordDoesntExist < GitModelError + end + + class NullId < GitModelError + end + +end diff --git a/lib/gitmodel/persistable.rb b/lib/gitmodel/persistable.rb new file mode 100644 index 0000000..af9cbea --- /dev/null +++ b/lib/gitmodel/persistable.rb @@ -0,0 +1,269 @@ +module GitModel + module Persistable + + def self.included(base) + base.class_eval do + + extend ActiveModel::Callbacks + extend ActiveModel::Naming + include ActiveModel::Validations + include ActiveModel::Dirty + include ActiveModel::Observing + include ActiveModel::Translation + + define_model_callbacks :initialize, :find, :touch, :only => :after + define_model_callbacks :save, :create, :update, :destroy + end + + base.extend(ClassMethods) + end + + + def initialize(args = {}) + _run_initialize_callbacks do + @new_record = true + self.attributes = {} + self.blobs = {} + args.each do |k,v| + self.send("#{k}=".to_sym, v) + end + end + end + + def to_model + self + end + + def to_key + id ? [id] : nil + end + + def to_param + id && id.to_s + end + + def id + @id + end + + def id=(string) + # TODO ensure is valid as a filename + @id = string + end + + def attributes + @attributes + end + + def attributes=(new_attributes, guard_protected_attributes = true) + @attributes = HashWithIndifferentAccess.new + if new_attributes + new_attributes.each {|k,v| @attributes[k] = v} + end + end + + def blobs + @blobs + end + + def blobs=(new_blobs) + @blobs = HashWithIndifferentAccess.new + if new_blobs + new_blobs.each {|k,v| @blobs[k] = v} + end + end + + def new_record? + @new_record || false + end + + # Valid options are: + # :transaction + # OR: + # :branch + # :commit_message + # Returns false if validations failed, otherwise returns the SHA of the commit + def save(options = {}) + raise GitModel::NullId unless self.id + + if new_record? + raise GitModel::RecordExists if self.class.exists?(self.id) + else + raise GitModel::RecordDoesntExist unless self.class.exists?(self.id) + end + + dir = File.join(self.class.db_subdir, self.id) + + transaction = options.delete(:transaction) || GitModel::Transaction.new(options) + result = transaction.execute do |t| + # Write the attributes to the attributes file + t.index.add(File.join(dir, 'attributes.json'), JSON.pretty_generate(attributes)) + + # Write the blob files + blobs.each do |name, data| + t.index.add(File.join(dir, name), data) + end + end + + return result + end + + # Same as #save but raises an exception on error + def save!(options = {}) + save(options) || raise(GitModel::RecordNotSaved) + end + + def delete(options = {}) + freeze + self.class.delete(id, options) + end + + def to_s + "#<#{self.class.name}:#{__id__} id=#{id}, attributes=#{attributes.inspect}, blobs.keys=#{blobs.keys.inspect}>" + end + + + private + + def load(dir) + _run_find_callbacks do + # remove dangerous ".." + # todo find a better way to ensure path is safe + dir.gsub!(/\.\./, '') + + raise GitModel::RecordNotFound if GitModel.current_tree.nil? + + self.id = File.basename(dir) + @new_record = false + + # load the attributes + object = GitModel.current_tree / File.join(dir, 'attributes.json') + raise GitModel::RecordNotFound if object.nil? + + self.attributes = JSON.parse(object.data, :max_nesting => false) + + # load all other non-hidden files in the dir as blobs + blobs = (GitModel.current_tree / dir).blobs.reject{|b| b.name[0] == '.' || b.name == 'attributes.json'} + blobs.each do |b| + self.blobs[b.name] = b.data + end + end + end + + + module ClassMethods + + def db_subdir + self.to_s.tableize + end + + def attribute(name, options = {}) + default = options[:default] + self.class_eval <<-EOF + def #{name}; attributes[:#{name}] || #{default.inspect}; end + def #{name}=(value); attributes[:#{name}] = value; end + EOF + end + + def blob(name, options = {}) + self.class_eval <<-EOF + def #{name}; blobs[:#{name}]; end + def #{name}=(value); blobs[:#{name}] = value; end + EOF + end + + def find(id) + GitModel.logger.debug "Finding #{name} with id: #{id}" + o = new + dir = File.join(db_subdir, id) + o.send :load, dir + return o + end + + def exists?(id) + GitModel.repo.commits.any? && !(GitModel.current_tree / File.join(db_subdir, id, 'attributes.json')).nil? + end + + def find_all(conditions = {}) + GitModel.logger.debug "Finding all #{name.pluralize} with conditions: #{conditions.inspect}" + results = [] + dirs = (GitModel.current_tree / db_subdir).trees + dirs.each do |dir| + if dir.blobs.any? + o = new + o.send :load, File.join(db_subdir, dir.name) + results << o + end + end + + return results + end + + def create(args) + if args.is_a?(Array) + args.map{|arg| create(arg)} + else + o = self.new(args) + o.save + end + return o + end + + def create!(args) + if args.is_a?(Array) + args.map{|arg| create!(arg)} + else + o = self.new(args) + o.save! + end + return o + end + + def delete(id, options = {}) + path = File.join(db_subdir, id) + transaction = options.delete(:transaction) || GitModel::Transaction.new(options) + result = transaction.execute do |t| + delete_tree(path, t.index, options) + end + end + + def delete_all(options = {}) + transaction = options.delete(:transaction) || GitModel::Transaction.new(options) + result = transaction.execute do |t| + delete_tree(db_subdir, t.index, options) + end + end + + + private + + def delete_tree(path, index, options = {}) + # This leaves a bunch of empty sub-trees, there must be a way to just + # replace the tree to be deleted with an empty tree that doesn't even + # reference the sub-trees. + current = index.tree + path.split('/').each do |dir| + current[dir] ||= {} + current = current[dir] + end + + build_tree_hash(current, (index.current_tree / path)) + end + + # recusively build the hash representing the objects that grit will commit + def build_tree_hash(hash, tree) + tree.blobs.each do |b| + hash[b.name] = false + end + tree.trees.each do |t| + hash[t.name] = {} + build_tree_hash(hash[t.name], t) + end + return hash + end + + end # module ClassMethods + + end # module Persistable +end # module GitModel + diff --git a/lib/gitmodel/transaction.rb b/lib/gitmodel/transaction.rb new file mode 100644 index 0000000..56798c2 --- /dev/null +++ b/lib/gitmodel/transaction.rb @@ -0,0 +1,62 @@ +module GitModel + class Transaction + + attr_accessor :index + attr_accessor :branch + attr_accessor :commit_message + + def initialize(options = {}) + self.branch = options[:branch] || GitModel.default_branch + self.commit_message = options[:commit_message] + end + + def execute(&block) + if index + # We're already in a transaction + yield self + else + # For now there's a big ugly lock here, this will be fixed! + # TODO move this lock around the commit only (need to make sure two + # processes aren't updating refs/heads/ at the same time) and + # make concurrent transactions can work. This will require some merging + # magic! + lock do + # We're not in a transaction, start a new one + GitModel.logger.debug "Beginning transaction on #{branch}..." + + # Save the current head so that concurrent transactions can work. We need + # to make sure the parent of this commit is the same SHA that this + # index's tree is based on. + parent = GitModel.last_commit(branch) + + self.index = Grit::Index.new(GitModel.repo) + index.read_tree(parent.to_s) + + yield self + + committer = Grit::Actor.new(GitModel.git_user_name, GitModel.git_user_email) + sha = index.commit(commit_message, parent ? [parent] : nil, committer, nil, branch) + # TODO return false and log if anything went wrong with the commit + + GitModel.logger.debug "Finished transaction on #{branch}." + + return sha + end + end + end + + # Wait until we can get an exclusive lock on the branch, then execute the + # block. We lock the branch by creating refs/heads/.lock, which + # the git commands also seem to respect + def lock(&block) + lockfile = Lockfile.new File.join(GitModel.repo.path, 'refs/heads', branch + '.lock') + begin + lockfile.lock + yield + ensure + lockfile.unlock + end + end + + end +end diff --git a/spec/gitmodel/persistable_spec.rb b/spec/gitmodel/persistable_spec.rb new file mode 100644 index 0000000..0d34e70 --- /dev/null +++ b/spec/gitmodel/persistable_spec.rb @@ -0,0 +1,436 @@ +require 'spec_helper' + +class TestEntity + include GitModel::Persistable +end + +class LintTest < ActiveModel::TestCase + include ActiveModel::Lint::Tests + + def setup + @model = TestEntity.new + end +end + +describe GitModel::Persistable do + + it 'passes ActiveModel lint tests' do + + o = LintTest.new("ActiveModel lint test") + o.setup + + # TODO get this list of methods dynamically + o.test_to_key + o.test_to_param + o.test_valid? + o.test_persisted? + o.test_model_naming + o.test_errors_aref + o.test_errors_full_messages + end + + describe '#save' do + + it 'raises an exception if the id is not set' do + o = TestEntity.new + lambda {o.save}.should raise_error(GitModel::NullId) + end + + it 'stores an instance in a Git repository in a subdir of db_root named with the id' do + id = 'foo' + TestEntity.create!(:id => id) + + repo = Grit::Repo.new(GitModel.db_root) + (repo.commits.first.tree / File.join(TestEntity.db_subdir, id, 'attributes.json')).data.should_not be_nil + end + + it 'stores attributes in a JSON file' do + id = 'foo' + attrs = {:one => 1, :two => 2} + TestEntity.create!(:id => id, :attributes => attrs) + + repo = Grit::Repo.new(GitModel.db_root) + attrs = (repo.commits.first.tree / File.join(TestEntity.db_subdir, id, 'attributes.json')).data + r = JSON.parse(attrs) + r.size.should == 2 + r['one'].should == 1 + r['two'].should == 2 + end + + it 'stores blobs in files' do + id = 'foo' + blobs = {'blob1.txt' => 'This is blob 1'} + TestEntity.create!(:id => id, :blobs => blobs) + + repo = Grit::Repo.new(GitModel.db_root) + (repo.commits.first.tree / File.join(TestEntity.db_subdir, id, 'blob1.txt')).data.should == 'This is blob 1' + end + + it 'can store attributes and blobs' do + id = 'foo' + attrs = {:one => 1, :two => 2} + blobs = {'blob1.txt' => 'This is blob 1'} + TestEntity.create!(:id => id, :attributes => attrs, :blobs => blobs) + + r = TestEntity.find('foo') + r.attributes['one'].should == 1 + r.attributes['two'].should == 2 + r.blobs['blob1.txt'].should == 'This is blob 1' + end + + it 'returns false if the validations failed' + + it 'returns the SHA of the commit if the save was successful' + + it 'deletes blobs that have been removed' + end + + describe '#save!' do + + it "calls save and returns the non-false and non-nil result" + + it "calls save and raises an exception if the result is nil" + + it "calls save and raises an exception if the result is false" + + end + + describe '#new' do + it 'creates a new unsaved instance' do + TestEntity.new.new_record?.should be_true + end + + it 'takes an optional hash to set id, attributes and blobs' do + o = TestEntity.new(:id => 'foo', :attributes => {:one => 1}, :blobs => {'blob1.txt' => 'This is blob 1'}) + o.id.should == 'foo' + o.attributes['one'].should == 1 + o.blobs['blob1.txt'].should == 'This is blob 1' + end + end + + describe '.create' do + + it 'creates a new instance with the given parameters and calls #save on it' do + id = 'foo' + attrs = {:one => 1, :two => 2} + blobs = {'blob1.txt' => 'This is blob 1'} + + new_mock = mock("new_mock") + TestEntity.should_receive(:new).with(:id => id, :attributes => attrs, :blobs => blobs).and_return(new_mock) + new_mock.should_receive(:save) + + TestEntity.create(:id => id, :attributes => attrs, :blobs => blobs) + end + + it 'returns an instance of the record created' do + o = TestEntity.create(:id => 'lemur') + o.should be_a(TestEntity) + o.id.should == 'lemur' + end + + describe 'with a single array as a parameter' do + + it 'creates a new instance with each element of the array as parameters and calls #save on it' do + args = [ + {:id => 'foo', :attributes => {:one => 1}, :blobs => {'blob1.txt' => 'This is blob 1'}}, + {:id => 'bar', :attributes => {:two => 2}, :blobs => {'blob2.txt' => 'This is blob 2'}} + ] + + new_mock1 = mock("new_mock1") + new_mock2 = mock("new_mock2") + TestEntity.should_receive(:new).with(args[0]).once.and_return(new_mock1) + TestEntity.should_receive(:new).with(args[1]).once.and_return(new_mock2) + new_mock1.should_receive(:save) + new_mock2.should_receive(:save) + + TestEntity.create(args) + end + + end + + end + + describe '.create!' do + + it 'creates a new instance with the given parameters and calls #save! on it' do + id = 'foo' + attrs = {:one => 1, :two => 2} + blobs = {'blob1.txt' => 'This is blob 1'} + + new_mock = mock("new_mock") + TestEntity.should_receive(:new).with(:id => id, :attributes => attrs, :blobs => blobs).and_return(new_mock) + new_mock.should_receive(:save!) + + TestEntity.create!(:id => id, :attributes => attrs, :blobs => blobs) + end + + it 'returns an instance of the record created' do + o = TestEntity.create!(:id => 'lemur') + o.should be_a(TestEntity) + o.id.should == 'lemur' + end + + describe 'with a single array as a parameter' do + it 'creates a new instance with each element of the array as parameters and calls #save! on it' do + args = [ + {:id => 'foo', :attributes => {:one => 1}, :blobs => {'blob1.txt' => 'This is blob 1'}}, + {:id => 'bar', :attributes => {:two => 2}, :blobs => {'blob2.txt' => 'This is blob 2'}} + ] + + new_mock1 = mock("new_mock1") + new_mock2 = mock("new_mock2") + TestEntity.should_receive(:new).with(args[0]).once.and_return(new_mock1) + TestEntity.should_receive(:new).with(args[1]).once.and_return(new_mock2) + new_mock1.should_receive(:save!) + new_mock2.should_receive(:save!) + + TestEntity.create!(args) + end + end + + end + + describe '.delete' do + + it 'deletes the object with the given id from the database' do + TestEntity.create!(:id => 'monkey') + TestEntity.delete('monkey') + + TestEntity.exists?('monkey').should be_false + end + + it 'also deletes blobs associated with the given object' do + id = 'Lemuridae' + TestEntity.create!(:id => id, :blobs => {:crowned => "Eulemur coronatus", :brown => "Eulemur fulvus"}) + (GitModel.current_tree / File.join(TestEntity.db_subdir, id, 'crowned')).data.should_not be_nil + (GitModel.current_tree / File.join(TestEntity.db_subdir, id, 'brown')).data.should_not be_nil + TestEntity.delete(id) + + (GitModel.current_tree / File.join(TestEntity.db_subdir, id, 'attributes.json')).should be_nil + (GitModel.current_tree / File.join(TestEntity.db_subdir, id, 'attributes.json')).should be_nil + + (GitModel.current_tree / File.join(TestEntity.db_subdir, id, 'crowned')).should be_nil + (GitModel.current_tree / File.join(TestEntity.db_subdir, id, 'brown')).should be_nil + end + + + end + + describe '.delete_all' do + + it 'deletes all objects of the same type from the database' do + TestEntity.create!(:id => 'monkey') + TestEntity.create!(:id => 'ape') + + TestEntity.delete_all + TestEntity.find_all.should be_empty + end + + end + + describe '#delete' do + + it 'deletes the object from the database' do + o = TestEntity.create!(:id => 'monkey') + o.delete + + TestEntity.exists?('monkey').should be_false + end + + it 'freezes the object' do + o = TestEntity.create!(:id => 'monkey') + o.delete + + o.frozen?.should be_true + end + + end + + describe '#find' do + + #it 'can load an object from an empty subdir of db_root' do + # id = "foo" + # dir = File.join(GitModel.db_root, TestEntity.db_subdir, id) + # FileUtils.mkdir_p dir + + # o = TestEntity.find(id) + # o.id.should == id + # o.attributes.should be_empty + # o.blobs.should be_empty + #end + + describe 'with no commits in the repo' do + + it 'raises GitModel::RecordNotFound if a record with the given id doesn\'t exist' do + lambda{TestEntity.find('missing')}.should raise_error(GitModel::RecordNotFound) + end + + end + + it 'raises GitModel::RecordNotFound if a record with the given id doesn\'t exist' do + TestEntity.create!(:id => 'something') + lambda{TestEntity.find('missing')}.should raise_error(GitModel::RecordNotFound) + end + + it 'can load an object with attributes and no blobs' do + id = "foo" + attrs = {:one => 1, :two => 2} + TestEntity.create!(:id => id, :attributes => attrs) + + o = TestEntity.find(id) + o.id.should == id + o.attributes.size.should == 2 + o.attributes['one'].should == 1 + o.attributes['two'].should == 2 + o.blobs.should be_empty + end + + it 'can load an object with blobs and no attributes' do + id = 'foo' + blobs = {'blob1.txt' => 'This is blob 1', 'blob2' => 'This is blob 2'} + TestEntity.create!(:id => id, :blobs => blobs) + + o = TestEntity.find(id) + o.id.should == id + o.attributes.should be_empty + o.blobs.size.should == 2 + o.blobs["blob1.txt"].should == 'This is blob 1' + o.blobs["blob2"].should == 'This is blob 2' + end + + it 'can load an object with both attributes and blobs' do + id = 'foo' + attrs = {:one => 1, :two => 2} + blobs = {'blob1.txt' => 'This is blob 1', 'blob2' => 'This is blob 2'} + TestEntity.create!(:id => id, :attributes => attrs, :blobs => blobs) + + o = TestEntity.find(id) + o.id.should == id + o.attributes.size.should == 2 + o.attributes['one'].should == 1 + o.attributes['two'].should == 2 + o.blobs.size.should == 2 + o.blobs["blob1.txt"].should == 'This is blob 1' + o.blobs["blob2"].should == 'This is blob 2' + end + + end + + describe '#find_all' do + + it 'returns an array of all objects' do + TestEntity.create!(:id => 'one') + TestEntity.create!(:id => 'two') + TestEntity.create!(:id => 'three') + + r = TestEntity.find_all + r.size.should == 3 + end + + end + + describe '#exists?' do + + it 'returns true if the record exists' do + TestEntity.create!(:id => 'one') + TestEntity.exists?('one').should be_true + end + + it "returns false if the record doesn't exist" do + TestEntity.exists?('missing').should be_false + end + + end + + describe '#attributes' do + it 'accepts symbols or strings interchangeably as strings' do + o = TestEntity.new(:id => 'lol', :attributes => {"one" => 1, :two => 2}) + o.save! + o.attributes["one"].should == 1 + o.attributes[:one].should == 1 + o.attributes["two"].should == 2 + o.attributes[:two].should == 2 + + # Should also be true after reloading + o = TestEntity.find 'lol' + o.attributes["one"].should == 1 + o.attributes[:one].should == 1 + o.attributes["two"].should == 2 + o.attributes[:two].should == 2 + end + end + + describe '#blobs' do + it 'accepts symbols or strings interchangeably as strings' do + o = TestEntity.new(:id => 'lol', :blobs => {"one" => 'this is blob 1', :two => 'this is blob 2'}) + o.save! + o.blobs["one"].should == 'this is blob 1' + o.blobs[:one].should == 'this is blob 1' + o.blobs["two"].should == 'this is blob 2' + o.blobs[:two].should == 'this is blob 2' + + # Should also be true after reloading + o = TestEntity.find 'lol' + o.blobs["one"].should == 'this is blob 1' + o.blobs[:one].should == 'this is blob 1' + o.blobs["two"].should == 'this is blob 2' + o.blobs[:two].should == 'this is blob 2' + end + end + + describe 'attribute description in the class definition' do + + it 'creates convenient accessor methods for accessing the attributes hash' do + o = TestEntity.new + class << o + attribute :colour + end + + o.colour.should == nil + o.colour = "red" + o.colour.should == "red" + o.attributes[:colour].should == "red" + end + + it 'can set default values for attributes, with any ruby value for the default' do + o = TestEntity.new + + # Change the singleton class for object o, this doesn't change the + # TestEntity class + class << o + attribute :size, :default => "medium" + attribute :shape, :default => 2 + attribute :style, :default => nil + attribute :teeth, :default => {"molars" => 4, "canines" => 2} + end + + o.size.should == "medium" + o.shape.should == 2 + o.style.should == nil + o.teeth.should == {"molars" => 4, "canines" => 2} + + o.size = "large" + o.size.should == "large" + end + + end + + describe 'blob description in the class definition' do + + it 'creates convenient accessor methods for accessing the blobs hash' do + o = TestEntity.new + class << o + blob :avatar + end + + o.avatar.should == nil + + o.avatar = "image_data_here" + o.avatar.should == "image_data_here" + o.blobs[:avatar].should == "image_data_here" + end + + end + +end + diff --git a/spec/gitmodel/transaction_spec.rb b/spec/gitmodel/transaction_spec.rb new file mode 100644 index 0000000..71b965e --- /dev/null +++ b/spec/gitmodel/transaction_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe GitModel::Transaction do + + describe '#execute' do + it "yields to a given block" do + m = mock("mock") + m.should_receive(:a_method) + GitModel::Transaction.new.execute do + m.a_method + end + end + + describe "when called the first time" do + it "creates a new Git index" do + index = mock("index") + index.stub!(:read_tree) + index.stub!(:commit) + Grit::Index.should_receive(:new).and_return(index) + GitModel::Transaction.new.execute {} + end + + it "commits after yielding" do + index = mock("index") + index.stub!(:read_tree) + index.should_receive(:commit) + Grit::Index.should_receive(:new).and_return(index) + GitModel::Transaction.new.execute {} + end + + it "can create the first commit in the repo" do + GitModel::Transaction.new.execute do |t| + t.index.add "foo", "foo" + end + end + + # TODO it "locks the branch while committing" + + # TODO it "merges commits from concurrent transactions" + + end + + describe "when called recursively" do + + it "re-uses the existing git index and doesn't commit" do + index = mock("index") + index.stub!(:read_tree) + index.should_receive(:commit).once + Grit::Index.should_receive(:new).and_return(index) + GitModel::Transaction.new.execute do |t| + t.execute {} + end + end + + end + + end + +end diff --git a/spec/gitmodel_spec.rb b/spec/gitmodel_spec.rb new file mode 100644 index 0000000..ed3e3e3 --- /dev/null +++ b/spec/gitmodel_spec.rb @@ -0,0 +1,39 @@ +require File.expand_path(File.dirname(__FILE__) + '/spec_helper') + +describe GitModel do + + describe "#last_commit" do + + it "returns nil if there are no commits" do + GitModel.last_commit.should == nil + end + + it "returns the most recent commit on a branch" do + index = Grit::Index.new(GitModel.repo) + head = GitModel.repo.commits.first + index.read_tree head.to_s + index.add "foo", "foo" + sha = index.commit nil, nil, nil, nil, 'master' + + GitModel.last_commit.to_s.should == sha + end + + end + + describe "#current_tree" do + + it "returns nil if there are no commits" do + GitModel.current_tree.should == nil + end + + it "returns the tree for the most recent commit on a branch" do + last_commit = mock('last_commit') + last_commit.should_receive(:tree).and_return("yay, a tree!") + GitModel.should_receive(:last_commit).with('master').and_return(last_commit) + GitModel.current_tree('master') + end + + end + +end + diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..9af00f9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,12 @@ +require 'rspec' + +$LOAD_PATH.unshift(File.dirname(__FILE__)) +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +require 'gitmodel' + +Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each {|f| require f} + +Rspec.configure do |c| + c.mock_with :rspec +end + diff --git a/spec/support/setup.rb b/spec/support/setup.rb new file mode 100644 index 0000000..e5db21c --- /dev/null +++ b/spec/support/setup.rb @@ -0,0 +1,10 @@ +GitModel.db_root = '/tmp/gitmodel-test-data' +GitModel.git_user_name = 'GitModel Test' +GitModel.git_user_email = 'foo@bar.com' + +RSpec.configure do |config| + config.before(:each) do + GitModel.recreate_db! + end +end +