dis 1.2.0 → 1.3.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.
@@ -7,12 +7,18 @@ module Dis
7
7
  # Facilitates communication between the model and the storage,
8
8
  # and holds any newly assigned data before the record is saved.
9
9
  class Data
10
+ # @param record [ActiveRecord::Base] the model instance
11
+ # @param raw [File, IO, String, nil] newly assigned data
10
12
  def initialize(record, raw = nil)
11
13
  @record = record
12
14
  @raw = raw
13
15
  end
14
16
 
15
17
  # Returns true if two Data objects represent the same data.
18
+ #
19
+ # @param other [Dis::Model::Data, #read, Object] the object to
20
+ # compare
21
+ # @return [Boolean]
16
22
  def ==(other)
17
23
  if !raw? && other.is_a?(self.class) && !other.changed?
18
24
  content_hash == other.content_hash
@@ -24,24 +30,33 @@ module Dis
24
30
  end
25
31
 
26
32
  # Returns true if data exists either in memory or in storage.
33
+ #
34
+ # @return [Boolean]
27
35
  def any?
28
36
  raw? || stored?
29
37
  end
30
38
 
31
39
  # Returns the data as a binary string.
40
+ #
41
+ # @return [String, nil]
32
42
  def read
33
43
  @read ||= read_from(closest)
34
44
  end
35
45
 
36
- # Will be true if data has been explicitely set.
46
+ # Will be true if data has been explicitly set.
47
+ #
48
+ # @return [Boolean]
37
49
  #
50
+ # @example
38
51
  # Dis::Model::Data.new(record).changed? # => false
39
- # Dis::Model::Data.new(record, new_file).changed? # => true
52
+ # Dis::Model::Data.new(record, file).changed? # => true
40
53
  def changed?
41
54
  raw?
42
55
  end
43
56
 
44
- # Returns the length of the data.
57
+ # Returns the length of the data in bytes.
58
+ #
59
+ # @return [Integer]
45
60
  def content_length
46
61
  if raw? && raw.respond_to?(:length)
47
62
  raw.length
@@ -50,9 +65,13 @@ module Dis
50
65
  end
51
66
  end
52
67
 
53
- # Expires a data object from the storage if it's no longer being used
54
- # by existing records. This is triggered from callbacks on the record
55
- # whenever they are changed or destroyed.
68
+ # Expires a data object from the storage if it's no longer
69
+ # being used by existing records. This is triggered from
70
+ # callbacks on the record whenever they are changed or
71
+ # destroyed.
72
+ #
73
+ # @param hash [String] the content hash to expire
74
+ # @return [void]
56
75
  def expire(hash)
57
76
  return if hash.blank?
58
77
 
@@ -63,37 +82,53 @@ module Dis
63
82
  end
64
83
  end
65
84
 
66
- # Stores the data. Returns a hash of the content for reference.
85
+ # Stores the data and returns the content hash.
86
+ #
87
+ # @return [String] the SHA1 content hash
88
+ # @raise [Dis::Errors::NoDataError] if no data has been
89
+ # assigned
67
90
  def store!
68
91
  raise Dis::Errors::NoDataError unless raw?
69
92
 
70
93
  Dis::Storage.store(storage_type, raw)
71
94
  end
72
95
 
73
- # Clears cached data and tempfiles, allowing them to be garbage
74
- # collected. Subsequent calls to read or tempfile will re-fetch.
96
+ # Clears cached data, allowing it to be garbage collected.
97
+ # Subsequent calls to +read+ will re-fetch from storage.
98
+ #
99
+ # @return [void]
75
100
  def reset_read_cache!
76
101
  @read = nil
77
- return unless @tempfile
78
-
79
- @tempfile.close!
80
- @tempfile = nil
81
102
  end
82
103
 
83
- # Returns the file path to the data. Prefers a local storage path
84
- # to avoid unnecessary copies, falls back to a tempfile.
85
- def file_path
86
- local_path || tempfile.path
104
+ # Yields a path to the data, removing any temporary copy
105
+ # afterwards.
106
+ #
107
+ # @yieldparam path [Pathname] path to the data
108
+ # @return [Object] the return value of the block
109
+ def with_file
110
+ path = local_path
111
+ return yield(Pathname.new(path)) if path
112
+
113
+ file = materialize
114
+ begin
115
+ yield(Pathname.new(file.path))
116
+ ensure
117
+ close_and_unlink(file)
118
+ end
87
119
  end
88
120
 
89
- # Writes the data to a temporary file.
90
- def tempfile
91
- unless @tempfile
92
- @tempfile = Tempfile.new(binmode: true)
93
- @tempfile.write(@read || read_from(closest))
94
- @tempfile.open
95
- end
96
- @tempfile
121
+ # Returns the data as an open file. Any temporary copy is
122
+ # unlinked first, leaving the kernel to reclaim it on close.
123
+ #
124
+ # @return [File] an open file, positioned at the start
125
+ def open
126
+ path = local_path
127
+ return File.open(path, "rb") if path
128
+
129
+ file = materialize
130
+ File.unlink(file.path)
131
+ file
97
132
  end
98
133
 
99
134
  protected
@@ -155,6 +190,48 @@ module Dis
155
190
  Dis::Storage.file_path(storage_type, content_hash)
156
191
  end
157
192
 
193
+ # Writes the data to a new temporary file. The caller owns it.
194
+ def materialize
195
+ file = Tempfile.create
196
+ file.binmode
197
+ fill(file)
198
+ file.rewind
199
+ file
200
+ rescue StandardError
201
+ close_and_unlink(file) if file
202
+ raise
203
+ end
204
+
205
+ def fill(file)
206
+ if raw?
207
+ write_raw_to(file)
208
+ else
209
+ Dis::Storage.get_file(storage_type, content_hash, file)
210
+ end
211
+ end
212
+
213
+ def write_raw_to(file)
214
+ if raw.respond_to?(:read)
215
+ rewind_raw
216
+ IO.copy_stream(raw, file)
217
+ rewind_raw
218
+ else
219
+ file.write(raw)
220
+ end
221
+ file.flush
222
+ end
223
+
224
+ def rewind_raw
225
+ raw.rewind if raw.respond_to?(:rewind)
226
+ end
227
+
228
+ def close_and_unlink(file)
229
+ file.close unless file.closed?
230
+ File.unlink(file.path)
231
+ rescue Errno::ENOENT
232
+ nil
233
+ end
234
+
158
235
  attr_reader :raw
159
236
  end
160
237
  end
data/lib/dis/model.rb CHANGED
@@ -7,7 +7,7 @@ module Dis
7
7
  # = Dis Model
8
8
  #
9
9
  # ActiveModel extension for the model holding your data. To use it,
10
- # simply include the module in your model:
10
+ # include the module in your model:
11
11
  #
12
12
  # class Document < ActiveRecord::Base
13
13
  # include Dis::Model
@@ -36,24 +36,24 @@ module Dis
36
36
  #
37
37
  # == Usage
38
38
  #
39
- # To save a file, simply assign to the <tt>file</tt> attribute.
39
+ # To save a file, assign to the <tt>file</tt> attribute.
40
40
  #
41
41
  # document = Document.create(file: params.permit(:file))
42
42
  #
43
43
  # <tt>content_type</tt> and <tt>filename</tt> will automatically be set if
44
- # the supplied object quacks like a file. <tt>content_length</tt> will always
45
- # be set. <tt>content_hash</tt> won't be set until the record is being saved.
44
+ # the supplied object quacks like a file. <tt>content_length</tt> and
45
+ # <tt>content_hash</tt> will always be set.
46
46
  #
47
- # If you don't care about filenames and content types and just want to store
48
- # a binary blob, you can also just set the <tt>data</tt> attribute.
47
+ # To store a binary blob without filenames or content types, set the
48
+ # <tt>data</tt> attribute directly.
49
49
  #
50
50
  # my_data = File.read('document.pdf')
51
51
  # document.update(data: my_data)
52
52
  #
53
- # The data won't be stored until the record is saved, and not unless
53
+ # The data won't be stored until the record is saved, and only if
54
54
  # the record is valid.
55
55
  #
56
- # To retrieve your data, simply read the <tt>data</tt> attribute. The file
56
+ # To retrieve your data, read the <tt>data</tt> attribute. The file
57
57
  # will be lazily loaded from the store on demand and cached in memory as long
58
58
  # as the record stays in scope.
59
59
  #
@@ -73,7 +73,7 @@ module Dis
73
73
  # validates_data_presence
74
74
  # end
75
75
  #
76
- # If you want to validate content types, size or similar, simply use standard
76
+ # If you want to validate content types, size or similar, use standard
77
77
  # Rails validations on the metadata attributes:
78
78
  #
79
79
  # validates :content_type, presence: true, format: /\Aapplication\/pdf\z/
@@ -89,18 +89,26 @@ module Dis
89
89
  attribute :data, :binary
90
90
  end
91
91
 
92
- # Returns the data as a binary string, or nil if no data has been set.
92
+ # Returns the data as a binary string, or nil if no data has
93
+ # been set.
94
+ #
95
+ # @return [String, nil]
93
96
  def data
94
97
  dis_data.read
95
98
  end
96
99
 
97
100
  # Returns true if data is set.
101
+ #
102
+ # @return [Boolean]
98
103
  def data?
99
104
  dis_data.any?
100
105
  end
101
106
 
102
- # Assigns new data. This also sets <tt>content_length</tt>, and resets
103
- # <tt>content_hash</tt> to nil.
107
+ # Assigns new data. This also sets +content_length+ and
108
+ # +content_hash+.
109
+ #
110
+ # @param raw_data [File, IO, String, nil] the content to store
111
+ # @return [void]
104
112
  def data=(raw_data)
105
113
  new_data = Dis::Model::Data.new(self, raw_data)
106
114
  attribute_will_change!("data") unless new_data == dis_data
@@ -113,32 +121,67 @@ module Dis
113
121
  dis_set :content_length, dis_data.content_length
114
122
  end
115
123
 
116
- # Returns true if the data has been changed since the object was last saved.
124
+ # Returns true if the data has been changed since the object
125
+ # was last saved.
126
+ #
127
+ # @return [Boolean]
117
128
  def data_changed?
118
129
  changes.include?("data")
119
130
  end
120
131
 
132
+ # Returns true if the record has been persisted and its data
133
+ # has not been changed since the last save.
134
+ #
135
+ # @return [Boolean]
121
136
  def dis_stored?
122
137
  !(new_record? || data_changed?)
123
138
  end
124
139
 
125
- # Assigns new data from an uploaded file. In addition to the actions
126
- # performed by <tt>data=</tt>, this will set <tt>content_type</tt> and
127
- # <tt>filename</tt>.
140
+ # Assigns new data from an uploaded file. In addition to the
141
+ # actions performed by {#data=}, this will set +content_type+
142
+ # and +filename+.
143
+ #
144
+ # @param file [ActionDispatch::Http::UploadedFile,
145
+ # Rack::Test::UploadedFile] an uploaded file that responds to
146
+ # +content_type+ and +original_filename+
147
+ # @return [void]
128
148
  def file=(file)
129
149
  self.data = file
130
150
  dis_set :content_type, file.content_type
131
151
  dis_set :filename, file.original_filename
132
152
  end
133
153
 
134
- # Returns a file path to the data, preferring local storage paths.
135
- def data_file_path
136
- dis_data.file_path
154
+ # Yields the path to a file containing the data, for tools that
155
+ # need a file name rather than the bytes. The path is valid for
156
+ # the duration of the block only, so resolve anything lazy before
157
+ # returning.
158
+ #
159
+ # @yieldparam path [Pathname] path to the data
160
+ # @return [Object] the return value of the block
161
+ #
162
+ # @example
163
+ # document.with_data_file { |path| Vips::Image.new_from_file(path.to_s).avg }
164
+ def with_data_file(&)
165
+ dis_data.with_file(&)
166
+ end
167
+
168
+ # Returns the data as an open, read-only file, or yields it and
169
+ # closes it afterwards. The data stays readable until the file is
170
+ # closed, even if the content is deleted or evicted meanwhile.
171
+ #
172
+ # @yieldparam file [File] an open file, positioned at the start
173
+ # @return [File, Object] the open file, or the block's value
174
+ def open_data
175
+ file = dis_data.open
176
+ return file unless block_given?
177
+
178
+ begin
179
+ yield file
180
+ ensure
181
+ file.close unless file.closed?
182
+ end
137
183
  end
138
184
 
139
- # Returns the data as a temporary file.
140
- delegate :tempfile, to: :dis_data
141
-
142
185
  private
143
186
 
144
187
  def cleanup_data