shrine 3.8.0 → 3.9.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55c3fc9bf7eb40700d2be0ee82900986c601b1b3449d5d5ee7b246546bfe5eb3
4
- data.tar.gz: 11ffe3b196b79946c931f5a0e9538bda74cc4abd74711124509c76a13a5eaddc
3
+ metadata.gz: fc3e1cb064bc4b1fc0132c5d97c244506b65014eac06b0e8181cf44b2319bf67
4
+ data.tar.gz: c289af323f41d3cd70dbe740439c9a08f595bba81150a396639cecd92b8b53af
5
5
  SHA512:
6
- metadata.gz: 19585a7a2c486d43fb9be2abc84123adced4d18beebf50919938a9c631d16c0c2ecb15e777397b5b8a1642911171d328c55bf86769b9a31a4a3957cf3d8256d9
7
- data.tar.gz: 1dfbb667941b5f2bfa1b035230f0b1fb809c59855fc253f8365394624cffd4e3078a0aa41188eb27c8e57be8d4243e45d5dcf56a83b4d9ea1900bf02fa340dc6
6
+ metadata.gz: 128cb224b8199aba9b4a24a0b911c90bc88e404fc9746b440b3edafe19b28422506edf67f4c01474701a9d9a56d5bc4a874152741239a8ff05bc1fa6449a6fb4
7
+ data.tar.gz: 2582f06b0c087d56cea3e9056bd0763a8d50613dc86bc2707593a3322da0d4be018a507189ca96b63f70715309d8f469f3bc536fcf4c5b3ab859d54697e9910b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 3.9.0 (2026-07-13)
2
+
3
+ * `Shrine.find_storage` now raises `Shrine::MissingStorage` (subclass of `Shrine::Error`) when the storage isn't registered, so it can be rescued separately (@janko)
4
+
5
+ * `url_options` – Allow regular expression storage keys for compatibility with `dynamic_storage` plugin (@janko)
6
+
7
+ * `upload_options` – Allow regular expression storage keys for compatibility with `dynamic_storage` plugin (@janko)
8
+
9
+ * `remove_invalid` – Fix model attribute not being cleared/reverted after deassigning invalid file (@janko)
10
+
11
+ * `activerecord`/`sequel` – Skip deleting the previous attachment when the record being saved was just created, to avoid deleting the original attachment of a duplicated record (@janko)
12
+
13
+ * `tempfile` – Allow `UploadedFile#tempfile` to be called without opening the uploaded file first (@janko)
14
+
15
+ * `activerecord` – Add `:attribute_types` option for skipping serialization of JSON/JSONB attributes declared via the Attributes API, not just database columns (@vojtad, @janko)
16
+
1
17
  ## 3.8.0 (2026-06-24)
2
18
 
3
19
  * `s3` – Use single-request uploads for smaller files again instead of always using multipart uploads (@janko)
data/doc/attacher.md CHANGED
@@ -189,7 +189,7 @@ attacher.upload(file, location: "path/to/file") # setting upload locat
189
189
 
190
190
  ### Changes
191
191
 
192
- When a new file is attached, calling [`Attacher#finalize`](#finalization) will
192
+ When a new file is attached, calling [`Attacher#finalize`](#finalizing) will
193
193
  perform additional actions such as promotion and deleting any previous file.
194
194
  It will also trigger [validation].
195
195
 
data/doc/design.md CHANGED
@@ -9,8 +9,8 @@ There are five main types of classes that you deal with in Shrine:
9
9
 
10
10
  | Class | Description |
11
11
  | :---- | :---------- |
12
- | [`Shrine::Storage::*`](#Storage) | Manages files on a particular storage service |
13
- | [`Shrine`](#Shrine) | Wraps uploads and handles loading plugins |
12
+ | [`Shrine::Storage::*`](#storage) | Manages files on a particular storage service |
13
+ | [`Shrine`](#shrine) | Wraps uploads and handles loading plugins |
14
14
  | [`Shrine::UploadedFile`](#shrineuploadedfile) | Represents a file uploaded to a storage |
15
15
  | [`Shrine::Attacher`](#shrineattacher) | Handles file attachment logic |
16
16
  | [`Shrine::Attachment`](#shrineattachment) | Provides convenience model attachment interface |
@@ -60,7 +60,7 @@ end
60
60
  ```
61
61
 
62
62
  Storages are typically not used directly, but through [`Shrine`](#shrine) and
63
- [`Shrine::UploadedFile`](#shrine-uploadedfile) classes.
63
+ [`Shrine::UploadedFile`](#shrineuploadedfile) classes.
64
64
 
65
65
  ## `Shrine`
66
66
 
@@ -80,6 +80,62 @@ Active Record currently has a [bug with transaction callbacks], so if you have
80
80
  any "after commit" callbacks, make sure to include Shrine's attachment module
81
81
  *after* they have all been defined.
82
82
 
83
+ #### Duplicating records
84
+
85
+ Since a record being created can't yet have a confirmed attachment of its own
86
+ to safely replace, Shrine never deletes the previous file when the attachment
87
+ changes as part of *creating* a record, only when *updating* one. This
88
+ matters most when duplicating a record: `#dup` performs a shallow copy, so a
89
+ duplicated record initially points to the *same* underlying file as the
90
+ original (they'll have the same attached file data), and replacing the
91
+ attachment on the duplicate before it's ever saved won't affect the original:
92
+
93
+ ```rb
94
+ photo = Photo.create(image: file)
95
+ photo2 = photo.dup
96
+
97
+ photo2.update!(image: new_file) # replaces the attachment on `photo2`
98
+ photo.image.exists? #=> true (not affected)
99
+ ```
100
+
101
+ Once `photo2` has been saved, replacing its attachment again behaves
102
+ normally (the previously attached file is deleted):
103
+
104
+ ```rb
105
+ previous_image = photo2.image
106
+ photo2.update!(image: another_file)
107
+ previous_image.exists? #=> false
108
+ ```
109
+
110
+ Keep in mind this only protects against *replacing* the attachment on create.
111
+ As long as `photo` and `photo2` continue to reference the same underlying
112
+ file (i.e. `photo2` is saved without ever changing its attachment), destroying
113
+ either record will still delete the file the other one references, since
114
+ Shrine has no way of knowing the file is shared:
115
+
116
+ ```rb
117
+ photo2.save! # still references the same file as `photo`
118
+
119
+ photo2.destroy
120
+ photo.image.exists? #=> false
121
+ ```
122
+
123
+ If you want the duplicated record to have its own independent copy of the
124
+ file from the start, so that destroying either record is also safe, upload a
125
+ new copy explicitly after duplicating:
126
+
127
+ ```rb
128
+ photo = Photo.create(image: file)
129
+ photo2 = photo.dup
130
+
131
+ photo2.image_attacher.set(nil)
132
+ photo2.image_attacher.attach(photo.image, storage: photo.image.storage_key)
133
+ photo2.save!
134
+
135
+ photo2.destroy # no longer affects `photo`
136
+ photo.image.exists? #=> true
137
+ ```
138
+
83
139
  #### Overriding callbacks
84
140
 
85
141
  You can override any of the following attacher methods to modify callback
@@ -177,6 +233,39 @@ model errors, you can set `:validations` to `false`:
177
233
  plugin :activerecord, validations: false
178
234
  ```
179
235
 
236
+ ### JSON columns
237
+
238
+ When the data attribute is backed by a `json` or `jsonb` database column,
239
+ Active Record already serializes hashes into JSON, so Shrine skips its own
240
+ serialization to avoid double encoding.
241
+
242
+ ```rb
243
+ class Photo < ActiveRecord::Base # `image_data` is a jsonb column
244
+ include ImageUploader::Attachment(:image)
245
+ end
246
+ ```
247
+ ```rb
248
+ photo.image = file
249
+ photo.image_data #=> { "id" => "bc2e13.jpg", "storage" => "cache", "metadata" => { ... } }
250
+ ```
251
+
252
+ By default this detection only looks at real database columns. If you declare
253
+ the data attribute type via the [Attributes API] (e.g. on top of a `text`
254
+ column), enable the `:attribute_types` option so it's recognized as well:
255
+
256
+ ```rb
257
+ plugin :activerecord, attribute_types: true
258
+ ```
259
+ ```rb
260
+ class Photo < ActiveRecord::Base # `image_data` is a text column
261
+ include ImageUploader::Attachment(:image)
262
+
263
+ attribute :image_data, :json
264
+ end
265
+ ```
266
+
267
+ This option defaults to `false` for backwards compatibility.
268
+
180
269
  ## Attacher
181
270
 
182
271
  You can also use `Shrine::Attacher` directly (with or without the
@@ -217,8 +306,10 @@ See [persistence] docs for more details.
217
306
 
218
307
  [activerecord]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/activerecord.rb
219
308
  [Active Record]: https://guides.rubyonrails.org/active_record_basics.html
309
+ [Attributes API]: https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute
220
310
  [model]: https://shrinerb.com/docs/plugins/model
221
311
  [callbacks]: https://guides.rubyonrails.org/active_record_callbacks.html
222
312
  [bug with transaction callbacks]: https://github.com/rails/rails/issues/14493
223
313
  [validation]: https://shrinerb.com/docs/plugins/validation
224
314
  [persistence]: https://shrinerb.com/docs/plugins/persistence
315
+ [Replacing]: https://shrinerb.com/docs/attacher#replacing
@@ -203,6 +203,32 @@ class MyUploader < Shrine
203
203
  end
204
204
  ```
205
205
 
206
+ ## Testing
207
+
208
+ Since promotion happens in a background job, it won't have run yet
209
+ immediately after you save the record, so asserting on the promoted file's
210
+ data or location will fail unless the job has actually been executed. For
211
+ example, with Active Job you'll need to run enqueued jobs inline:
212
+
213
+ ```rb
214
+ require "active_job/test_helper"
215
+
216
+ include ActiveJob::TestHelper
217
+
218
+ perform_enqueued_jobs do
219
+ photo = Photo.create(image: file) # spawns promote job
220
+ end
221
+
222
+ photo.reload # fetch attachment data updated by the background job
223
+ photo.image.storage_key #=> :store
224
+ ```
225
+
226
+ Keep in mind that while the file is cached (i.e. before the promote job has
227
+ run), the record may not have an `id` yet, so plugins like [`pretty_location`]
228
+ that build the storage location from the record's identifier won't be able
229
+ to include it until promotion happens.
230
+
206
231
  [backgrounding]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/backgrounding.rb
232
+ [`pretty_location`]: https://shrinerb.com/docs/plugins/pretty_location
207
233
  [derivatives]: https://shrinerb.com/docs/plugins/derivatives
208
234
  [atomic_helpers]: https://shrinerb.com/docs/plugins/atomic_helpers
@@ -22,4 +22,19 @@ the bucket "foo". The block is yielded an instance of `MatchData`.
22
22
 
23
23
  This can be useful in combination with the `default_storage` plugin.
24
24
 
25
+ If the resolver block returns `nil` (e.g. it looks up a record that no longer
26
+ exists), `Shrine.find_storage` raises `Shrine::MissingStorage`, which can be
27
+ rescued separately from other `Shrine::Error` subclasses:
28
+
29
+ ```rb
30
+ storage /store_(\w+)/ do |match|
31
+ Library.find_by(id: match[1])&.storage
32
+ end
33
+ ```
34
+
35
+ ```rb
36
+ attacher.file.storage
37
+ # => raises Shrine::MissingStorage if the Library record was deleted
38
+ ```
39
+
25
40
  [dynamic_storage]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/dynamic_storage.rb
@@ -100,6 +100,63 @@ set `:hooks` to `false`:
100
100
  plugin :sequel, hooks: false
101
101
  ```
102
102
 
103
+ #### Duplicating records
104
+
105
+ Since a record being created can't yet have a confirmed attachment of its own
106
+ to safely replace, Shrine never deletes the previous file when the attachment
107
+ changes as part of *creating* a record, only when *updating* one.
108
+
109
+ Note that Sequel's `#dup`/`#clone`, unlike Active Record's, don't reset the
110
+ primary key or persistence state — a duplicated record still refers to the
111
+ *same* row, so saving it just updates that row rather than inserting a new
112
+ one. There's only ever one row here, so there's nothing to protect:
113
+
114
+ ```rb
115
+ photo = Photo.create(image: file)
116
+ photo2 = photo.dup # `photo2` refers to the same row as `photo`
117
+
118
+ photo2.update(image: new_file)
119
+ photo.image.exists? #=> false (the row now has `new_file`, so this is expected)
120
+ ```
121
+
122
+ The common way to duplicate a record in Sequel is instead to construct a new
123
+ one from the original's values, which *does* produce a genuinely new,
124
+ unpersisted row, and so *is* protected:
125
+
126
+ ```rb
127
+ photo2 = Photo.new(photo.values.except(:id))
128
+
129
+ photo2.update(image: new_file)
130
+ photo.image.exists? #=> true (not affected)
131
+ ```
132
+
133
+ Keep in mind this only protects against *replacing* the attachment on create.
134
+ As long as `photo` and `photo2` continue to reference the same underlying
135
+ file (i.e. `photo2` is saved without ever changing its attachment),
136
+ destroying either record will still delete the file the other one
137
+ references, since Shrine has no way of knowing the file is shared:
138
+
139
+ ```rb
140
+ photo2.save # still references the same file as `photo`
141
+
142
+ photo2.destroy
143
+ photo.image.exists? #=> false
144
+ ```
145
+
146
+ If you want `photo2` to have its own independent copy of the file from the
147
+ start, so that destroying either record is also safe, upload a new copy
148
+ explicitly:
149
+
150
+ ```rb
151
+ photo2 = Photo.new(photo.values.reject { |k, _| k == :id })
152
+ photo2.image_attacher.set(nil)
153
+ photo2.image_attacher.attach(photo.image, storage: photo.image.storage_key)
154
+ photo2.save
155
+
156
+ photo2.destroy # no longer affects `photo`
157
+ photo.image.exists? #=> true
158
+ ```
159
+
103
160
  ### Validations
104
161
 
105
162
  If you're using the [`validation`][validation] plugin, the attachment module
@@ -9,14 +9,22 @@ uploaded file on disk.
9
9
  Shrine.plugin :tempfile
10
10
  ```
11
11
 
12
- The plugin provides the `UploadedFile#tempfile` method, which when called on an
13
- open uploaded file will return a copy of its content on disk. The first time
14
- the method is called the file content will cached into a temporary file and
15
- returned. On any subsequent method calls the cached temporary file will be
16
- returned directly. The temporary file is deleted when the uploaded file is
17
- closed.
12
+ The plugin provides the `UploadedFile#tempfile` method, which returns a copy
13
+ of the uploaded file's content on disk. The first time the method is called
14
+ the file content will be downloaded into a temporary file and returned. On
15
+ any subsequent method calls the cached temporary file will be returned
16
+ directly. If the uploaded file is currently open, its tempfile is deleted
17
+ when the uploaded file is closed; otherwise it's deleted whenever it becomes
18
+ unreachable and is garbage collected (so it's still recommended to close the
19
+ uploaded file when you're done with it, to have the tempfile cleaned up
20
+ deterministically).
18
21
 
19
22
  ```rb
23
+ uploaded_file.tempfile #=> #<Tempfile:...> (file is downloaded and cached)
24
+ uploaded_file.tempfile #=> #<Tempfile:...> (cache is returned)
25
+
26
+ # OR
27
+
20
28
  uploaded_file.open do
21
29
  # ...
22
30
  uploaded_file.tempfile #=> #<Tempfile:...> (file is cached)
@@ -29,4 +29,16 @@ the uploader.
29
29
  uploader.upload(file, upload_options: { acl: "public-read" })
30
30
  ```
31
31
 
32
+ If storage keys are generated dynamically (e.g. via the
33
+ [`dynamic_storage`][dynamic_storage] plugin), it's not possible to list every
34
+ storage key upfront. In that case you can use a `Regexp` instead, which will
35
+ be matched against the storage key:
36
+
37
+ ```rb
38
+ plugin :upload_options, /_store\z/ => { acl: "private" }
39
+ ```
40
+
41
+ An exact storage key match always takes precedence over a `Regexp` match.
42
+
32
43
  [upload_options]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/upload_options.rb
44
+ [dynamic_storage]: https://shrinerb.com/docs/plugins/dynamic_storage
@@ -24,4 +24,16 @@ In both cases the default options are merged with options passed to
24
24
  `UploadedFile#url`, and the latter will always have precedence over default
25
25
  options.
26
26
 
27
+ If storage keys are generated dynamically (e.g. via the
28
+ [`dynamic_storage`][dynamic_storage] plugin), it's not possible to list every
29
+ storage key upfront. In that case you can use a `Regexp` instead, which will
30
+ be matched against the storage key:
31
+
32
+ ```rb
33
+ plugin :url_options, /_store\z/ => { expires_in: 24*60*60 }
34
+ ```
35
+
36
+ An exact storage key match always takes precedence over a `Regexp` match.
37
+
27
38
  [url_options]: https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/url_options.rb
39
+ [dynamic_storage]: https://shrinerb.com/docs/plugins/dynamic_storage
@@ -0,0 +1,42 @@
1
+ ---
2
+ title: Shrine 3.9.0
3
+ ---
4
+
5
+ ## New features
6
+
7
+ * `Shrine.find_storage` now raises `Shrine::MissingStorage` (a subclass of `Shrine::Error`) when the storage isn't registered, instead of a generic `Shrine::Error`. This is useful with the `dynamic_storage` plugin, where the resolver might fail to find its dependencies (e.g. a deleted DB record), and lets you rescue that case separately from other errors.
8
+
9
+ ```rb
10
+ begin
11
+ Shrine.find_storage(:store)
12
+ rescue Shrine::MissingStorage
13
+ # handle missing storage specifically
14
+ end
15
+ ```
16
+
17
+ * The `url_options` and `upload_options` plugins now accept a `Regexp` in place of a storage key. This is useful when storage keys are generated dynamically (e.g. via the `dynamic_storage` plugin), since it's not possible to list every storage key upfront. An exact storage key match always takes precedence over a `Regexp` match.
18
+
19
+ ```rb
20
+ plugin :url_options, /_store\z/ => { expires_in: 24*60*60 }
21
+ plugin :upload_options, /_store\z/ => { acl: "private" }
22
+ ```
23
+
24
+ * The `activerecord` plugin now accepts an `:attribute_types` option. When enabled, it uses `type_for_attribute` (instead of just `columns_hash`) to detect whether the data attribute is JSON/JSONB, so Shrine also skips its own serialization for attributes declared via the Active Record Attributes API (not just real database columns). It defaults to `false` for backwards compatibility with apps that might be relying on the previous double-serialization behavior.
25
+
26
+ ```rb
27
+ class Photo < ActiveRecord::Base # `image_data` is a text column
28
+ include ImageUploader::Attachment(:image)
29
+
30
+ attribute :image_data, :json
31
+ end
32
+
33
+ plugin :activerecord, attribute_types: true
34
+ ```
35
+
36
+ ## Bug fixes
37
+
38
+ * The `activerecord` and `sequel` plugins no longer delete the previous attachment when the record being saved was just created. Previously, duplicating a persisted record (e.g. via `#dup`) and immediately assigning a new attachment before saving the duplicate could end up deleting the *original* record's attachment, since both records shared the same underlying file.
39
+
40
+ * The `remove_invalid` plugin now correctly clears/reverts the model attribute after an invalid file is deassigned. Previously the record's raw attribute could be left out of sync with the attacher after validation failed.
41
+
42
+ * `UploadedFile#tempfile` (from the `tempfile` plugin) can now be called without opening the uploaded file first. Previously it raised `Shrine::Error` unless the file was already open; now it downloads and caches the file on demand. If the uploaded file is open, its tempfile is still cleaned up when the file is closed; otherwise it's cleaned up whenever it becomes unreachable and is garbage collected, so it's still recommended to close the uploaded file when you're done with it for deterministic cleanup.
@@ -20,7 +20,7 @@ class Shrine
20
20
 
21
21
  # Initializes the attacher from a data hash generated from `Attacher#data`.
22
22
  #
23
- # attacher = Attacher.from_data({ "id" => "...", "storage" => "...", "metadata" => { ... } })
23
+ # attacher = Attacher.from_data({ "id" => "...", "storage" => "...", "metadata" => {} })
24
24
  # attacher.file #=> #<Shrine::UploadedFile>
25
25
  def from_data(data, **)
26
26
  attacher = new(**)
@@ -60,8 +60,8 @@ class Shrine
60
60
  # useful when the uploaded file comes from form fields). Forwards any
61
61
  # additional options to #attach_cached.
62
62
  #
63
- # attacher.assign(File.open(...))
64
- # attacher.assign(File.open(...), metadata: { "foo" => "bar" })
63
+ # attacher.assign(File.open(path))
64
+ # attacher.assign(File.open(path), metadata: { "foo" => "bar" })
65
65
  # attacher.assign('{"id":"...","storage":"cache","metadata":{...}}')
66
66
  # attacher.assign({ "id" => "...", "storage" => "cache", "metadata" => {} })
67
67
  #
@@ -82,10 +82,10 @@ class Shrine
82
82
  # #attach.
83
83
  #
84
84
  # # upload file to temporary storage and set the uploaded file.
85
- # attacher.attach_cached(File.open(...))
85
+ # attacher.attach_cached(File.open(path))
86
86
  #
87
87
  # # foward additional options to the uploader
88
- # attacher.attach_cached(File.open(...), metadata: { "foo" => "bar" })
88
+ # attacher.attach_cached(File.open(path), metadata: { "foo" => "bar" })
89
89
  #
90
90
  # # sets an existing cached file from JSON data
91
91
  # attacher.attach_cached('{"id":"...","storage":"cache","metadata":{...}}')
@@ -189,7 +189,7 @@ class Shrine
189
189
  # attacher.destroy_previous
190
190
  # previous_file.exists? #=> false
191
191
  def destroy_previous
192
- @previous.destroy_attached if changed?
192
+ @previous.destroy_attached if changed? && destroy_previous?
193
193
  end
194
194
 
195
195
  # Destroys the attached file if it exists and is uploaded to permanent
@@ -299,7 +299,7 @@ class Shrine
299
299
  # Loads the uploaded file from data generated by `Attacher#data`.
300
300
  #
301
301
  # attacher.file #=> nil
302
- # attacher.load_data({ "id" => "...", "storage" => "...", "metadata" => { ... } })
302
+ # attacher.load_data({ "id" => "...", "storage" => "...", "metadata" => {} })
303
303
  # attacher.file #=> #<Shrine::UploadedFile>
304
304
  def load_data(data)
305
305
  @file = data && uploaded_file(data)
@@ -356,7 +356,7 @@ class Shrine
356
356
  # #=> #<Shrine::UploadedFile>
357
357
  #
358
358
  # # from Hash data
359
- # attacher.cached({ "id" => "...", "storage" => "cache", "metadata" => { ... } })
359
+ # attacher.cached({ "id" => "...", "storage" => "cache", "metadata" => {} })
360
360
  # #=> #<Shrine::UploadedFile>
361
361
  def cached(value, **)
362
362
  uploaded_file = uploaded_file(value)
@@ -380,6 +380,11 @@ class Shrine
380
380
  attached? && !cached?
381
381
  end
382
382
 
383
+ # Whether the previous attached file should be deleted.
384
+ def destroy_previous?
385
+ true
386
+ end
387
+
383
388
  # Whether assigning the given file is considered a change.
384
389
  def change?(file)
385
390
  @file != file
@@ -12,7 +12,7 @@ class Shrine
12
12
  end
13
13
 
14
14
  def self.configure(uploader, **opts)
15
- uploader.opts[:activerecord] ||= { callbacks: true, validations: true }
15
+ uploader.opts[:activerecord] ||= { callbacks: true, validations: true, attribute_types: false }
16
16
  uploader.opts[:activerecord].merge!(opts)
17
17
  end
18
18
 
@@ -108,12 +108,22 @@ class Shrine
108
108
  record.transaction { yield record.clone.reload(lock: true) }
109
109
  end
110
110
 
111
- # Returns true if the data attribute represents a JSON or JSONB column.
111
+ # Returns true if the data attribute represents a JSON or JSONB type.
112
112
  # Used by the _persistence plugin to determine whether serialization
113
113
  # should be skipped.
114
+ #
115
+ # By default only real database columns are checked. When the
116
+ # `:attribute_types` plugin option is enabled, the check uses
117
+ # `type_for_attribute`, so attributes declared via the Attributes API
118
+ # are recognized as well.
114
119
  def activerecord_hash_attribute?
115
- column = record.class.columns_hash[attribute.to_s]
116
- column && [:json, :jsonb].include?(column.type)
120
+ if shrine_class.opts[:activerecord][:attribute_types]
121
+ attribute_type = record.class.type_for_attribute(attribute.to_s)
122
+ attribute_type && [:json, :jsonb].include?(attribute_type.type)
123
+ else
124
+ column = record.class.columns_hash[attribute.to_s]
125
+ column && [:json, :jsonb].include?(column.type)
126
+ end
117
127
  end
118
128
 
119
129
  # Returns whether the record is an ActiveRecord model. Used by the
@@ -121,6 +131,17 @@ class Shrine
121
131
  def activerecord?
122
132
  record.is_a?(::ActiveRecord::Base)
123
133
  end
134
+
135
+ # Prevents the previous file from being destroyed when this save
136
+ # created the record (as opposed to updating an existing one), since
137
+ # a record being created can't yet have its own confirmed attachment
138
+ # to safely replace (e.g. when it was duplicated via `#dup` from
139
+ # another, still-persisted record).
140
+ def destroy_previous?
141
+ return super unless activerecord?
142
+
143
+ super && !record.previously_new_record?
144
+ end
124
145
  end
125
146
  end
126
147
 
@@ -47,7 +47,7 @@ class Shrine
47
47
  # attacher.promote_block do |attacher|
48
48
  # Attachment::PromoteJob.perform_async(
49
49
  # attacher.record,
50
- # attacher.name
50
+ # attacher.name,
51
51
  # attacher.file_data,
52
52
  # )
53
53
  # end
@@ -54,7 +54,7 @@ class Shrine
54
54
  # Converts the column data hash into a string (generates JSON by
55
55
  # default).
56
56
  #
57
- # Attacher.serialize_column({ "id" => "...", "storage" => "...", "metadata" => { ... } })
57
+ # Attacher.serialize_column({ "id" => "...", "storage" => "...", "metadata" => {} })
58
58
  # #=> '{"id":"...","storage":"...","metadata":{...}}'
59
59
  #
60
60
  # Attacher.serialize_column(nil)
@@ -431,12 +431,12 @@ class Shrine
431
431
  # attacher.load_data({
432
432
  # "id" => "...",
433
433
  # "storage" => "store",
434
- # "metadata" => { ... },
434
+ # "metadata" => {},
435
435
  # "derivatives" => {
436
436
  # "thumb" => {
437
437
  # "id" => "...",
438
438
  # "storage" => "store",
439
- # "metadata" => { ... },
439
+ # "metadata" => {},
440
440
  # }
441
441
  # }
442
442
  # })
@@ -471,7 +471,7 @@ class Shrine
471
471
 
472
472
  # Sets a hash of derivatives.
473
473
  #
474
- # attacher.derivatives = { thumb: Shrine.uploaded_file(...) }
474
+ # attacher.derivatives = { thumb: Shrine.uploaded_file(data) }
475
475
  # attacher.derivatives #=> { thumb: #<Shrine::UploadedFile ...> }
476
476
  def derivatives=(derivatives)
477
477
  unless derivatives.is_a?(Hash)
@@ -483,7 +483,9 @@ class Shrine
483
483
 
484
484
  # Iterates through nested derivatives and maps results.
485
485
  #
486
- # attacher.map_derivative(derivatives) { |path, file| ... }
486
+ # attacher.map_derivative(derivatives) do |path, file|
487
+ # # ...
488
+ # end
487
489
  def map_derivative(derivatives, **options, &block)
488
490
  shrine_class.map_derivative(derivatives, **options, &block)
489
491
  end
@@ -26,6 +26,10 @@ class Shrine
26
26
  else
27
27
  load_data nil
28
28
  end
29
+
30
+ # `load_data` bypasses `Attacher#set`, so when the model plugin is
31
+ # loaded the record attribute needs to be synced manually.
32
+ write if respond_to?(:write)
29
33
  end
30
34
  end
31
35
  end
@@ -87,8 +87,11 @@ class Shrine
87
87
  end
88
88
  end
89
89
 
90
- # Calls Attacher#save. Called before model save.
90
+ # Calls Attacher#save. Called before model save. Remembers whether
91
+ # the record is new, since by the time `#sequel_after_save` runs,
92
+ # Sequel has already flipped `#new?` to false.
91
93
  def sequel_before_save
94
+ @previously_new_record = record.new?
92
95
  save
93
96
  end
94
97
 
@@ -132,6 +135,17 @@ class Shrine
132
135
  def sequel?
133
136
  record.is_a?(::Sequel::Model)
134
137
  end
138
+
139
+ # Prevents the previous file from being destroyed when this save
140
+ # created the record (as opposed to updating an existing one), since
141
+ # a record being created can't yet have its own confirmed attachment
142
+ # to safely replace (e.g. when it was duplicated from another,
143
+ # still-persisted record).
144
+ def destroy_previous?
145
+ return super unless sequel?
146
+
147
+ super && !@previously_new_record
148
+ end
135
149
  end
136
150
  end
137
151
 
@@ -19,8 +19,6 @@ class Shrine
19
19
 
20
20
  module FileMethods
21
21
  def tempfile
22
- raise Error, "uploaded file must be opened" unless @io
23
-
24
22
  @tempfile ||= download
25
23
  @tempfile.rewind
26
24
  @tempfile
@@ -4,9 +4,9 @@ class Shrine
4
4
  module Plugins
5
5
  # Documentation can be found on https://shrinerb.com/docs/plugins/upload_options
6
6
  module UploadOptions
7
- def self.configure(uploader, **opts)
7
+ def self.configure(uploader, options = {})
8
8
  uploader.opts[:upload_options] ||= {}
9
- uploader.opts[:upload_options].merge!(opts)
9
+ uploader.opts[:upload_options].merge!(options)
10
10
  end
11
11
 
12
12
  module InstanceMethods
@@ -19,11 +19,25 @@ class Shrine
19
19
  end
20
20
 
21
21
  def get_upload_options(io, options)
22
- upload_options = opts[:upload_options][storage_key] || {}
22
+ upload_options = find_upload_options || {}
23
23
  upload_options = upload_options.call(io, options) if upload_options.respond_to?(:call)
24
24
  upload_options = upload_options.merge(options[:upload_options]) if options[:upload_options]
25
25
  upload_options
26
26
  end
27
+
28
+ # Matches the storage key exactly first, then falls back to any
29
+ # registered regex that matches the storage key. The regex form is
30
+ # useful when storage keys are generated dynamically (e.g. via the
31
+ # `dynamic_storage` plugin), since it's not possible to list every
32
+ # storage key upfront.
33
+ def find_upload_options
34
+ upload_options = opts[:upload_options]
35
+
36
+ return upload_options[storage_key] if upload_options.key?(storage_key)
37
+
38
+ _, options = upload_options.find { |key, _| key.is_a?(Regexp) && key.match?(storage_key.to_s) }
39
+ options
40
+ end
27
41
  end
28
42
  end
29
43
 
@@ -4,9 +4,9 @@ class Shrine
4
4
  module Plugins
5
5
  # Documentation can be found on https://shrinerb.com/docs/plugins/url_options
6
6
  module UrlOptions
7
- def self.configure(uploader, **opts)
7
+ def self.configure(uploader, options = {})
8
8
  uploader.opts[:url_options] ||= {}
9
- uploader.opts[:url_options].merge!(opts)
9
+ uploader.opts[:url_options].merge!(options)
10
10
  end
11
11
 
12
12
  module FileMethods
@@ -19,10 +19,24 @@ class Shrine
19
19
  private
20
20
 
21
21
  def url_options(options)
22
- default_options = shrine_class.opts[:url_options][storage_key]
22
+ default_options = find_url_options
23
23
  default_options = default_options.call(self, options) if default_options.respond_to?(:call)
24
24
  default_options || {}
25
25
  end
26
+
27
+ # Matches the storage key exactly first, then falls back to any
28
+ # registered regex that matches the storage key. The regex form is
29
+ # useful when storage keys are generated dynamically (e.g. via the
30
+ # `dynamic_storage` plugin), since it's not possible to list every
31
+ # storage key upfront.
32
+ def find_url_options
33
+ url_options = shrine_class.opts[:url_options]
34
+
35
+ return url_options[storage_key] if url_options.key?(storage_key)
36
+
37
+ _, options = url_options.find { |key, _| key.is_a?(Regexp) && key.match?(storage_key.to_s) }
38
+ options
39
+ end
26
40
  end
27
41
  end
28
42
 
@@ -7,7 +7,7 @@ class Shrine
7
7
 
8
8
  module VERSION
9
9
  MAJOR = 3
10
- MINOR = 8
10
+ MINOR = 9
11
11
  TINY = 0
12
12
  PRE = nil
13
13
 
data/lib/shrine.rb CHANGED
@@ -28,6 +28,10 @@ class Shrine
28
28
  class FileNotFound < Error
29
29
  end
30
30
 
31
+ # Raised by Shrine.find_storage when the storage isn't registered.
32
+ class MissingStorage < Error
33
+ end
34
+
31
35
  @opts = {}
32
36
  @storages = {}
33
37
  @logger = Logger.new(STDOUT)
@@ -86,7 +90,7 @@ class Shrine
86
90
  # Retrieves the storage under the given identifier (can be a Symbol or
87
91
  # a String), raising Shrine::Error if the storage is missing.
88
92
  def find_storage(name)
89
- storages[name.to_sym] || storages[name.to_s] or fail Error, "storage #{name.inspect} isn't registered on #{self}"
93
+ storages[name.to_sym] || storages[name.to_s] or fail MissingStorage, "storage #{name.inspect} isn't registered on #{self}"
90
94
  end
91
95
 
92
96
  # Generates an instance of Shrine::Attachment to be included in the
data/shrine.gemspec CHANGED
@@ -36,6 +36,8 @@ direct uploads for fully asynchronous user experience.
36
36
  gem.add_dependency "down", "~> 5.1"
37
37
  gem.add_dependency "content_disposition", "~> 1.0"
38
38
 
39
+ gem.add_development_dependency "rdoc", "~> 8.0" unless RUBY_ENGINE == "jruby"
40
+
39
41
  # general testing helpers
40
42
  gem.add_development_dependency "rake", ">= 11.1"
41
43
  gem.add_development_dependency "minitest", "~> 6.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shrine
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.8.0
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janko Marohnić
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rdoc
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '8.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '8.0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rake
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -155,6 +169,20 @@ dependencies:
155
169
  - - ">="
156
170
  - !ruby/object:Gem::Version
157
171
  version: '0'
172
+ - !ruby/object:Gem::Dependency
173
+ name: ruby-filemagic
174
+ requirement: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - "~>"
177
+ - !ruby/object:Gem::Version
178
+ version: '0.7'
179
+ type: :development
180
+ prerelease: false
181
+ version_requirements: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - "~>"
184
+ - !ruby/object:Gem::Version
185
+ version: '0.7'
158
186
  - !ruby/object:Gem::Dependency
159
187
  name: mime-types
160
188
  requirement: !ruby/object:Gem::Requirement
@@ -287,14 +315,14 @@ dependencies:
287
315
  requirements:
288
316
  - - "~>"
289
317
  - !ruby/object:Gem::Version
290
- version: 8.0.0
318
+ version: '8.1'
291
319
  type: :development
292
320
  prerelease: false
293
321
  version_requirements: !ruby/object:Gem::Requirement
294
322
  requirements:
295
323
  - - "~>"
296
324
  - !ruby/object:Gem::Version
297
- version: 8.0.0
325
+ version: '8.1'
298
326
  - !ruby/object:Gem::Dependency
299
327
  name: sequel
300
328
  requirement: !ruby/object:Gem::Requirement
@@ -315,14 +343,28 @@ dependencies:
315
343
  requirements:
316
344
  - - "~>"
317
345
  - !ruby/object:Gem::Version
318
- version: 8.0.0
346
+ version: '8.1'
319
347
  type: :development
320
348
  prerelease: false
321
349
  version_requirements: !ruby/object:Gem::Requirement
322
350
  requirements:
323
351
  - - "~>"
324
352
  - !ruby/object:Gem::Version
325
- version: 8.0.0
353
+ version: '8.1'
354
+ - !ruby/object:Gem::Dependency
355
+ name: sqlite3
356
+ requirement: !ruby/object:Gem::Requirement
357
+ requirements:
358
+ - - "~>"
359
+ - !ruby/object:Gem::Version
360
+ version: '2.1'
361
+ type: :development
362
+ prerelease: false
363
+ version_requirements: !ruby/object:Gem::Requirement
364
+ requirements:
365
+ - - "~>"
366
+ - !ruby/object:Gem::Version
367
+ version: '2.1'
326
368
  description: |
327
369
  Shrine is a toolkit for file attachments in Ruby applications. It supports
328
370
  uploading, downloading, processing and deleting IO objects, backed by various
@@ -456,6 +498,7 @@ files:
456
498
  - doc/release_notes/3.7.0.md
457
499
  - doc/release_notes/3.7.1.md
458
500
  - doc/release_notes/3.8.0.md
501
+ - doc/release_notes/3.9.0.md
459
502
  - doc/retrieving_uploads.md
460
503
  - doc/securing_uploads.md
461
504
  - doc/storage/file_system.md
@@ -549,7 +592,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
549
592
  - !ruby/object:Gem::Version
550
593
  version: '0'
551
594
  requirements: []
552
- rubygems_version: 4.0.3
595
+ rubygems_version: 4.0.13
553
596
  specification_version: 4
554
597
  summary: Toolkit for file attachments in Ruby applications
555
598
  test_files: []