poro_repository 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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