plain_record 0.1 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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