Change soft-fail to use the config, rather than env
[rbx.git] / lib / pstore.rb
blob6df64474abd7cd29a4d2b45d06e8b256857a70cc
1 # = PStore -- Transactional File Storage for Ruby Objects
3 # pstore.rb -
4 #   originally by matz
5 #   documentation by Kev Jackson and James Edward Gray II
7 # See PStore for documentation.
10 require "fileutils"
11 require "digest/md5"
14 # PStore implements a file based persistance mechanism based on a Hash.  User
15 # code can store hierarchies of Ruby objects (values) into the data store file
16 # by name (keys).  An object hierarchy may be just a single object.  User code 
17 # may later read values back from the data store or even update data, as needed.
18
19 # The transactional behavior ensures that any changes succeed or fail together.
20 # This can be used to ensure that the data store is not left in a transitory
21 # state, where some values were upated but others were not.
22
23 # Behind the scenes, Ruby objects are stored to the data store file with 
24 # Marshal.  That carries the usual limitations.  Proc objects cannot be 
25 # marshalled, for example.
27 # == Usage example:
28
29 #  require "pstore"
30 #  
31 #  # a mock wiki object...
32 #  class WikiPage
33 #    def initialize( page_name, author, contents )
34 #      @page_name = page_name
35 #      @revisions = Array.new
36 #      
37 #      add_revision(author, contents)
38 #    end
39 #    
40 #    attr_reader :page_name
41 #    
42 #    def add_revision( author, contents )
43 #      @revisions << { :created  => Time.now,
44 #                      :author   => author,
45 #                      :contents => contents }
46 #    end
47 #    
48 #    def wiki_page_references
49 #      [@page_name] + @revisions.last[:contents].scan(/\b(?:[A-Z]+[a-z]+){2,}/)
50 #    end
51 #    
52 #    # ...
53 #  end
54 #  
55 #  # create a new page...
56 #  home_page = WikiPage.new( "HomePage", "James Edward Gray II",
57 #                            "A page about the JoysOfDocumentation..." )
58 #  
59 #  # then we want to update page data and the index together, or not at all...
60 #  wiki = PStore.new("wiki_pages.pstore")
61 #  wiki.transaction do  # begin transaction; do all of this or none of it
62 #    # store page...
63 #    wiki[home_page.page_name] = home_page
64 #    # ensure that an index has been created...
65 #    wiki[:wiki_index] ||= Array.new
66 #    # update wiki index...
67 #    wiki[:wiki_index].push(*home_page.wiki_page_references)
68 #  end                   # commit changes to wiki data store file
69 #  
70 #  ### Some time later... ###
71 #  
72 #  # read wiki data...
73 #  wiki.transaction(true) do  # begin read-only transaction, no changes allowed
74 #    wiki.roots.each do |data_root_name|
75 #      p data_root_name
76 #      p wiki[data_root_name]
77 #    end
78 #  end
80 class PStore
81   binmode = defined?(File::BINARY) ? File::BINARY : 0
82   RDWR_ACCESS = File::RDWR | File::CREAT | binmode
83   RD_ACCESS = File::RDONLY | binmode
84   WR_ACCESS = File::WRONLY | File::CREAT | File::TRUNC | binmode
86   # The error type thrown by all PStore methods.
87   class Error < StandardError
88   end
90   # 
91   # To construct a PStore object, pass in the _file_ path where you would like 
92   # the data to be stored.
93   # 
94   def initialize(file)
95     dir = File::dirname(file)
96     unless File::directory? dir
97       raise PStore::Error, format("directory %s does not exist", dir)
98     end
99     if File::exist? file and not File::readable? file
100       raise PStore::Error, format("file %s not readable", file)
101     end
102     @transaction = false
103     @filename = file
104     @abort = false
105   end
107   # Raises PStore::Error if the calling code is not in a PStore#transaction.
108   def in_transaction
109     raise PStore::Error, "not in transaction" unless @transaction
110   end
111   # 
112   # Raises PStore::Error if the calling code is not in a PStore#transaction or
113   # if the code is in a read-only PStore#transaction.
114   # 
115   def in_transaction_wr()
116     in_transaction()
117     raise PStore::Error, "in read-only transaction" if @rdonly
118   end
119   private :in_transaction, :in_transaction_wr
121   #
122   # Retrieves a value from the PStore file data, by _name_.  The hierarchy of 
123   # Ruby objects stored under that root _name_ will be returned.
124   # 
125   # *WARNING*:  This method is only valid in a PStore#transaction.  It will
126   # raise PStore::Error if called at any other time.
127   #
128   def [](name)
129     in_transaction
130     @table[name]
131   end
132   #
133   # This method is just like PStore#[], save that you may also provide a 
134   # _default_ value for the object.  In the event the specified _name_ is not 
135   # found in the data store, your _default_ will be returned instead.  If you do 
136   # not specify a default, PStore::Error will be raised if the object is not 
137   # found.
138   # 
139   # *WARNING*:  This method is only valid in a PStore#transaction.  It will
140   # raise PStore::Error if called at any other time.
141   #
142   def fetch(name, default=PStore::Error)
143     in_transaction
144     unless @table.key? name
145       if default==PStore::Error
146         raise PStore::Error, format("undefined root name `%s'", name)
147       else
148         return default
149       end
150     end
151     @table[name]
152   end
153   #
154   # Stores an individual Ruby object or a hierarchy of Ruby objects in the data
155   # store file under the root _name_.  Assigning to a _name_ already in the data
156   # store clobbers the old data.
157   # 
158   # == Example:
159   # 
160   #  require "pstore"
161   #  
162   #  store = PStore.new("data_file.pstore")
163   #  store.transaction do  # begin transaction
164   #    # load some data into the store...
165   #    store[:single_object] = "My data..."
166   #    store[:obj_heirarchy] = { "Kev Jackson" => ["rational.rb", "pstore.rb"],
167   #                              "James Gray"  => ["erb.rb", "pstore.rb"] }
168   #  end                   # commit changes to data store file
169   # 
170   # *WARNING*:  This method is only valid in a PStore#transaction and it cannot
171   # be read-only.  It will raise PStore::Error if called at any other time.
172   #
173   def []=(name, value)
174     in_transaction_wr()
175     @table[name] = value
176   end
177   #
178   # Removes an object hierarchy from the data store, by _name_.
179   # 
180   # *WARNING*:  This method is only valid in a PStore#transaction and it cannot
181   # be read-only.  It will raise PStore::Error if called at any other time.
182   #
183   def delete(name)
184     in_transaction_wr()
185     @table.delete name
186   end
188   #
189   # Returns the names of all object hierarchies currently in the store.
190   # 
191   # *WARNING*:  This method is only valid in a PStore#transaction.  It will
192   # raise PStore::Error if called at any other time.
193   #
194   def roots
195     in_transaction
196     @table.keys
197   end
198   #
199   # Returns true if the supplied _name_ is currently in the data store.
200   # 
201   # *WARNING*:  This method is only valid in a PStore#transaction.  It will
202   # raise PStore::Error if called at any other time.
203   #
204   def root?(name)
205     in_transaction
206     @table.key? name
207   end
208   # Returns the path to the data store file.
209   def path
210     @filename
211   end
213   #
214   # Ends the current PStore#transaction, committing any changes to the data
215   # store immediately.
216   # 
217   # == Example:
218   # 
219   #  require "pstore"
220   #   
221   #  store = PStore.new("data_file.pstore")
222   #  store.transaction do  # begin transaction
223   #    # load some data into the store...
224   #    store[:one] = 1
225   #    store[:two] = 2
226   #  
227   #    store.commit        # end transaction here, committing changes
228   #  
229   #    store[:three] = 3   # this change is never reached
230   #  end
231   # 
232   # *WARNING*:  This method is only valid in a PStore#transaction.  It will
233   # raise PStore::Error if called at any other time.
234   #
235   def commit
236     in_transaction
237     @abort = false
238     throw :pstore_abort_transaction
239   end
240   #
241   # Ends the current PStore#transaction, discarding any changes to the data
242   # store.
243   # 
244   # == Example:
245   # 
246   #  require "pstore"
247   #   
248   #  store = PStore.new("data_file.pstore")
249   #  store.transaction do  # begin transaction
250   #    store[:one] = 1     # this change is not applied, see below...
251   #    store[:two] = 2     # this change is not applied, see below...
252   #  
253   #    store.abort         # end transaction here, discard all changes
254   #  
255   #    store[:three] = 3   # this change is never reached
256   #  end
257   # 
258   # *WARNING*:  This method is only valid in a PStore#transaction.  It will
259   # raise PStore::Error if called at any other time.
260   #
261   def abort
262     in_transaction
263     @abort = true
264     throw :pstore_abort_transaction
265   end
267   #
268   # Opens a new transaction for the data store.  Code executed inside a block
269   # passed to this method may read and write data to and from the data store 
270   # file.
271   # 
272   # At the end of the block, changes are committed to the data store
273   # automatically.  You may exit the transaction early with a call to either 
274   # PStore#commit or PStore#abort.  See those methods for details about how
275   # changes are handled.  Raising an uncaught Exception in the block is 
276   # equivalent to calling PStore#abort.
277   # 
278   # If _read_only_ is set to +true+, you will only be allowed to read from the
279   # data store during the transaction and any attempts to change the data will
280   # raise a PStore::Error.
281   # 
282   # Note that PStore does not support nested transactions.
283   #
284   def transaction(read_only=false)  # :yields:  pstore
285     raise PStore::Error, "nested transaction" if @transaction
286     begin
287       @rdonly = read_only
288       @abort = false
289       @transaction = true
290       value = nil
291       new_file = @filename + ".new"
293       content = nil
294       unless read_only
295         file = File.open(@filename, RDWR_ACCESS)
296         file.flock(File::LOCK_EX)
297         commit_new(file) if FileTest.exist?(new_file)
298         content = file.read()
299       else
300         begin
301           file = File.open(@filename, RD_ACCESS)
302           file.flock(File::LOCK_SH)
303           content = (File.open(new_file, RD_ACCESS) {|n| n.read} rescue file.read())
304         rescue Errno::ENOENT
305           content = ""
306         end
307       end
309       if content != ""
310         @table = load(content)
311         if !read_only
312           size = content.size
313           md5 = Digest::MD5.digest(content)
314         end
315       else
316         @table = {}
317       end
318       content = nil             # unreference huge data
320       begin
321         catch(:pstore_abort_transaction) do
322           value = yield(self)
323         end
324       rescue Exception
325         @abort = true
326         raise
327       ensure
328         if !read_only and !@abort
329           tmp_file = @filename + ".tmp"
330           content = dump(@table)
331           if !md5 || size != content.size || md5 != Digest::MD5.digest(content)
332             File.open(tmp_file, WR_ACCESS) {|t| t.write(content)}
333             File.rename(tmp_file, new_file)
334             commit_new(file)
335           end
336           content = nil         # unreference huge data
337         end
338       end
339     ensure
340       @table = nil
341       @transaction = false
342       file.close if file
343     end
344     value
345   end
347   # This method is just a wrapped around Marshal.dump.
348   def dump(table)  # :nodoc:
349     Marshal::dump(table)
350   end
352   # This method is just a wrapped around Marshal.load.
353   def load(content)  # :nodoc:
354     Marshal::load(content)
355   end
357   # This method is just a wrapped around Marshal.load.
358   def load_file(file)  # :nodoc:
359     Marshal::load(file)
360   end
362   private
363   # Commits changes to the data store file.
364   def commit_new(f)
365     f.truncate(0)
366     f.rewind
367     new_file = @filename + ".new"
368     File.open(new_file, RD_ACCESS) do |nf|
369       FileUtils.copy_stream(nf, f)
370     end
371     File.unlink(new_file)
372   end
375 # :enddoc:
377 if __FILE__ == $0
378   db = PStore.new("/tmp/foo")
379   db.transaction do
380     p db.roots
381     ary = db["root"] = [1,2,3,4]
382     ary[1] = [1,1.5]
383   end
385   1000.times do
386     db.transaction do
387       db["root"][0] += 1
388       p db["root"][0]
389     end
390   end
392   db.transaction(true) do
393     p db["root"]
394   end