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 +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
|