poro_repository 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Gemfile +3 -0
- data/README.md +73 -0
- data/lib/poro_repository/boundary_token.rb +10 -0
- data/lib/poro_repository/record_meta_data.rb +13 -0
- data/lib/poro_repository/weak_hash.rb +24 -0
- data/lib/poro_repository.rb +165 -0
- data/poro_repository.gemspec +18 -0
- data/spec/poro_repository_spec.rb +167 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/test_object.rb +3 -0
- metadata +75 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
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,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
|
data/spec/spec_helper.rb
ADDED
data/spec/test_object.rb
ADDED
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
|