poro_repository 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ Gemfile.lock
2
+
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'rspec'
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ poro_repository
2
+ ================
3
+
4
+ Store plain old ruby objects to the file system. You can store any object that
5
+ can be marshalled. The objects to be stored need not inherit from any library
6
+ base class, nor include any library module.
7
+
8
+ Usage Examples
9
+ --------------
10
+
11
+ A simple example
12
+
13
+ ```ruby
14
+ class Contact
15
+ attr_accessor :id, :name
16
+ end
17
+
18
+ contact = Contact.new
19
+ contact.id = 1
20
+ contact.name = "John Smith"
21
+
22
+ repo = PoroRepository.new("/repository/path")
23
+ repo.save_record contact
24
+
25
+ # ...
26
+
27
+ repo.load_record('Contact', 1).name #=> "John Smith"
28
+ ```
29
+
30
+ Storing different entities separately
31
+
32
+ ```ruby
33
+ class Contact
34
+ attr_accessor :id, :name
35
+ end
36
+
37
+ class Company
38
+ attr_accessor :id, :name
39
+ end
40
+
41
+ xyz_company = Company.new
42
+ xyz_company.id = 1
43
+ xyz_company.name = "XYZ Company"
44
+
45
+ contact = Contact.new
46
+ contact.id = 1
47
+ contact.name = "John Smith"
48
+ contact.company = xyz_company
49
+
50
+ repo = PoroRepository.new("/repository/path")
51
+ repo.boundary :Contact, :@company # causes company record to save separately
52
+
53
+ repo.save_record contact
54
+
55
+ # ...
56
+
57
+ # company record stored separately, and can be loaded independently
58
+ loaded_company = repo.load_record 'Company', 1
59
+
60
+ loaded_contact = repo.load_record 'Contact', 1
61
+ loaded_contact.company == loaded_company #=> true
62
+ loaded_contact.company.equal?(loaded_company) #=> true ; same object
63
+ ```
64
+
65
+ Caveats
66
+ -------
67
+
68
+ * No consideration has been given to concurrency. If you need concurrency, you
69
+ should probably use something else.
70
+ * At least at this stage, it is only really suitable for storing hundreds of
71
+ objects, not thousands or millions.
72
+ * It's still very incomplete and is missing most features required for it to
73
+ be generally useful.
@@ -0,0 +1,10 @@
1
+ class PoroRepository::BoundaryToken
2
+
3
+ attr_reader :original_type, :original_id
4
+
5
+ def initialize original_type, original_id
6
+ @original_type = original_type
7
+ @original_id = original_id
8
+ end
9
+
10
+ end
@@ -0,0 +1,13 @@
1
+ class PoroRepository::RecordMetaData
2
+
3
+ def id
4
+ @id ||= random_id
5
+ end
6
+
7
+ private
8
+
9
+ def random_id
10
+ Digest::SHA1.hexdigest("#{rand}#{rand}#{rand}#{Time.now.to_i}")
11
+ end
12
+
13
+ end
@@ -0,0 +1,24 @@
1
+ require 'weakref'
2
+
3
+ # http://endofline.wordpress.com/2011/01/09/getting-to-know-the-ruby-standard-library-weakref/
4
+ class PoroRepository::WeakHash < Hash
5
+
6
+ class AmbivalentRef < WeakRef
7
+ def __getobj__
8
+ super rescue nil
9
+ end
10
+
11
+ alias actual __getobj__
12
+ end
13
+
14
+ def []= key, object
15
+ super(key, AmbivalentRef.new(object))
16
+ end
17
+
18
+ def [] key
19
+ ref = super(key)
20
+ self.delete(key) if !ref.weakref_alive?
21
+ ref
22
+ end
23
+
24
+ end
@@ -0,0 +1,165 @@
1
+ require 'digest/sha1'
2
+ require 'fileutils'
3
+
4
+ class PoroRepository
5
+
6
+ autoload :WeakHash, "poro_repository/weak_hash"
7
+ autoload :RecordMetaData, "poro_repository/record_meta_data"
8
+ autoload :BoundaryToken, "poro_repository/boundary_token"
9
+
10
+ attr_accessor :remember
11
+
12
+ def initialize root
13
+ @root = root
14
+ @instantiated_records = {}
15
+ @boundaries = {}
16
+ @remember = true
17
+ end
18
+
19
+ # When serialising, attributes identified as "boundaries" are not serialised with the
20
+ # larger object, but are instead serialised separately. A placeholder is used in the
21
+ # original object, with an ID for the extracted object.
22
+ # @param type [Symbol]
23
+ # @param instance_var [Symbol]
24
+ def boundary type, instance_var
25
+ @boundaries[type] ||= []
26
+ @boundaries[type] << instance_var
27
+ end
28
+
29
+ def nuke! really
30
+ if really == 'yes, really'
31
+ FileUtils.rm_rf @root
32
+ else
33
+ raise "wont do it!"
34
+ end
35
+ end
36
+
37
+ def load_record type, id
38
+ record = previous_instantiated type, id
39
+ return record unless record.nil?
40
+ data = read_if_exists(record_path(type, id))
41
+ data && deserialise(data).tap do |record|
42
+ record.instance_variables.each do |inst_var|
43
+ if (token = record.instance_variable_get(inst_var)).is_a? BoundaryToken
44
+ object = load_record token.original_type, token.original_id
45
+ record.instance_variable_set inst_var, object
46
+ end
47
+ end
48
+ remember_record record if @remember
49
+ end
50
+ end
51
+
52
+ # @return [String] record id
53
+ def save_record record, remember=true
54
+ id = id_from_record(record)
55
+ path = record_path(type_from_record(record), id)
56
+ open_for_write path do |file|
57
+ with_boundary_objects_extracted record do |extracted|
58
+ file.write serialise record
59
+ extracted.each do |extracted_record|
60
+ save_record extracted_record
61
+ end
62
+ end
63
+ end
64
+ remember_record record if @remember
65
+ id
66
+ end
67
+
68
+ private
69
+
70
+ def open_for_write path, &block
71
+ FileUtils.mkdir_p File.dirname(path)
72
+ File.open path, 'w', &block
73
+ end
74
+
75
+ # @return [String, nil]
76
+ def read_if_exists path
77
+ if File.exist? path
78
+ File.read path
79
+ else
80
+ nil
81
+ end
82
+ end
83
+
84
+ def record_metadata record
85
+ record.instance_eval do
86
+ @_repository_data ||= RecordMetaData.new
87
+ end
88
+ end
89
+
90
+ def serialise record
91
+ Marshal.dump(record)
92
+ end
93
+
94
+ def deserialise data
95
+ Marshal.load(data)
96
+ end
97
+
98
+ # @return [String]
99
+ def type_from_record record
100
+ if record.respond_to? :type
101
+ record.type
102
+ else
103
+ record.class.name.split('::').last
104
+ end
105
+ end
106
+
107
+ def id_from_record record
108
+ if record.respond_to?(:id) && record.id
109
+ record.id
110
+ else
111
+ record_metadata(record).id
112
+ end
113
+ end
114
+
115
+ def record_path type, id
116
+ raise if id.nil?
117
+ "#{@root}/#{type}/records/#{id}"
118
+ end
119
+
120
+ def index_path type, field
121
+ "#{@root}/#{type}/index/#{field}"
122
+ end
123
+
124
+ def with_boundary_objects_extracted record, &block
125
+ originals = {}
126
+ boundaries(record).each do |inst_var|
127
+ value = record.instance_variable_get(inst_var)
128
+ originals[inst_var] = value
129
+ record.instance_variable_set(inst_var, boundary_token(value))
130
+ end
131
+ block.call originals.values
132
+ ensure
133
+ originals.each do |inst_var, original_value|
134
+ record.instance_variable_set(inst_var, original_value)
135
+ end
136
+ end
137
+
138
+ def boundaries record
139
+ @boundaries[type_from_record(record).to_sym] || []
140
+ end
141
+
142
+ def boundary_token record
143
+ BoundaryToken.new type_from_record(record), id_from_record(record)
144
+ end
145
+
146
+ def remember_record record
147
+ type = type_from_record(record)
148
+ @instantiated_records[type] ||= WeakHash.new
149
+ @instantiated_records[type][id_from_record(record)] = record
150
+ end
151
+
152
+ def previous_instantiated type, id
153
+ records = @instantiated_records[type] || {}
154
+ ref = records[id]
155
+ ref && ref.actual
156
+ end
157
+
158
+ # this method is only used in test
159
+ def remembered_records
160
+ @instantiated_records.values.collect do |h|
161
+ h.values.compact.collect(&:actual)
162
+ end.flatten.compact
163
+ end
164
+
165
+ end
@@ -0,0 +1,18 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "poro_repository"
6
+ s.version = "0.0.1"
7
+ s.authors = ["Joel Plane"]
8
+ s.email = ["joel.plane@gmail.com"]
9
+ s.homepage = "https://github.com/joelplane/poro_repository"
10
+ s.summary = %q{PORO Repository}
11
+ s.description = %q{Library for storing plain old ruby objects to the file system}
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- spec/*`.split("\n")
15
+ s.require_paths = ["lib"]
16
+
17
+ s.add_development_dependency "rspec"
18
+ end
@@ -0,0 +1,167 @@
1
+ require 'spec_helper'
2
+ require 'ostruct'
3
+
4
+ describe PoroRepository do
5
+
6
+ subject do
7
+ PoroRepository.new("/tmp/test-avial-invoicing-repository").tap do |repo|
8
+ # disable storing of records in memory, so we know we're not cheating
9
+ repo.remember = false
10
+ end
11
+ end
12
+
13
+ let(:record) do
14
+ OpenStruct.new.tap do |o|
15
+ o.type = 'Invoice'
16
+ o.blah = 'blah'
17
+ end
18
+ end
19
+
20
+ let(:record_with_id) do
21
+ OpenStruct.new.tap do |o|
22
+ o.id = "123"
23
+ o.type = 'Invoice'
24
+ o.blah = 'blah'
25
+ end
26
+ end
27
+
28
+ after(:each) do
29
+ subject.nuke! 'yes, really'
30
+ end
31
+
32
+ context "when the record does not have an id attribute" do
33
+ it "automatically assigns a random id" do
34
+ record_id = subject.save_record record
35
+ record_id.should match /^[a-f0-9]{40}$/
36
+ end
37
+ end
38
+
39
+ context "when the record has an id attribute" do
40
+ it "uses the record's id, not a random one" do
41
+ record_id = subject.save_record record_with_id
42
+ record_id.should == '123'
43
+ end
44
+ end
45
+
46
+ it "saves and retrieves a record" do
47
+ record_id = subject.save_record record
48
+ loaded_record = subject.load_record('Invoice', record_id)
49
+ loaded_record.should == record # record should be the same value
50
+ loaded_record.should_not equal record # but not the same object
51
+ # NOTE - it would be the same object, but we did repo.remember = false
52
+ end
53
+
54
+ it "does not save more than one copy of a record" do
55
+ record_id1 = subject.save_record record
56
+ record_id2 = subject.save_record record
57
+ record_id1.should == record_id2
58
+ end
59
+
60
+ # An Invoice has a Contact. We define the relationship as a boundary.
61
+ # This means the Invoice and the Contact are stored separately. To
62
+ # confirm this, we store the Invoice, and then try to load up just
63
+ # the Contact.
64
+ describe "boundaries" do
65
+
66
+ subject do
67
+ PoroRepository.new("/tmp/test-avial-invoicing-repository").tap do |repo|
68
+ repo.boundary :Invoice, :@contact
69
+ repo.remember = false
70
+ end
71
+ end
72
+
73
+ # Some arbitrary object that an invoice might have.
74
+ # Here to test that it gets stored with the invoice and not
75
+ # independently, since don't define a boundary for it.
76
+ let(:terms) do
77
+ TestObject.new.tap do |o|
78
+ o.id = '123'
79
+ o.type = 'Terms'
80
+ o.due_days = 7
81
+ end
82
+ end
83
+
84
+ let(:contact) do
85
+ TestObject.new.tap do |o|
86
+ o.id = '234'
87
+ o.type = 'Contact'
88
+ o.name = 'John Smith'
89
+ end
90
+ end
91
+
92
+ let(:invoice) do
93
+ TestObject.new.tap do |o|
94
+ o.id = '345'
95
+ o.type = 'Invoice'
96
+ o.contact = contact
97
+ o.terms = terms
98
+ end
99
+ end
100
+
101
+ it "should not store non-boundary objects separately" do
102
+ subject.save_record invoice
103
+ loaded_terms = subject.load_record('Terms', terms.id)
104
+ loaded_terms.should be_nil
105
+ end
106
+
107
+ it "should store boundary objects separately" do
108
+ subject.save_record invoice
109
+ loaded_contact = subject.load_record('Contact', contact.id)
110
+ loaded_contact.should be_a contact.class
111
+ loaded_contact.name.should == 'John Smith'
112
+ end
113
+
114
+ it "should load boundary objects" do
115
+ subject.save_record invoice
116
+ loaded_invoice = subject.load_record('Invoice', invoice.id)
117
+ loaded_invoice.contact.should be_a contact.class
118
+ loaded_invoice.contact.name.should == 'John Smith'
119
+ end
120
+
121
+ end
122
+
123
+ describe "object lifecycle" do
124
+
125
+ subject do
126
+ PoroRepository.new("/tmp/test-avial-invoicing-repository")
127
+ end
128
+
129
+ let(:invoice) do
130
+ TestObject.new.tap do |o|
131
+ o.id = '345'
132
+ o.type = 'Invoice'
133
+ end
134
+ end
135
+
136
+ context do
137
+ before do
138
+ subject.save_record invoice
139
+ end
140
+
141
+ it "should return the same object on sucessive calls to load_record" do
142
+ record1 = subject.load_record 'Invoice', '345'
143
+ record2 = subject.load_record 'Invoice', '345'
144
+ record1.should equal record2
145
+ end
146
+
147
+ it "should return the original object if still in memory" do
148
+ loaded_record = subject.load_record 'Invoice', '345'
149
+ loaded_record.should equal invoice
150
+ end
151
+ end
152
+
153
+ it "should not prevent records from being garbage collected" do
154
+ GC.disable
155
+ records = [TestObject.new.tap { |o| o.id = '345'; o.type = 'Invoice' }]
156
+ subject.save_record records.first
157
+ subject.send(:remembered_records).length.should == 1
158
+ records.clear
159
+ subject.send(:remembered_records).length.should == 1
160
+ GC.enable
161
+ GC.start
162
+ subject.send(:remembered_records).length.should == 0
163
+ end
164
+
165
+ end
166
+
167
+ end
@@ -0,0 +1,9 @@
1
+ require 'poro_repository'
2
+ require_relative 'test_object'
3
+
4
+ RSpec.configure do |config|
5
+ config.treat_symbols_as_metadata_keys_with_true_values = true
6
+ config.run_all_when_everything_filtered = true
7
+ config.filter_run :focus
8
+ config.order = 'random'
9
+ end
@@ -0,0 +1,3 @@
1
+ class TestObject
2
+ attr_accessor :id, :type, :name, :terms, :contact, :due_days
3
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: poro_repository
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Joel Plane
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-10-06 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ description: Library for storing plain old ruby objects to the file system
31
+ email:
32
+ - joel.plane@gmail.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - .gitignore
38
+ - Gemfile
39
+ - README.md
40
+ - lib/poro_repository.rb
41
+ - lib/poro_repository/boundary_token.rb
42
+ - lib/poro_repository/record_meta_data.rb
43
+ - lib/poro_repository/weak_hash.rb
44
+ - poro_repository.gemspec
45
+ - spec/poro_repository_spec.rb
46
+ - spec/spec_helper.rb
47
+ - spec/test_object.rb
48
+ homepage: https://github.com/joelplane/poro_repository
49
+ licenses: []
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ! '>='
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubyforge_project:
68
+ rubygems_version: 1.8.24
69
+ signing_key:
70
+ specification_version: 3
71
+ summary: PORO Repository
72
+ test_files:
73
+ - spec/poro_repository_spec.rb
74
+ - spec/spec_helper.rb
75
+ - spec/test_object.rb