saviour 0.4.5 → 0.4.6

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -18
  3. data/README.md +6 -9
  4. data/lib/saviour.rb +8 -0
  5. data/lib/saviour/base_uploader.rb +17 -4
  6. data/lib/saviour/config.rb +1 -1
  7. data/lib/saviour/db_helpers.rb +69 -0
  8. data/lib/saviour/file.rb +76 -26
  9. data/lib/saviour/integrator.rb +8 -3
  10. data/lib/saviour/life_cycle.rb +60 -12
  11. data/lib/saviour/local_storage.rb +35 -12
  12. data/lib/saviour/model.rb +12 -3
  13. data/lib/saviour/s3_storage.rb +46 -17
  14. data/lib/saviour/source_filename_extractor.rb +6 -1
  15. data/lib/saviour/uploader/processors_runner.rb +29 -2
  16. data/lib/saviour/url_source.rb +7 -3
  17. data/lib/saviour/validator.rb +34 -4
  18. data/lib/saviour/version.rb +1 -1
  19. data/saviour.gemspec +4 -3
  20. data/spec/feature/{allow_overriding_attached_as_method.rb → allow_overriding_attached_as_method_spec.rb} +0 -0
  21. data/spec/feature/crud_workflows_spec.rb +26 -10
  22. data/spec/feature/dirty_spec.rb +29 -21
  23. data/spec/feature/memory_usage_spec.rb +84 -0
  24. data/spec/feature/{processors_api.rb → processors_api_spec.rb} +22 -5
  25. data/spec/feature/{rewind_source_before_read.rb → rewind_source_before_read_spec.rb} +0 -0
  26. data/spec/feature/transactional_behavior_spec.rb +147 -0
  27. data/spec/feature/{uploader_declaration.rb → uploader_declaration_spec.rb} +0 -0
  28. data/spec/feature/validations_spec.rb +19 -0
  29. data/spec/feature/with_copy_spec.rb +43 -0
  30. data/spec/models/base_uploader_spec.rb +12 -33
  31. data/spec/models/config_spec.rb +2 -2
  32. data/spec/models/file_spec.rb +19 -47
  33. data/spec/models/local_storage_spec.rb +13 -34
  34. data/spec/models/s3_storage_spec.rb +12 -37
  35. data/spec/models/url_source_spec.rb +4 -4
  36. data/spec/spec_helper.rb +4 -0
  37. metadata +29 -14
  38. data/gemfiles/4.0.gemfile +0 -9
  39. data/gemfiles/4.1.gemfile +0 -9
  40. data/gemfiles/4.2.gemfile +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6a8dde206061328305a93bbcbcf03ef6e15d9659
4
- data.tar.gz: 828b58ec76d1974a88bf595ea2306cded359f752
3
+ metadata.gz: 5b1d3de91b6a03bd4e483d2012346575d1fb444d
4
+ data.tar.gz: eca43877819318d52cabb2bcc6a02f71f131ee03
5
5
  SHA512:
6
- metadata.gz: 0a13bc0dced7f96b5306a6ef7dbb72f01a66d8b9f9f962b4de99697eea8212db71f706abb9f7d12ef1cb29ae5a879d329c408937ddffd9f225d1c35ae4bb4e76
7
- data.tar.gz: dee3ef8dab84f49536c9c311d23e97cbe66ee6e1e5278fe48f02267720dfa7178a424b814746ba0dd1fdf67d4cd8e213c11f37d17e26144fa1bd281e40a801ea
6
+ metadata.gz: ab1eabe24fdc96f7785c9a72d6da2250c6ae9362aca1da91817c08846d70443b8919ca41c14cc34afc5652d4081dcac80a2450a3e30cbeee5ce9c2b82ac5fd29
7
+ data.tar.gz: 9db4871ec035aa11f7d9fcce3cd72ed7eb7e96e44567a5f21552e02df9a520a9057b455941ead2513cac13cdf14ce44d1d9c42012ec4af1e01007e855feef6ef
data/.travis.yml CHANGED
@@ -2,15 +2,11 @@ language: ruby
2
2
  sudo: false
3
3
  cache: bundler
4
4
  rvm:
5
- - 2.1.10
6
5
  - 2.2.8
7
6
  - 2.3.5
8
7
  - 2.4.2
9
8
 
10
9
  gemfile:
11
- - gemfiles/4.0.gemfile
12
- - gemfiles/4.1.gemfile
13
- - gemfiles/4.2.gemfile
14
10
  - gemfiles/5.0.gemfile
15
11
  - gemfiles/5.1.gemfile
16
12
 
@@ -20,17 +16,3 @@ addons:
20
16
 
21
17
  after_success:
22
18
  - bundle exec codeclimate-test-reporter
23
-
24
- matrix:
25
- exclude:
26
- # rails 5+ requires 2.2+
27
- - rvm: 2.1.10
28
- gemfile: gemfiles/5.0.gemfile
29
- - rvm: 2.1.10
30
- gemfile: gemfiles/5.1.gemfile
31
-
32
- # Arel support for ruby 2.4 starts in 4.2
33
- - rvm: 2.4.2
34
- gemfile: gemfiles/4.0.gemfile
35
- - rvm: 2.4.2
36
- gemfile: gemfiles/4.1.gemfile
data/README.md CHANGED
@@ -223,10 +223,6 @@ You can also assign a Proc instead of a String to dynamically manage this (for m
223
223
 
224
224
  This storage will take care of removing empty folders after removing files.
225
225
 
226
- This storage includes a feature of overwrite protection, raising an exception if an attempt is made of writing something
227
- on a path that already exists. This behaviour in enabled by default, but you can turn it off by passing an additional
228
- argument when instantiating the storage: `overwrite_protection: false`.
229
-
230
226
 
231
227
  ### S3Storage
232
228
 
@@ -262,11 +258,6 @@ Saviour::Config.storage = Saviour::S3Storage.new(
262
258
  )
263
259
  ```
264
260
 
265
- This storage includes a feature of overwrite protection, raising an exception if an attempt is made of writing something
266
- on a path that already exists. This behaviour in enabled by default, but you can turn it off by passing an additional
267
- argument when instantiating the storage: `overwrite_protection: false`. This feature requires an additional HEAD request
268
- to verify existence for every write.
269
-
270
261
  NOTE: Be aware that S3 has a limit of 1024 bytes for the keys (paths) used. Be sure to truncate to that maximum length
271
262
  if you're using an s3 storage, for example with a processor like this:
272
263
 
@@ -368,6 +359,12 @@ end
368
359
  Use `store_dir` to indicate the default directory under which the file will be stored. You can also use it under a
369
360
  `version` to change the default directory for that specific version.
370
361
 
362
+ Note that it's very important that the full path to any attached file to any model is unique. This is typically
363
+ accomplished by using `model.id` and `attached_as` as part of either the `store_dir` or the `filename`, in any
364
+ combination you may want. If this is not satisfied, you may experience unexpected overwrite of files or files
365
+ having unexpected contents, for example if two different models write to the same storage path, and then one
366
+ of them is deleted.
367
+
371
368
 
372
369
  ### Accessing model and attached_as
373
370
 
data/lib/saviour.rb CHANGED
@@ -12,8 +12,16 @@ require 'saviour/source_filename_extractor'
12
12
  require 'saviour/life_cycle'
13
13
  require 'saviour/persistence_layer'
14
14
  require 'saviour/validator'
15
+ require 'saviour/db_helpers'
15
16
 
16
17
  require 'tempfile'
18
+ require 'fileutils'
17
19
 
18
20
  module Saviour
21
+ NoActiveRecordDetected = Class.new(StandardError)
22
+ FileNotPresent = Class.new(StandardError)
23
+ ConfigurationError = Class.new(StandardError)
24
+ SourceError = Class.new(StandardError)
25
+ CannotCopy = Class.new(StandardError)
26
+ MissingSource = Class.new(StandardError)
19
27
  end
@@ -19,8 +19,8 @@ module Saviour
19
19
  @data.key?(name) || super
20
20
  end
21
21
 
22
- def write(contents, filename)
23
- raise RuntimeError, "Please use `store_dir` before trying to write" unless store_dir
22
+ def _process_as_contents(contents, filename)
23
+ raise ConfigurationError, "Please use `store_dir` in the uploader" unless store_dir
24
24
 
25
25
  catch(:halt_process) do
26
26
  if Config.processing_enabled
@@ -28,9 +28,22 @@ module Saviour
28
28
  end
29
29
 
30
30
  path = ::File.join(store_dir, filename)
31
- Config.storage.write(contents, path)
32
31
 
33
- path
32
+ [contents, path]
33
+ end
34
+ end
35
+
36
+ def _process_as_file(file, filename)
37
+ raise ConfigurationError, "Please use `store_dir` in the uploader" unless store_dir
38
+
39
+ catch(:halt_process) do
40
+ if Config.processing_enabled
41
+ file, filename = Uploader::ProcessorsRunner.new(self).run_as_file!(file, filename)
42
+ end
43
+
44
+ path = ::File.join(store_dir, filename)
45
+
46
+ [file, path]
34
47
  end
35
48
  end
36
49
 
@@ -4,7 +4,7 @@ module Saviour
4
4
  class Config
5
5
  class NotImplemented
6
6
  def method_missing(*)
7
- raise(RuntimeError, "You need to provide a storage! Set Saviour::Config.storage = xxx")
7
+ raise(ConfigurationError, "You need to provide a storage! Set Saviour::Config.storage = xxx")
8
8
  end
9
9
  end
10
10
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Saviour
4
+ module DbHelpers
5
+ NotInTransaction = Class.new(StandardError)
6
+
7
+ class CommitDummy
8
+ def initialize(block)
9
+ @block = block
10
+ end
11
+
12
+ def rolledback!(*)
13
+ close_transaction
14
+ end
15
+
16
+ def close_transaction(*)
17
+ end
18
+
19
+ def before_committed!(*)
20
+ end
21
+
22
+ def committed!(*)
23
+ @block.call
24
+ end
25
+ end
26
+
27
+ class RollbackDummy
28
+ def initialize(block)
29
+ @block = block
30
+ end
31
+
32
+ def rolledback!(*)
33
+ @block.call
34
+ close_transaction
35
+ end
36
+
37
+ def close_transaction(*)
38
+ end
39
+
40
+ def before_committed!(*)
41
+ end
42
+
43
+ def committed!(*)
44
+ end
45
+ end
46
+
47
+
48
+ class << self
49
+
50
+ def run_after_commit(&block)
51
+ unless ActiveRecord::Base.connection.current_transaction.open?
52
+ raise NotInTransaction, 'Trying to use `run_after_commit` but no transaction is currently open.'
53
+ end
54
+
55
+ dummy = CommitDummy.new(block)
56
+ ActiveRecord::Base.connection.add_transaction_record(dummy)
57
+ end
58
+
59
+ def run_after_rollback(&block)
60
+ unless ActiveRecord::Base.connection.current_transaction.open?
61
+ raise NotInTransaction, 'Trying to use `run_after_commit` but no transaction is currently open.'
62
+ end
63
+
64
+ dummy = RollbackDummy.new(block)
65
+ ActiveRecord::Base.connection.add_transaction_record(dummy)
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/saviour/file.rb CHANGED
@@ -3,6 +3,7 @@ require 'securerandom'
3
3
  module Saviour
4
4
  class File
5
5
  attr_reader :persisted_path
6
+ attr_reader :source
6
7
 
7
8
  def initialize(uploader_klass, model, attached_as)
8
9
  @uploader_klass, @model, @attached_as = uploader_klass, model, attached_as
@@ -11,6 +12,7 @@ module Saviour
11
12
 
12
13
  def set_path!(path)
13
14
  @persisted_path = path
15
+ @persisted_path_before_last_save = path
14
16
  end
15
17
 
16
18
  def exists?
@@ -18,11 +20,11 @@ module Saviour
18
20
  end
19
21
 
20
22
  def read
21
- persisted? && exists? && Config.storage.read(@persisted_path)
23
+ persisted? && Config.storage.read(@persisted_path)
22
24
  end
23
25
 
24
26
  def delete
25
- persisted? && exists? && Config.storage.delete(@persisted_path)
27
+ persisted? && Config.storage.delete(@persisted_path)
26
28
  end
27
29
 
28
30
  def public_url
@@ -36,20 +38,34 @@ module Saviour
36
38
  if persisted?
37
39
  another_file.persisted_path == persisted_path
38
40
  else
39
- another_file.instance_variable_get("@source") == @source
41
+ another_file.source == @source
40
42
  end
41
43
  end
42
44
 
43
45
  def clone
46
+ return nil unless persisted?
47
+
44
48
  new_file = Saviour::File.new(@uploader_klass, @model, @attached_as)
45
- new_file.set_path!(@persisted_path)
49
+ new_file.set_path! @persisted_path
50
+ new_file
51
+ end
52
+
53
+ def dup
54
+ new_file = Saviour::File.new(@uploader_klass, @model, @attached_as)
55
+
56
+ if persisted?
57
+ new_file.assign(Saviour::StringSource.new(read, filename))
58
+ else
59
+ new_file.assign(Saviour::StringSource.new(source_data, filename_to_be_assigned))
60
+ end
61
+
46
62
  new_file
47
63
  end
48
64
 
49
65
  alias_method :url, :public_url
50
66
 
51
67
  def assign(object)
52
- raise(RuntimeError, "must respond to `read`") if object && !object.respond_to?(:read)
68
+ raise(SourceError, "given object to #assign or #<attach_as>= must respond to `read`") if object && !object.respond_to?(:read)
53
69
 
54
70
  followers = @model.class.attached_followers_per_leader[@attached_as]
55
71
  followers.each { |x| @model.send(x).assign(object) unless @model.send(x).changed? } if followers
@@ -79,20 +95,16 @@ module Saviour
79
95
  end
80
96
 
81
97
  def with_copy
82
- raise "must be persisted" unless persisted?
83
-
84
- Tempfile.open([::File.basename(filename, ".*"), ::File.extname(filename)]) do |file|
85
- begin
86
- file.binmode
87
- file.write(read)
88
- file.flush
89
- file.rewind
90
-
91
- yield(file)
92
- ensure
93
- file.close
94
- file.delete
95
- end
98
+ raise CannotCopy, "must be persisted" unless persisted?
99
+
100
+ temp_file = Tempfile.new([::File.basename(filename, ".*"), ::File.extname(filename)])
101
+
102
+ begin
103
+ Config.storage.read_to_file(@persisted_path, temp_file)
104
+
105
+ yield(temp_file)
106
+ ensure
107
+ temp_file.close!
96
108
  end
97
109
  end
98
110
 
@@ -100,15 +112,53 @@ module Saviour
100
112
  changed? ? (SourceFilenameExtractor.new(@source).detected_filename || SecureRandom.hex) : nil
101
113
  end
102
114
 
103
- def write
104
- raise(RuntimeError, "You must provide a source to read from first") unless @source
115
+ def __maybe_with_tmpfile(source_type, file)
116
+ return yield if source_type == :stream
117
+
118
+ tmpfile = Tempfile.new([::File.basename(file.path, ".*"), ::File.extname(file.path)])
119
+ FileUtils.cp(file.path, tmpfile.path)
120
+
121
+ begin
122
+ yield(tmpfile)
123
+ ensure
124
+ tmpfile.close!
125
+ end
126
+ end
127
+
128
+ def write(before_write: nil)
129
+ raise(MissingSource, "You must provide a source to read from before trying to write") unless @source
130
+
131
+ __maybe_with_tmpfile(source_type, @source) do |tmpfile|
132
+ contents, path = case source_type
133
+ when :stream
134
+ uploader._process_as_contents(source_data, filename_to_be_assigned)
135
+ when :file
136
+ uploader._process_as_file(tmpfile, filename_to_be_assigned)
137
+ end
138
+ @source_was = @source
105
139
 
106
- path = uploader.write(source_data, filename_to_be_assigned)
107
- @source_was = @source
140
+ if path
141
+ before_write.call(path) if before_write
108
142
 
109
- if path
110
- @persisted_path = path
111
- path
143
+ case source_type
144
+ when :stream
145
+ Config.storage.write(contents, path)
146
+ when :file
147
+ Config.storage.write_from_file(contents, path)
148
+ end
149
+
150
+ @persisted_path = path
151
+ @persisted_path_before_last_save = path
152
+ path
153
+ end
154
+ end
155
+ end
156
+
157
+ def source_type
158
+ if @source.respond_to?(:path)
159
+ :file
160
+ else
161
+ :stream
112
162
  end
113
163
  end
114
164
 
@@ -6,7 +6,7 @@ module Saviour
6
6
  end
7
7
 
8
8
  def setup!
9
- raise "You cannot include Saviour twice in the same class" if @klass.respond_to?(:attached_files)
9
+ raise(ConfigurationError, "You cannot include Saviour::Model twice in the same class") if @klass.respond_to?(:attached_files)
10
10
 
11
11
  @klass.class_attribute :attached_files
12
12
  @klass.attached_files = []
@@ -26,7 +26,7 @@ module Saviour
26
26
  end
27
27
 
28
28
  if uploader_klass.nil? && block.nil?
29
- raise ArgumentError, "you must provide either an UploaderClass or a block to define it."
29
+ raise ConfigurationError, "you must provide either an UploaderClass or a block to define it."
30
30
  end
31
31
 
32
32
  mod = Module.new do
@@ -58,7 +58,12 @@ module Saviour
58
58
 
59
59
  @klass.define_singleton_method("attach_validation") do |attach_as, method_name = nil, &block|
60
60
  klass.__saviour_validations ||= Hash.new { [] }
61
- klass.__saviour_validations[attach_as] += [method_name || block]
61
+ klass.__saviour_validations[attach_as] += [{ method_or_block: method_name || block, type: :memory }]
62
+ end
63
+
64
+ @klass.define_singleton_method("attach_validation_with_file") do |attach_as, method_name = nil, &block|
65
+ klass.__saviour_validations ||= Hash.new { [] }
66
+ klass.__saviour_validations[attach_as] += [{ method_or_block: method_name || block, type: :file }]
62
67
  end
63
68
  end
64
69
  end
@@ -1,23 +1,42 @@
1
1
  module Saviour
2
2
  class LifeCycle
3
- def initialize(model, persistence_klass = nil)
4
- raise "Please provide an object compatible with Saviour." unless model.class.respond_to?(:attached_files)
3
+ def initialize(model, persistence_klass)
4
+ raise ConfigurationError, "Please provide an object compatible with Saviour." unless model.class.respond_to?(:attached_files)
5
5
 
6
6
  @persistence_klass = persistence_klass
7
7
  @model = model
8
8
  end
9
9
 
10
10
  def delete!
11
+ DbHelpers.run_after_commit do
12
+ attached_files.each do |column|
13
+ @model.send(column).delete
14
+ end
15
+ end
16
+ end
17
+
18
+ def create!
11
19
  attached_files.each do |column|
12
- @model.send(column).delete if @model.send(column).exists?
20
+ next unless @model.send(column).changed?
21
+
22
+ persistence_layer = @persistence_klass.new(@model)
23
+ new_path = @model.send(column).write
24
+
25
+ if new_path
26
+ persistence_layer.write(column, new_path)
27
+
28
+ DbHelpers.run_after_rollback do
29
+ Config.storage.delete(new_path)
30
+ end
31
+ end
13
32
  end
14
33
  end
15
34
 
16
- def save!
35
+ def update!
17
36
  attached_files.each do |column|
18
- base_file_changed = @model.send(column).changed?
37
+ next unless @model.send(column).changed?
19
38
 
20
- upload_file(column) if base_file_changed
39
+ update_file(column)
21
40
  end
22
41
  end
23
42
 
@@ -25,14 +44,43 @@ module Saviour
25
44
  private
26
45
 
27
46
 
28
- def upload_file(column)
29
- persistence_layer = @persistence_klass.new(@model) if @persistence_klass
30
- current_path = persistence_layer.read(column) if persistence_layer
47
+ def update_file(column)
48
+ persistence_layer = @persistence_klass.new(@model)
49
+ current_path = persistence_layer.read(column)
50
+ dup_temp_path = SecureRandom.hex
51
+
52
+ dup_file = proc do
53
+ Config.storage.cp current_path, dup_temp_path
31
54
 
32
- Config.storage.delete(current_path) if current_path
55
+ DbHelpers.run_after_commit do
56
+ Config.storage.delete dup_temp_path
57
+ end
33
58
 
34
- new_path = @model.send(column).write
35
- persistence_layer.write(column, new_path) if persistence_layer && new_path
59
+ DbHelpers.run_after_rollback do
60
+ Config.storage.mv dup_temp_path, current_path
61
+ end
62
+ end
63
+
64
+ new_path = @model.send(column).write(
65
+ before_write: ->(path) { dup_file.call if current_path == path }
66
+ )
67
+
68
+ if new_path
69
+ persistence_layer.write(column, new_path)
70
+
71
+ if current_path && current_path != new_path
72
+ DbHelpers.run_after_commit do
73
+ Config.storage.delete(current_path)
74
+ end
75
+ end
76
+
77
+ # Delete the newly uploaded file only if it's an update in a different path
78
+ if current_path.nil? || current_path != new_path
79
+ DbHelpers.run_after_rollback do
80
+ Config.storage.delete(new_path)
81
+ end
82
+ end
83
+ end
36
84
  end
37
85
 
38
86
  def attached_files