plain_record 0.1 → 0.2

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.
@@ -26,36 +26,55 @@ module PlainRecord
26
26
  dir = Pathname(__FILE__).dirname.expand_path + 'model'
27
27
  autoload :Entry, (dir + 'entry').to_s
28
28
  autoload :List, (dir + 'list').to_s
29
-
30
- # Properties names.
31
- attr_reader :properties
32
-
29
+
30
+ include PlainRecord::Callbacks
31
+ include PlainRecord::Filepath
32
+ include PlainRecord::Associations
33
+
34
+ # YAML properties names.
35
+ attr_accessor :properties
36
+
33
37
  # Name of special properties with big text.
34
- attr_reader :texts
35
-
38
+ attr_accessor :texts
39
+
40
+ # Properties names with dynamic value.
41
+ attr_accessor :virtuals
42
+
36
43
  # Storage type: +:entry+ or +:list+.
37
44
  attr_reader :storage
38
-
45
+
39
46
  # Content of already loaded files.
40
- attr_reader :loaded
41
-
47
+ attr_accessor :loaded
48
+
49
+ def self.extended(base) #:nodoc:
50
+ base.properties = []
51
+ base.virtuals = []
52
+ base.texts = []
53
+ base.loaded = { }
54
+ end
55
+
42
56
  # Load and return all entries in +file+.
43
57
  #
44
58
  # See method code in <tt>Model::Entry</tt> or <tt>Model::List</tt>.
45
59
  def load_file(file); end
46
-
47
- # Call block on all entry. Unlike <tt>all.each</tt> it use lazy file
48
- # loading, so it is useful if you planing to break this loop somewhere in
49
- # the middle (for example, like +first+).
60
+
61
+ # Call block on all entry, which is may be match for +matchers+. Unlike
62
+ # <tt>all.each</tt> it use lazy file loading, so it is useful if you planing
63
+ # to break this loop somewhere in the middle (for example, like +first+).
50
64
  #
51
65
  # See method code in <tt>Model::Entry</tt> or <tt>Model::List</tt>.
52
- def each_entry; end
53
-
66
+ def each_entry(matchers = { }); end
67
+
54
68
  # Delete +entry+ from +file+.
55
69
  #
56
70
  # See method code in <tt>Model::Entry</tt> or <tt>Model::List</tt>.
57
71
  def delete_entry(file, entry = nil); end
58
-
72
+
73
+ # Move +entry+ from one file to another.
74
+ #
75
+ # See method code in <tt>Model::Entry</tt> or <tt>Model::List</tt>.
76
+ def move_entry(entry, from, to); end
77
+
59
78
  # Write all loaded entries to +file+.
60
79
  def save_file(file)
61
80
  if @loaded.has_key? file
@@ -64,7 +83,7 @@ module PlainRecord
64
83
  end
65
84
  end
66
85
  end
67
-
86
+
68
87
  # Return all entries, which is match for +matchers+ and return true on
69
88
  # +block+.
70
89
  #
@@ -74,13 +93,13 @@ module PlainRecord
74
93
  # Post.all(title: 'Post title')
75
94
  # Post.all(title: /^Post/, summary: /cool/)
76
95
  # Post.all { |post| 20 < Post.content.length }
77
- def all(matchers = {}, &block)
78
- entries = all_entries
96
+ def all(matchers = { }, &block)
97
+ entries = all_entries(matchers)
79
98
  entries.delete_if { |i| not match(i, matchers) } if matchers
80
- entries.delete_if { |i| not block.call(i) } if block_given?
99
+ entries.delete_if { |i| not block.call(i) } if block_given?
81
100
  entries
82
101
  end
83
-
102
+
84
103
  # Return first entry, which is match for +matchers+ and return true on
85
104
  # +block+.
86
105
  #
@@ -90,11 +109,13 @@ module PlainRecord
90
109
  # Post.first(title: 'Post title')
91
110
  # Post.first(title: /^Post/, summary: /cool/)
92
111
  # Post.first { |post| 2 < Post.title.length }
93
- def first(matchers = {}, &block)
112
+ def first(matchers = { }, &block)
94
113
  if matchers and block_given?
95
- each_entry { |i| return i if match(i, matchers) and block.call(i) }
114
+ each_entry(matchers) do |i|
115
+ return i if match(i, matchers) and block.call(i)
116
+ end
96
117
  elsif matchers
97
- each_entry { |i| return i if match(i, matchers) }
118
+ each_entry(matchers) { |i| return i if match(i, matchers) }
98
119
  elsif block_given?
99
120
  each_entry { |i| return i if block.call(i) }
100
121
  else
@@ -102,29 +123,37 @@ module PlainRecord
102
123
  end
103
124
  nil
104
125
  end
105
-
106
- # Return all model files.
107
- def files
108
- Dir.glob(File.join(PlainRecord.root, @path))
126
+
127
+ # Return all file list for models, which match for +matchers+.
128
+ def files(matchers = { })
129
+ Dir.glob(PlainRecord.root(path(matchers)))
130
+ end
131
+
132
+ # Return glob pattern to for files with entris, which is may be match for
133
+ # +matchers+.
134
+ def path(matchers = { })
135
+ use_callbacks(:path, matchers) do
136
+ @path
137
+ end
109
138
  end
110
-
139
+
111
140
  private
112
-
113
- # Return all model entries.
141
+
142
+ # Return all model entries, which is may be match for +matchers+.
114
143
  #
115
144
  # See method code in <tt>Model::Entry</tt> or <tt>Model::List</tt>.
116
- def all_entries; end
117
-
145
+ def all_entries(matchers); end
146
+
118
147
  # Return string representation of +entries+ to write it to file.
119
148
  #
120
149
  # See method code in <tt>Model::Entry</tt> or <tt>Model::List</tt>.
121
150
  def entries_string(entries); end
122
-
151
+
123
152
  # Delete file, cache and empty dirs in path.
124
153
  def delete_file(file)
125
154
  File.delete(file)
126
155
  @loaded.delete(file)
127
-
156
+
128
157
  path = Pathname(file).dirname
129
158
  root = Pathname(PlainRecord.root)
130
159
  until 2 != path.entries.length or path == root
@@ -132,7 +161,7 @@ module PlainRecord
132
161
  path = path.parent
133
162
  end
134
163
  end
135
-
164
+
136
165
  # Match +object+ by +matchers+ to use in +all+ and +first+ methods.
137
166
  def match(object, matchers)
138
167
  matchers.each_pair do |key, matcher|
@@ -145,20 +174,19 @@ module PlainRecord
145
174
  end
146
175
  true
147
176
  end
148
-
177
+
149
178
  # Set glob +pattern+ for files with entry. Each file must contain one entry.
150
179
  # To set root for this path use +PlainRecord.root+.
151
180
  #
152
181
  # Also add methods from <tt>Model::Entry</tt>.
153
182
  #
154
- # entry_in 'content/*/post.m'
183
+ # entry_in 'content/*/post.md'
155
184
  def entry_in(path)
156
185
  @storage = :entry
157
186
  @path = path
158
187
  self.extend PlainRecord::Model::Entry
159
- @loaded = {}
160
188
  end
161
-
189
+
162
190
  # Set glob +pattern+ for files with list of entries. Each file may contain
163
191
  # several entries, but you may have several files. All data will storage
164
192
  # in YAML, so you can’t define +text+.
@@ -170,29 +198,55 @@ module PlainRecord
170
198
  @storage = :list
171
199
  @path = path
172
200
  self.extend PlainRecord::Model::List
173
- @loaded = {}
174
201
  end
175
-
176
- # Add property to model with some +name+. It will be stored as YAML.
202
+
203
+ # Add virtual property with some +name+ to model. It value willn’t be in
204
+ # file and will be calculated dynamically.
205
+ #
206
+ # You _must_ provide your own define logic by +definers+. Definer Proc
207
+ # will be call with property name in first argument and may return
208
+ # +:accessor+, +:writer+ or +:reader+ this method create standard methods
209
+ # to access to property.
210
+ #
211
+ # class Post
212
+ # include PlainRecord::Resource
213
+ #
214
+ # entry_in 'posts/*/post.md'
215
+ #
216
+ # virtual :name, in_filepath(1)
217
+ # end
218
+ def virtual(name, *definers)
219
+ @virtuals ||= []
220
+ @virtuals << name
221
+
222
+ accessors = call_definers(definers, name, :virtual)
223
+
224
+ if accessors[:reader] or accessors[:writer]
225
+ raise ArgumentError, 'You must provide you own accessors for virtual ' +
226
+ "property #{name}"
227
+ end
228
+ end
229
+
230
+ # Add property with some +name+ to model. It will be stored as YAML.
177
231
  #
178
232
  # You can provide your own define logic by +definers+. Definer Proc
179
233
  # will be call with property name in first argument and may return
180
234
  # +:accessor+, +:writer+ or +:reader+ this method create standard methods
181
235
  # to access to property.
182
- #
236
+ #
183
237
  # class Post
184
238
  # include PlainRecord::Resource
185
239
  #
186
- # entry_in 'posts/*/post.m'
240
+ # entry_in 'posts/*/post.md'
187
241
  #
188
242
  # property :title
189
243
  # end
190
244
  def property(name, *definers)
191
245
  @properties ||= []
192
246
  @properties << name
193
-
194
- accessors = call_definers(definers, name)
195
-
247
+
248
+ accessors = call_definers(definers, name, :property)
249
+
196
250
  if accessors[:reader]
197
251
  class_eval <<-EOS, __FILE__, __LINE__
198
252
  def #{name}
@@ -208,7 +262,7 @@ module PlainRecord
208
262
  EOS
209
263
  end
210
264
  end
211
-
265
+
212
266
  # Add special property with big text (for example, blog entry content). It
213
267
  # will stored after 3 dashes (<tt>---</tt>).
214
268
  #
@@ -223,19 +277,19 @@ module PlainRecord
223
277
  # == Example
224
278
  #
225
279
  # Model:
226
- #
280
+ #
227
281
  # class Post
228
282
  # include PlainRecord::Resource
229
283
  #
230
- # entry_in 'posts/*/post.m'
284
+ # entry_in 'posts/*/post.md'
231
285
  #
232
286
  # property :title
233
287
  # text :summary
234
288
  # text :content
235
289
  # end
236
- #
290
+ #
237
291
  # File:
238
- #
292
+ #
239
293
  # title: Post title
240
294
  # ---
241
295
  # Post summary
@@ -245,13 +299,13 @@ module PlainRecord
245
299
  if :list == @storage
246
300
  raise ArgumentError, 'Text is supported by only entry_in models'
247
301
  end
248
-
302
+
249
303
  @texts ||= []
250
304
  @texts << name
251
305
  number = @texts.length - 1
252
-
253
- accessors = call_definers(definers, name)
254
-
306
+
307
+ accessors = call_definers(definers, name, :text)
308
+
255
309
  if accessors[:reader]
256
310
  class_eval <<-EOS, __FILE__, __LINE__
257
311
  def #{name}
@@ -267,14 +321,15 @@ module PlainRecord
267
321
  EOS
268
322
  end
269
323
  end
270
-
271
- # Call +definers+ for property with +name+ and return accessors, which will
324
+
325
+ # Call +definers+ from +caller+ (<tt>:virtual</tt>, <tt>:property</tt> or
326
+ # <tt>:text</tt>) for property with +name+ and return accessors, which will
272
327
  # be created as standart by +property+ or +text+ method.
273
- def call_definers(definers, name)
274
- accessors = {:reader => true, :writer => true}
275
-
328
+ def call_definers(definers, name, caller)
329
+ accessors = { :reader => true, :writer => true }
330
+
276
331
  definers.each do |definer|
277
- access = definer.call(name)
332
+ access = definer.call(name, caller)
278
333
  if :writer == access or access.nil?
279
334
  accessors[:reader] = false
280
335
  end
@@ -282,7 +337,7 @@ module PlainRecord
282
337
  accessors[:writer] = false
283
338
  end
284
339
  end
285
-
340
+
286
341
  accessors
287
342
  end
288
343
  end
@@ -21,11 +21,27 @@ module PlainRecord
21
21
  # Module to be included into model class. Contain instance methods. See
22
22
  # Model for class methods.
23
23
  #
24
+ # You can set your callbacks before and after some methods/events:
25
+ # * <tt>path(matchers)</tt> – return file names for model which is match for
26
+ # matchers;
27
+ # * <tt>load(enrty)</tt> – load or create new entry;
28
+ # * <tt>destroy(entry)</tt> – delete entry;
29
+ # * <tt>save(entry)</tt> – write entry to file.
30
+ # See PlainRecord::Callbacks for details.
31
+ #
32
+ # You can define properties from entry file path, by +in_filepath+ definer.
33
+ # See PlainRecord::Filepath for details.
34
+ #
24
35
  # class Post
25
36
  # include PlainRecord::Resource
26
- #
27
- # entry_in '/content/*/post.m'
28
- #
37
+ #
38
+ # entry_in 'content/*/post.md'
39
+ #
40
+ # before :save do |enrty|
41
+ # entry.title = Time.now.to.s unless entry.title
42
+ # end
43
+ #
44
+ # virtual :name, in_filepath(1)
29
45
  # property :title
30
46
  # text :summary
31
47
  # text :content
@@ -36,39 +52,76 @@ module PlainRecord
36
52
  base.send :extend, Model
37
53
  end
38
54
  end
39
-
55
+
40
56
  # Properties values.
41
57
  attr_reader :data
42
-
58
+
43
59
  # Texts values.
44
60
  attr_reader :texts
45
-
61
+
46
62
  # File, where this object is stored.
47
- attr_reader :file
48
-
63
+ attr_accessor :file
64
+
49
65
  # Create new model instance with YAML +data+ and +texts+ from +file+.
50
- def initialize(file, data, texts = [])
51
- @file = file
52
- @data = data
53
- @texts = texts
66
+ def initialize(file = nil, data = { }, texts = [])
67
+ self.class.use_callbacks(:load, self) do
68
+ texts, data = data, nil if data.is_a? Array
69
+ data, file = file, nil if file.is_a? Hash
70
+
71
+ @file = file
72
+ @data = data
73
+ @texts = texts
74
+ end
54
75
  end
55
-
76
+
77
+ # Set path to entry storage. File should be in <tt>PlainRecord.root</tt> and
78
+ # can be relative.
79
+ def file=(value)
80
+ if PlainRecord.root != value.slice(0...PlainRecord.root.length)
81
+ value = PlainRecord.root(value)
82
+ end
83
+
84
+ if @file != value
85
+ self.class.move_entry(self, @file, value)
86
+ @file = value
87
+ end
88
+ end
89
+
90
+ # Return relative path to +file+ from <tt>PlainRecord.root</tt>.
91
+ def path
92
+ return nil unless @file
93
+ @file.slice(PlainRecord.root.length..-1)
94
+ end
95
+
56
96
  # Save entry to file. Note, that for in_list models it also save all other
57
97
  # entries in file.
58
98
  def save
59
- self.class.save_file(@file)
99
+ self.class.use_callbacks(:save, self) do
100
+ unless @file
101
+ unless self.class.path =~ /[\*\[\?\{]/
102
+ self.file = self.class.path
103
+ else
104
+ raise ArgumentError, "There isn't file to save entry. " +
105
+ "Set filepath properties or file."
106
+ end
107
+ end
108
+
109
+ self.class.save_file(@file)
110
+ end
60
111
  end
61
-
112
+
62
113
  # Delete current entry and it file if there isn’t has any other entries.
63
114
  def destroy
64
- self.class.delete_entry(@file, self)
115
+ self.class.use_callbacks(:destroy, self) do
116
+ self.class.delete_entry(@file, self)
117
+ end
65
118
  end
66
-
119
+
67
120
  # Return string of YAML representation of entry +data+.
68
- def to_yaml(opts = {})
121
+ def to_yaml(opts = { })
69
122
  @data.to_yaml(opts)
70
123
  end
71
-
124
+
72
125
  # Compare if its properties and texts are equal.
73
126
  def eql?(other)
74
127
  return false unless other.kind_of?(self.class)
@@ -1,3 +1,3 @@
1
1
  module PlainRecord
2
- VERSION = '0.1' unless defined? PlainRecord::VERSION
2
+ VERSION = '0.2' unless defined? PlainRecord::VERSION
3
3
  end
data/lib/plain_record.rb CHANGED
@@ -20,14 +20,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
20
20
  require 'pathname'
21
21
  require 'yaml'
22
22
 
23
+ YAML::ENGINE.yamler = 'syck' if defined? YAML::ENGINE
24
+
23
25
  dir = Pathname(__FILE__).dirname.expand_path + 'plain_record'
24
26
  require dir + 'version'
27
+ require dir + 'callbacks'
28
+ require dir + 'filepath'
29
+ require dir + 'association_proxy'
30
+ require dir + 'associations'
25
31
  require dir + 'model'
26
32
  require dir + 'resource'
27
33
 
28
34
  module PlainRecord
29
35
  class << self
30
- # Root of all file path in Model#entry_in.
31
- attr_accessor :root
36
+ # Set new root for Model#entry_in or Model#list_in.
37
+ #
38
+ # Note, that it add last slash to root path (<tt>/content</tt> will be saved
39
+ # as <tt>/content/</tt>).
40
+ def root=(value)
41
+ value += File::SEPARATOR if File::SEPARATOR != value[-1..-1]
42
+ @root = value
43
+ end
44
+
45
+ # Return root for Model#entry_in or Model#list_in.
46
+ #
47
+ # If you set +path+ it will be added to root path.
48
+ def root(path = '')
49
+ File.join(@root, path)
50
+ end
32
51
  end
33
52
  end
@@ -0,0 +1,30 @@
1
+ require './lib/plain_record/version'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.platform = Gem::Platform::RUBY
5
+ s.name = 'plain_record'
6
+ s.version = PlainRecord::VERSION
7
+ s.date = Time.now.strftime('%Y-%m-%d')
8
+ s.summary = 'Data persistence, which use human editable and ' +
9
+ 'readable plain text files.'
10
+ s.description = <<-EOF
11
+ Plain Record is a data persistence, which use human editable and
12
+ readable plain text files. It’s ideal for static generated sites,
13
+ like blog or homepage.
14
+ EOF
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {spec}/*`.split("\n")
18
+ s.extra_rdoc_files = ['README.md', 'LICENSE', 'ChangeLog']
19
+ s.require_path = 'lib'
20
+
21
+ s.author = 'Andrey "A.I." Sitnik'
22
+ s.email = 'andrey@sitnik.ru'
23
+ s.homepage = 'https://github.com/ai/plain_record'
24
+
25
+ s.add_development_dependency "bundler", [">= 1.0.10"]
26
+ s.add_development_dependency "yard", [">= 0"]
27
+ s.add_development_dependency "rake", [">= 0"]
28
+ s.add_development_dependency "rspec", [">= 0"]
29
+ s.add_development_dependency "redcarpet", [">= 0"]
30
+ end
@@ -0,0 +1,142 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ describe PlainRecord::Associations do
4
+
5
+ before :all do
6
+ class ::Rate
7
+ include PlainRecord::Resource
8
+ end
9
+
10
+ class ::RatedPost
11
+ include PlainRecord::Resource
12
+ entry_in 'data/3/post.md'
13
+ property :rate, one(::Rate)
14
+ end
15
+
16
+ class ::Comment
17
+ include PlainRecord::Resource
18
+
19
+ list_in 'data/*/comments.yml'
20
+
21
+ virtual :commented_post_name, in_filepath(1)
22
+ virtual :commented_post, one(::FilepathPost)
23
+
24
+ property :author_name
25
+ property :text
26
+ property :answers, many(::Comment)
27
+ end
28
+
29
+ class ::CommentedPost
30
+ include PlainRecord::Resource
31
+ entry_in 'data/*/post.md'
32
+ virtual :name, in_filepath(1)
33
+ virtual :comments, many(::Comment)
34
+ end
35
+ end
36
+
37
+ it "shouldn't create association for text" do
38
+ lambda {
39
+ Class.new do
40
+ include PlainRecord::Resource
41
+ text :one, one(Post)
42
+ end
43
+ }.should raise_error(ArgumentError, /text creator/)
44
+
45
+ lambda {
46
+ Class.new do
47
+ include PlainRecord::Resource
48
+ text :many, many(Post)
49
+ end
50
+ }.should raise_error(ArgumentError, /text creator/)
51
+ end
52
+
53
+ it "should load one-to-one real association" do
54
+ rate = ::RatedPost.first().rate
55
+ rate.should be_instance_of(::Rate)
56
+ rate.path.should == 'data/3/post.md'
57
+ rate.data.should == { 'subject' => 5, 'text' => 2 }
58
+ end
59
+
60
+ it "should save one-to-one real association" do
61
+ file = StringIO.new
62
+ File.should_receive(:open).with(anything(), 'w').and_yield(file)
63
+
64
+ ::RatedPost.first().save()
65
+
66
+ file.should has_yaml({ 'title' => 'Third',
67
+ 'rate' => { 'text' => 2, 'subject' => 5 } })
68
+ end
69
+
70
+ it "should load one-to-many real association" do
71
+ root = ::Comment.first()
72
+ root.should have(1).answers
73
+ root.answers[0].should be_instance_of(::Comment)
74
+ root.answers[0].path.should == 'data/1/comments.yml'
75
+ root.answers[0].data.should == { 'author_name' => 'john',
76
+ 'text' => 'Thanks',
77
+ 'answers' => [] }
78
+ end
79
+
80
+ it "should save one-to-many real association" do
81
+ file = StringIO.new
82
+ File.should_receive(:open).with(anything(), 'w').and_yield(file)
83
+
84
+ ::Comment.first().save()
85
+
86
+ file.should has_yaml([
87
+ {
88
+ 'author_name' => 'super1997',
89
+ 'text' => 'Cool!',
90
+ 'answers' => [{ 'author_name' => 'john', 'text' => 'Thanks' }]
91
+ }
92
+ ])
93
+ end
94
+
95
+ it "should find map for virtual association" do
96
+ PlainRecord::Associations.map(
97
+ ::Comment, ::CommentedPost, 'commented_post_').should == {
98
+ :commented_post_name => :name }
99
+ end
100
+
101
+ it "should load one-to-one virtual association" do
102
+ post = ::FilepathPost.first(:name => '1')
103
+ comment = ::Comment.first(:author_name => 'super1997')
104
+ comment.commented_post.should == post
105
+ end
106
+
107
+ it "should change one-to-one virtual association" do
108
+ post = ::FilepathPost.first(:name => '2')
109
+ comment = ::Comment.first(:author_name => 'super1997')
110
+ comment.commented_post = post
111
+
112
+ post.name.should == '1'
113
+ comment.commented_post.should == post
114
+ end
115
+
116
+ it "should load one-to-many virtual association" do
117
+ post = ::CommentedPost.first(:name => '1')
118
+ post.should have(1).comments
119
+ post.comments.first.should == ::Comment.first(:author_name => 'super1997')
120
+ end
121
+
122
+ it "should add new item to one-to-many virtual association" do
123
+ post = ::CommentedPost.first(:name => '1')
124
+ comment = ::Comment.new
125
+ post.comments << comment
126
+
127
+ post.should have(2).comments
128
+ post.comments[1].should == comment
129
+ comment.commented_post_name.should == post.name
130
+ end
131
+
132
+ it "should create new one-to-many association" do
133
+ post = ::CommentedPost.new(:name => 'new')
134
+ comment = ::Comment.new
135
+ post.comments = [comment]
136
+
137
+ post.should have(1).comments
138
+ post.comments.first.should == comment
139
+ comment.commented_post_name.should == post.name
140
+ end
141
+
142
+ end