strokedb 0.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.
Files changed (59) hide show
  1. data/CONTRIBUTORS +7 -0
  2. data/CREDITS +13 -0
  3. data/README +44 -0
  4. data/bin/sdbc +2 -0
  5. data/lib/config/config.rb +161 -0
  6. data/lib/data_structures/inverted_list.rb +297 -0
  7. data/lib/data_structures/point_query.rb +24 -0
  8. data/lib/data_structures/skiplist.rb +302 -0
  9. data/lib/document/associations.rb +107 -0
  10. data/lib/document/callback.rb +11 -0
  11. data/lib/document/coercions.rb +57 -0
  12. data/lib/document/delete.rb +28 -0
  13. data/lib/document/document.rb +684 -0
  14. data/lib/document/meta.rb +261 -0
  15. data/lib/document/slot.rb +199 -0
  16. data/lib/document/util.rb +27 -0
  17. data/lib/document/validations.rb +704 -0
  18. data/lib/document/versions.rb +106 -0
  19. data/lib/document/virtualize.rb +82 -0
  20. data/lib/init.rb +57 -0
  21. data/lib/stores/chainable_storage.rb +57 -0
  22. data/lib/stores/inverted_list_index/inverted_list_file_storage.rb +56 -0
  23. data/lib/stores/inverted_list_index/inverted_list_index.rb +49 -0
  24. data/lib/stores/remote_store.rb +172 -0
  25. data/lib/stores/skiplist_store/chunk.rb +119 -0
  26. data/lib/stores/skiplist_store/chunk_storage.rb +21 -0
  27. data/lib/stores/skiplist_store/file_chunk_storage.rb +44 -0
  28. data/lib/stores/skiplist_store/memory_chunk_storage.rb +37 -0
  29. data/lib/stores/skiplist_store/skiplist_store.rb +217 -0
  30. data/lib/stores/store.rb +5 -0
  31. data/lib/sync/chain_sync.rb +38 -0
  32. data/lib/sync/diff.rb +126 -0
  33. data/lib/sync/lamport_timestamp.rb +81 -0
  34. data/lib/sync/store_sync.rb +79 -0
  35. data/lib/sync/stroke_diff/array.rb +102 -0
  36. data/lib/sync/stroke_diff/default.rb +21 -0
  37. data/lib/sync/stroke_diff/hash.rb +186 -0
  38. data/lib/sync/stroke_diff/string.rb +116 -0
  39. data/lib/sync/stroke_diff/stroke_diff.rb +9 -0
  40. data/lib/util/blankslate.rb +42 -0
  41. data/lib/util/ext/blank.rb +50 -0
  42. data/lib/util/ext/enumerable.rb +36 -0
  43. data/lib/util/ext/fixnum.rb +16 -0
  44. data/lib/util/ext/hash.rb +22 -0
  45. data/lib/util/ext/object.rb +8 -0
  46. data/lib/util/ext/string.rb +35 -0
  47. data/lib/util/inflect.rb +217 -0
  48. data/lib/util/java_util.rb +9 -0
  49. data/lib/util/lazy_array.rb +54 -0
  50. data/lib/util/lazy_mapping_array.rb +64 -0
  51. data/lib/util/lazy_mapping_hash.rb +46 -0
  52. data/lib/util/serialization.rb +29 -0
  53. data/lib/util/trigger_partition.rb +136 -0
  54. data/lib/util/util.rb +38 -0
  55. data/lib/util/xml.rb +6 -0
  56. data/lib/view/view.rb +55 -0
  57. data/script/console +70 -0
  58. data/strokedb.rb +75 -0
  59. metadata +148 -0
@@ -0,0 +1,684 @@
1
+ module StrokeDB
2
+ # Slots which contain references to another documents are matched
3
+ # with these regexps.
4
+ DOCREF = /^@##{UUID_RE}$/
5
+ VERSIONREF = /^@##{UUID_RE}\.#{VERSION_RE}$/
6
+
7
+ #
8
+ # Raised on unexisting document access.
9
+ #
10
+ # Example:
11
+ #
12
+ # document.slot_that_does_not_exist_ever
13
+ #
14
+ class SlotNotFoundError < StandardError
15
+ attr_reader :slotname
16
+
17
+ def initialize(slotname)
18
+ @slotname = slotname
19
+ end
20
+
21
+ def message
22
+ "Can't find slot #{@slotname}"
23
+ end
24
+
25
+ def inspect
26
+ "#<#{self.class.name}: #{message}>"
27
+ end
28
+ end
29
+
30
+ #
31
+ # Raised when Document#save! is called on an invalid document
32
+ # (for which doc.valid? returns false)
33
+ #
34
+ class InvalidDocumentError < StandardError #:nodoc:
35
+ attr_reader :document
36
+
37
+ def initialize(document)
38
+ @document = document
39
+ end
40
+
41
+ def message
42
+ "Validation failed: #{@document.errors.messages.join(", ")}"
43
+ end
44
+
45
+ def inspect
46
+ "#<#{self.class.name}: #{message}>"
47
+ end
48
+ end
49
+
50
+ # Document is one of the core classes. It is being used to represent database document.
51
+ #
52
+ # Database document is an entity that:
53
+ #
54
+ # * is uniquely identified with UUID
55
+ # * has a number of slots, where each slot is a key-value pair (whereas pair could be a JSON object)
56
+ #
57
+ # Here is a simplistic example of document:
58
+ #
59
+ # <tt>1e3d02cc-0769-4bd8-9113-e033b246b013:</tt>
60
+ # name: "My Document"
61
+ # language: "English"
62
+ # authors: ["Yurii Rashkovskii","Oleg Andreev"]
63
+ #
64
+ class Document
65
+ include Validations::InstanceMethods
66
+
67
+ attr_reader :store, :callbacks #:nodoc:
68
+
69
+ def marshal_dump #:nodoc:
70
+ (@new ? '1' : '0') + (@saved ? '1' : '0') + to_raw.to_json
71
+ end
72
+
73
+ def marshal_load(content) #:nodoc:
74
+ @callbacks = {}
75
+ initialize_raw_slots(JSON.parse(content[2,content.length]))
76
+ @saved = content[1,1] == '1'
77
+ @new = content[0,1] == '1'
78
+ end
79
+
80
+ # Collection of meta documents
81
+ class Metas < Array #:nodoc:
82
+ def initialize(document)
83
+ @document = document
84
+ _meta = document[:meta]
85
+ concat [_meta].flatten.compact.map{|v| v.is_a?(DocumentReferenceValue) ? v.load : v}
86
+ end
87
+
88
+ def <<(meta)
89
+ add_meta(meta, :call_initialization_callbacks => true)
90
+ end
91
+
92
+ def add_meta(meta, opts = {})
93
+ opts = opts.stringify_keys
94
+ _module = nil
95
+
96
+ # meta can be specified both as a meta document and as a module
97
+ case meta
98
+ when Document
99
+ push meta
100
+ _module = StrokeDB::Document.collect_meta_modules(@document.store, meta).first
101
+ when Meta
102
+ push meta.document(@document.store)
103
+ _module = meta
104
+ else
105
+ raise ArgumentError, "Meta should be either document or meta module"
106
+ end
107
+
108
+ # register meta in the document
109
+ @document[:meta] = self
110
+
111
+ if _module
112
+ @document.extend(_module)
113
+
114
+ _module.send!(:setup_callbacks, @document) rescue nil
115
+
116
+ if opts['call_initialization_callbacks']
117
+ @document.send!(:execute_callbacks_for, _module, :on_initialization)
118
+ @document.send!(:execute_callbacks_for, _module, :on_new_document) if @document.new?
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ #
125
+ # Instantiates new document with given arguments (which are the same as in Document#new),
126
+ # and saves it right away
127
+ #
128
+ def self.create!(*args, &block)
129
+ new(*args, &block).save!
130
+ end
131
+
132
+ #
133
+ # Instantiates new document
134
+ #
135
+ # Here are few ways to call it:
136
+ #
137
+ # Document.new(:slot_1 => slot_1_value, :slot_2 => slot_2_value)
138
+ #
139
+ # This way new document with slots <tt>slot_1</tt> and <tt>slot_2</tt> will be initialized in the
140
+ # default store.
141
+ #
142
+ # Document.new(store,:slot_1 => slot_1_value, :slot_2 => slot_2_value)
143
+ #
144
+ # This way new document with slots <tt>slot_1</tt> and <tt>slot_2</tt> will be initialized in the
145
+ # given <tt>store</tt>.
146
+ #
147
+ # Document.new({:slot_1 => slot_1_value, :slot_2 => slot_2_value},uuid)
148
+ #
149
+ # where <tt>uuid</tt> is a string with UUID. *WARNING*: this way of initializing Document should not
150
+ # be used unless you know what are you doing!
151
+ #
152
+ def initialize(*args, &block)
153
+ @initialization_block = block
154
+
155
+ if args.first.is_a?(Hash) || args.empty?
156
+ raise NoDefaultStoreError unless StrokeDB.default_store
157
+ do_initialize(StrokeDB.default_store, *args)
158
+ else
159
+ do_initialize(*args)
160
+ end
161
+ end
162
+
163
+ #
164
+ # Get slot value by its name:
165
+ #
166
+ # document[:slot_1]
167
+ #
168
+ # If slot was not found, it will return <tt>nil</tt>
169
+ #
170
+ def [](slotname)
171
+ @slots[slotname.to_s].value rescue nil
172
+ end
173
+
174
+ #
175
+ # Set slot value by its name:
176
+ #
177
+ # document[:slot_1] = "some value"
178
+ #
179
+ def []=(slotname, value)
180
+ slotname = slotname.to_s
181
+
182
+ (@slots[slotname] ||= Slot.new(self, slotname)).value = value
183
+ update_version!(slotname)
184
+
185
+ value
186
+ end
187
+
188
+ #
189
+ # Checks slot presence. Unlike Document#slotnames it allows you to find even 'virtual slots' that could be
190
+ # computed runtime by associations or <tt>when_slot_found</tt> callbacks
191
+ #
192
+ # document.has_slot?(:slotname)
193
+ #
194
+ def has_slot?(slotname)
195
+ v = send(slotname)
196
+
197
+ (v.nil? && slotnames.include?(slotname.to_s)) ? true : !!v
198
+ rescue SlotNotFoundError
199
+ false
200
+ end
201
+
202
+ #
203
+ # Removes slot
204
+ #
205
+ # document.remove_slot!(:slotname)
206
+ #
207
+ def remove_slot!(slotname)
208
+ slotname = slotname.to_s
209
+
210
+ @slots.delete slotname
211
+ update_version! slotname
212
+
213
+ nil
214
+ end
215
+
216
+ #
217
+ # Returns an <tt>Array</tt> of explicitely defined slots
218
+ #
219
+ # document.slotnames #=> ["version","name","language","authors"]
220
+ #
221
+ def slotnames
222
+ @slots.keys
223
+ end
224
+
225
+ #
226
+ # Creates Diff document from <tt>from</tt> document to this document
227
+ #
228
+ # document.diff(original_document) #=> #<StrokeDB::Diff added_slots: {"b"=>2}, from: #<Doc a: 1>, removed_slots: {"a"=>1}, to: #<Doc b: 2>, updated_slots: {}>
229
+ #
230
+ def diff(from)
231
+ Diff.new(store, :from => from, :to => self)
232
+ end
233
+
234
+ def pretty_print #:nodoc:
235
+ slots = to_raw.except('meta')
236
+
237
+ s = is_a?(ImmutableDocument) ? "#<(imm)" : "#<"
238
+
239
+ Util.catch_circular_reference(self) do
240
+ if self[:meta] && name = meta[:name]
241
+ s << "#{name} "
242
+ else
243
+ s << "Doc "
244
+ end
245
+
246
+ slots.keys.sort.each do |k|
247
+ if %w(version previous_version).member?(k) && v = self[k]
248
+ s << "#{k}: #{v.gsub(/^(0)+/,'')[0,4]}..., "
249
+ else
250
+ s << "#{k}: #{self[k].inspect}, "
251
+ end
252
+ end
253
+
254
+ s.chomp!(', ')
255
+ s.chomp!(' ')
256
+ s << ">"
257
+ end
258
+
259
+ s
260
+ rescue Util::CircularReferenceCondition
261
+ "#(#{(self[:meta] ? "#{meta}" : "Doc")} #{('@#'+uuid)[0,5]}...)"
262
+ end
263
+
264
+ alias :to_s :pretty_print
265
+ alias :inspect :pretty_print
266
+
267
+ #
268
+ # Returns string with Document's JSON representation
269
+ #
270
+ def to_json
271
+ to_raw.to_json
272
+ end
273
+
274
+ #
275
+ # Returns string with Document's XML representation
276
+ #
277
+ def to_xml(opts = {})
278
+ to_raw.to_xml({ :root => 'document', :dasherize => true }.merge(opts))
279
+ end
280
+
281
+ #
282
+ # Primary serialization
283
+ #
284
+ def to_raw #:nodoc:
285
+ raw_slots = {}
286
+
287
+ @slots.each_pair do |k,v|
288
+ raw_slots[k.to_s] = v.to_raw
289
+ end
290
+
291
+ raw_slots
292
+ end
293
+
294
+ def to_optimized_raw #:nodoc:
295
+ __reference__
296
+ end
297
+
298
+ #
299
+ # Creates a document from a serialized representation
300
+ #
301
+ def self.from_raw(store, raw_slots, opts = {}) #:nodoc:
302
+ doc = new(store, raw_slots, true)
303
+
304
+ collect_meta_modules(store, raw_slots['meta']).each do |meta_module|
305
+ unless doc.is_a? meta_module
306
+ doc.extend(meta_module)
307
+
308
+ meta_module.send!(:setup_callbacks, doc) rescue nil
309
+ end
310
+ end
311
+
312
+ unless opts[:skip_callbacks]
313
+ doc.send! :execute_callbacks, :on_initialization
314
+ doc.send! :execute_callbacks, :on_load
315
+ end
316
+ doc
317
+ end
318
+
319
+ #
320
+ # Find document(s) by:
321
+ #
322
+ # a) UUID
323
+ #
324
+ # Document.find(uuid)
325
+ #
326
+ # b) search query
327
+ #
328
+ # Document.find(:slot => "value")
329
+ #
330
+ # If first argument is Store, that particular store will be used; otherwise default store will be assumed.
331
+ def self.find(*args)
332
+ store = nil
333
+ if args.empty? || args.first.is_a?(String) || args.first.is_a?(Hash)
334
+ store = StrokeDB.default_store
335
+ else
336
+ store = args.shift
337
+ end
338
+ raise NoDefaultStoreError.new unless store
339
+ query = args.first
340
+ case query
341
+ when UUID_RE
342
+ store.find(query)
343
+ when Hash
344
+ store.search(query)
345
+ else
346
+ raise TypeError
347
+ end
348
+ end
349
+
350
+ #
351
+ # Reloads head of the same document from store. All unsaved changes will be lost!
352
+ #
353
+ def reload
354
+ new? ? self : store.find(uuid)
355
+ end
356
+
357
+ #
358
+ # Returns <tt>true</tt> if this is a document that has never been saved.
359
+ #
360
+ def new?
361
+ !!@new
362
+ end
363
+
364
+ #
365
+ # Returns <tt>true</tt> if this document is a latest version of document being saved to a respective
366
+ # store
367
+ #
368
+ def head?
369
+ return false if new? || is_a?(VersionedDocument)
370
+ store.head_version(uuid) == version
371
+ end
372
+
373
+ #
374
+ # Saves the document. If validations do not pass, InvalidDocumentError
375
+ # exception is raised.
376
+ #
377
+ def save!(perform_validation = true)
378
+ execute_callbacks :before_save
379
+
380
+ if perform_validation
381
+ raise InvalidDocumentError.new(self) unless valid?
382
+ end
383
+
384
+ execute_callbacks :after_validation
385
+
386
+ store.save!(self)
387
+ @new = false
388
+ @saved = true
389
+
390
+ execute_callbacks :after_save
391
+
392
+ self
393
+ end
394
+
395
+ #
396
+ # Updates slots with specified <tt>hash</tt> and returns itself.
397
+ #
398
+ def update_slots(hash)
399
+ hash.each do |k, v|
400
+ self[k] = v
401
+ end
402
+
403
+ self
404
+ end
405
+
406
+ #
407
+ # Same as update_slots, but also saves the document.
408
+ #
409
+ def update_slots!(hash)
410
+ update_slots(hash).save!
411
+ end
412
+
413
+ #
414
+ # Returns document's metadocument (if any). In case if document has more than one metadocument,
415
+ # it will combine all metadocuments into one 'virtual' metadocument
416
+ #
417
+ def meta
418
+ unless (m = self[:meta]).kind_of? Array
419
+ # simple case
420
+ return m || Document.new(@store)
421
+ end
422
+
423
+ return m.first if m.size == 1
424
+
425
+ mm = m.clone
426
+ collected_meta = mm.shift.clone
427
+
428
+ names = collected_meta[:name].split(',') rescue []
429
+
430
+ mm.each do |next_meta|
431
+ next_meta = next_meta.clone
432
+ collected_meta += next_meta
433
+ names << next_meta.name if next_meta[:name]
434
+ end
435
+
436
+ collected_meta.name = names.uniq.join(',')
437
+ collected_meta.make_immutable!
438
+ end
439
+
440
+ #
441
+ # Instantiate a composite document
442
+ #
443
+ def +(document)
444
+ original, target = [to_raw, document.to_raw].map{ |raw| raw.except(*%w(uuid version previous_version)) }
445
+
446
+ Document.new(@store, original.merge(target).merge(:uuid => Util.random_uuid), true)
447
+ end
448
+
449
+ #
450
+ # Should be used to add metadocuments on the fly:
451
+ #
452
+ # document.metas << Buyer
453
+ # document.metas << Buyer.document
454
+ #
455
+ # Please not that it accept both meta modules and their documents, there is no difference
456
+ #
457
+ def metas
458
+ Metas.new(self)
459
+ end
460
+
461
+ #
462
+ # Returns document's version (which is stored in <tt>version</tt> slot)
463
+ #
464
+ def version
465
+ self[:version]
466
+ end
467
+
468
+ #
469
+ # Return document's uuid
470
+ #
471
+ def uuid
472
+ @uuid ||= self[:uuid]
473
+ end
474
+
475
+ #
476
+ # Returns document's previous version (which is stored in <tt>previous_version</tt> slot)
477
+ #
478
+ def previous_version
479
+ self[:previous_version]
480
+ end
481
+
482
+ def version=(v) #:nodoc:
483
+ self[:version] = v
484
+ end
485
+
486
+ #
487
+ # Returns an instance of Document::Versions
488
+ #
489
+ def versions
490
+ @versions ||= Versions.new(self)
491
+ end
492
+
493
+ def __reference__ #:nodoc:
494
+ "@##{uuid}.#{version}"
495
+ end
496
+
497
+ def ==(doc) #:nodoc:
498
+ case doc
499
+ when Document, DocumentReferenceValue
500
+ doc = doc.load if doc.kind_of? DocumentReferenceValue
501
+
502
+ # we make a quick UUID check here to skip two heavy to_raw calls
503
+ doc.uuid == uuid && doc.to_raw == to_raw
504
+ else
505
+ false
506
+ end
507
+ end
508
+
509
+ def eql?(doc) #:nodoc:
510
+ self == doc
511
+ end
512
+
513
+ # documents are hashed by their UUID
514
+ def hash #:nodoc:
515
+ uuid.hash
516
+ end
517
+
518
+ def make_immutable!
519
+ extend ImmutableDocument
520
+ self
521
+ end
522
+
523
+ def mutable?
524
+ true
525
+ end
526
+
527
+ def method_missing(sym, *args) #:nodoc:
528
+ sym = sym.to_s
529
+
530
+ return send(:[]=, sym.chomp('='), *args) if sym.ends_with? '='
531
+ return self[sym] if slotnames.include? sym
532
+ return !!send(sym.chomp('?'), *args) if sym.ends_with? '?'
533
+
534
+ raise SlotNotFoundError.new(sym) if (callbacks['when_slot_not_found'] || []).empty?
535
+
536
+ r = execute_callbacks(:when_slot_not_found, sym)
537
+ raise r if r.is_a? SlotNotFoundError # TODO: spec this behavior
538
+
539
+ r
540
+ end
541
+
542
+ def add_callback(cbk) #:nodoc:
543
+ name, uid = cbk.name, cbk.uid
544
+
545
+ callbacks[name] ||= []
546
+
547
+ # if uid is specified, previous callback with the same uid is deleted
548
+ if uid && old_cb = callbacks[name].find{ |cb| cb.uid == uid }
549
+ callbacks[name].delete old_cb
550
+ end
551
+
552
+ callbacks[name] << cbk
553
+ end
554
+
555
+ protected
556
+
557
+ # value of the last called callback is returned when executing callbacks
558
+ # (or nil if none found)
559
+ def execute_callbacks(name, *args) #:nodoc:
560
+ (callbacks[name.to_s] || []).inject(nil) do |prev_value, callback|
561
+ callback.call(self, *args)
562
+ end
563
+ end
564
+
565
+ def execute_callbacks_for(origin, name, *args) #:nodoc:
566
+ (callbacks[name.to_s] || []).inject(nil) do |prev_value, callback|
567
+ callback.origin == origin ? callback.call(self, *args) : prev_value
568
+ end
569
+ end
570
+
571
+ # initialize the document. initialize_raw is true when
572
+ # document is initialized from a raw serialized form
573
+ def do_initialize(store, slots={}, initialize_raw = false) #:nodoc:
574
+ @callbacks = {}
575
+ @store = store
576
+
577
+ if initialize_raw
578
+ initialize_raw_slots slots
579
+ @saved = true
580
+ else
581
+ @new = true
582
+ initialize_slots slots
583
+
584
+ self[:uuid] = Util.random_uuid unless self[:uuid]
585
+ generate_new_version! unless self[:version]
586
+ end
587
+ end
588
+
589
+ # initialize slots for a new, just created document
590
+ def initialize_slots(slots) #:nodoc:
591
+ @slots = {}
592
+ slots = slots.stringify_keys
593
+
594
+ # there is a reason for meta slot is initialized separately —
595
+ # we need to setup coercions before initializing actual slots
596
+ if meta = slots['meta']
597
+ meta = [meta] unless meta.is_a?(Array)
598
+ meta.each {|m| metas.add_meta(m) }
599
+ end
600
+
601
+ slots.except('meta').each {|name,value| self[name] = value }
602
+
603
+ # now, when we have all slots initialized, we can run initialization callbacks
604
+ execute_callbacks :on_initialization
605
+ execute_callbacks :on_new_document
606
+ end
607
+
608
+ # initialize slots from a raw representation
609
+ def initialize_raw_slots(slots) #:nodoc:
610
+ @slots = {}
611
+
612
+ slots.each do |name,value|
613
+ s = Slot.new(self, name)
614
+ s.raw_value = value
615
+
616
+ @slots[name.to_s] = s
617
+ end
618
+ end
619
+
620
+ # returns an array of meta modules (as constants) for a given something
621
+ # (a document reference, a document itself, or an array of the former)
622
+ def self.collect_meta_modules(store, meta) #:nodoc:
623
+ meta_names = []
624
+
625
+ case meta
626
+ when VERSIONREF
627
+ if m = store.find($1, $2)
628
+ meta_names << m[:name]
629
+ end
630
+ when DOCREF
631
+ if m = store.find($1)
632
+ meta_names << m[:name]
633
+ end
634
+ when Array
635
+ meta_names = meta.map { |m| collect_meta_modules(store, m) }.flatten
636
+ when Document
637
+ meta_names << meta[:name]
638
+ end
639
+
640
+ meta_names.map { |m| m.is_a?(String) ? (m.constantize rescue nil) : m }.compact
641
+ end
642
+
643
+ def generate_new_version!
644
+ self.version = Util.random_uuid
645
+ end
646
+
647
+ def update_version!(slotname)
648
+ if @saved && slotname != 'version' && slotname != 'previous_version'
649
+ self[:previous_version] = version unless version.nil?
650
+
651
+ generate_new_version!
652
+
653
+ @saved = nil
654
+ end
655
+ end
656
+ end
657
+
658
+ #
659
+ # VersionedDocument is a module that is being added to all document's of specific version.
660
+ # It should not be accessed directly
661
+ #
662
+ module VersionedDocument
663
+ #
664
+ # Reloads the same version of the same document from store. All unsaved changes will be lost!
665
+ #
666
+ def reload
667
+ store.find(uuid, version)
668
+ end
669
+ end
670
+
671
+ #
672
+ # ImmutableDocument can't be saved
673
+ # It should not be used directly, use Document#make_immutable! instead
674
+ #
675
+ module ImmutableDocument
676
+ def mutable?
677
+ false
678
+ end
679
+
680
+ def save!
681
+ self
682
+ end
683
+ end
684
+ end