file_blobs_rails 0.1.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.document +5 -0
  3. data/.travis.yml +16 -0
  4. data/Gemfile +18 -0
  5. data/Gemfile.lock +397 -0
  6. data/Gemfile.rails5 +18 -0
  7. data/LICENSE.txt +20 -0
  8. data/README.md +62 -0
  9. data/Rakefile +38 -0
  10. data/VERSION +1 -0
  11. data/file_blobs_rails.gemspec +131 -0
  12. data/lib/file_blobs_rails/action_controller_data_streaming_extensions.rb +60 -0
  13. data/lib/file_blobs_rails/active_record_extensions.rb +257 -0
  14. data/lib/file_blobs_rails/active_record_fixture_set_extensions.rb +58 -0
  15. data/lib/file_blobs_rails/active_record_migration_extensions.rb +31 -0
  16. data/lib/file_blobs_rails/active_record_table_definition_extensions.rb +35 -0
  17. data/lib/file_blobs_rails/active_support_test_extensions.rb +44 -0
  18. data/lib/file_blobs_rails/blob_model.rb +105 -0
  19. data/lib/file_blobs_rails/engine.rb +10 -0
  20. data/lib/file_blobs_rails/file_blob_proxy.rb +7 -0
  21. data/lib/file_blobs_rails/generators/blob_model_generator.rb +25 -0
  22. data/lib/file_blobs_rails/generators/blob_owner_generator.rb +28 -0
  23. data/lib/file_blobs_rails/generators/templates/001_create_file_blobs.rb.erb +7 -0
  24. data/lib/file_blobs_rails/generators/templates/002_create_blob_owners.rb.erb +7 -0
  25. data/lib/file_blobs_rails/generators/templates/blob_owner.rb.erb +3 -0
  26. data/lib/file_blobs_rails/generators/templates/blob_owner_test.rb.erb +13 -0
  27. data/lib/file_blobs_rails/generators/templates/blob_owners.yml.erb +11 -0
  28. data/lib/file_blobs_rails/generators/templates/file_blob.rb.erb +11 -0
  29. data/lib/file_blobs_rails/generators/templates/file_blob_test.rb.erb +9 -0
  30. data/lib/file_blobs_rails/generators/templates/file_blobs.yml.erb +7 -0
  31. data/lib/file_blobs_rails/generators/templates/files/invoice.pdf +137 -0
  32. data/lib/file_blobs_rails/generators/templates/files/ruby.png +0 -0
  33. data/lib/file_blobs_rails.rb +21 -0
  34. data/test/blob_model_test.rb +23 -0
  35. data/test/blob_owner_test.rb +9 -0
  36. data/test/controller_extensions_test.rb +80 -0
  37. data/test/file_blob_proxy_test.rb +100 -0
  38. data/test/file_blob_test.rb +8 -0
  39. data/test/file_blobs_fixture_test.rb +21 -0
  40. data/test/fixtures/003_create_gem_test_blobs.rb +8 -0
  41. data/test/fixtures/004_create_gem_test_messages.rb +15 -0
  42. data/test/fixtures/files/invoice.pdf +137 -0
  43. data/test/fixtures/files/ruby.png +0 -0
  44. data/test/fixtures/gem_test_blob.rb +5 -0
  45. data/test/fixtures/gem_test_message.rb +5 -0
  46. data/test/garbage_collection_test.rb +84 -0
  47. data/test/helpers/action_controller.rb +5 -0
  48. data/test/helpers/db_setup.rb +22 -0
  49. data/test/helpers/fixtures.rb +41 -0
  50. data/test/helpers/i18n.rb +1 -0
  51. data/test/helpers/migrations.rb +41 -0
  52. data/test/helpers/rails.rb +9 -0
  53. data/test/helpers/routes.rb +18 -0
  54. data/test/helpers/test_order.rb +1 -0
  55. data/test/test_extensions_test.rb +21 -0
  56. data/test/test_helper.rb +36 -0
  57. metadata +283 -0
@@ -0,0 +1,131 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+ # stub: file_blobs_rails 0.1.0 ruby lib
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "file_blobs_rails"
9
+ s.version = "0.1.0"
10
+
11
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
+ s.require_paths = ["lib"]
13
+ s.authors = ["Victor Costan"]
14
+ s.date = "2016-10-31"
15
+ s.description = "This gem is a quick way to add database-backed file storage to a Rails application. Files are stored in a dedicated table and de-duplicated."
16
+ s.email = "victor@costan.us"
17
+ s.extra_rdoc_files = [
18
+ "LICENSE.txt",
19
+ "README.md"
20
+ ]
21
+ s.files = [
22
+ ".document",
23
+ ".travis.yml",
24
+ "Gemfile",
25
+ "Gemfile.lock",
26
+ "Gemfile.rails5",
27
+ "LICENSE.txt",
28
+ "README.md",
29
+ "Rakefile",
30
+ "VERSION",
31
+ "file_blobs_rails.gemspec",
32
+ "lib/file_blobs_rails.rb",
33
+ "lib/file_blobs_rails/action_controller_data_streaming_extensions.rb",
34
+ "lib/file_blobs_rails/active_record_extensions.rb",
35
+ "lib/file_blobs_rails/active_record_fixture_set_extensions.rb",
36
+ "lib/file_blobs_rails/active_record_migration_extensions.rb",
37
+ "lib/file_blobs_rails/active_record_table_definition_extensions.rb",
38
+ "lib/file_blobs_rails/active_support_test_extensions.rb",
39
+ "lib/file_blobs_rails/blob_model.rb",
40
+ "lib/file_blobs_rails/engine.rb",
41
+ "lib/file_blobs_rails/file_blob_proxy.rb",
42
+ "lib/file_blobs_rails/generators/blob_model_generator.rb",
43
+ "lib/file_blobs_rails/generators/blob_owner_generator.rb",
44
+ "lib/file_blobs_rails/generators/templates/001_create_file_blobs.rb.erb",
45
+ "lib/file_blobs_rails/generators/templates/002_create_blob_owners.rb.erb",
46
+ "lib/file_blobs_rails/generators/templates/blob_owner.rb.erb",
47
+ "lib/file_blobs_rails/generators/templates/blob_owner_test.rb.erb",
48
+ "lib/file_blobs_rails/generators/templates/blob_owners.yml.erb",
49
+ "lib/file_blobs_rails/generators/templates/file_blob.rb.erb",
50
+ "lib/file_blobs_rails/generators/templates/file_blob_test.rb.erb",
51
+ "lib/file_blobs_rails/generators/templates/file_blobs.yml.erb",
52
+ "lib/file_blobs_rails/generators/templates/files/invoice.pdf",
53
+ "lib/file_blobs_rails/generators/templates/files/ruby.png",
54
+ "test/blob_model_test.rb",
55
+ "test/blob_owner_test.rb",
56
+ "test/controller_extensions_test.rb",
57
+ "test/file_blob_proxy_test.rb",
58
+ "test/file_blob_test.rb",
59
+ "test/file_blobs_fixture_test.rb",
60
+ "test/fixtures/003_create_gem_test_blobs.rb",
61
+ "test/fixtures/004_create_gem_test_messages.rb",
62
+ "test/fixtures/files/invoice.pdf",
63
+ "test/fixtures/files/ruby.png",
64
+ "test/fixtures/gem_test_blob.rb",
65
+ "test/fixtures/gem_test_message.rb",
66
+ "test/garbage_collection_test.rb",
67
+ "test/helpers/action_controller.rb",
68
+ "test/helpers/db_setup.rb",
69
+ "test/helpers/fixtures.rb",
70
+ "test/helpers/i18n.rb",
71
+ "test/helpers/migrations.rb",
72
+ "test/helpers/rails.rb",
73
+ "test/helpers/routes.rb",
74
+ "test/helpers/test_order.rb",
75
+ "test/test_extensions_test.rb",
76
+ "test/test_helper.rb"
77
+ ]
78
+ s.homepage = "https://github.com/pwnall/file_blobs_rails"
79
+ s.licenses = ["MIT"]
80
+ s.rubygems_version = "2.5.1"
81
+ s.summary = "Database-backed file storage for Rails 5 applications."
82
+
83
+ if s.respond_to? :specification_version then
84
+ s.specification_version = 4
85
+
86
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
87
+ s.add_runtime_dependency(%q<rails>, [">= 5.0.0.1"])
88
+ s.add_development_dependency(%q<bundler>, [">= 1.6.6"])
89
+ s.add_development_dependency(%q<jeweler>, [">= 2.1.1"])
90
+ s.add_development_dependency(%q<mocha>, [">= 1.2.1"])
91
+ s.add_development_dependency(%q<mysql2>, [">= 0.4.4"])
92
+ s.add_development_dependency(%q<omniauth>, [">= 1.3.1"])
93
+ s.add_development_dependency(%q<pg>, [">= 0.19.0"])
94
+ s.add_development_dependency(%q<rake>, [">= 11.3.0"])
95
+ s.add_development_dependency(%q<sqlite3>, [">= 1.3.12"])
96
+ s.add_development_dependency(%q<yard>, [">= 0.9.5"])
97
+ s.add_development_dependency(%q<rubysl>, [">= 0"])
98
+ s.add_development_dependency(%q<rubysl-bundler>, [">= 0"])
99
+ s.add_development_dependency(%q<rubysl-rake>, [">= 0"])
100
+ else
101
+ s.add_dependency(%q<rails>, [">= 5.0.0.1"])
102
+ s.add_dependency(%q<bundler>, [">= 1.6.6"])
103
+ s.add_dependency(%q<jeweler>, [">= 2.1.1"])
104
+ s.add_dependency(%q<mocha>, [">= 1.2.1"])
105
+ s.add_dependency(%q<mysql2>, [">= 0.4.4"])
106
+ s.add_dependency(%q<omniauth>, [">= 1.3.1"])
107
+ s.add_dependency(%q<pg>, [">= 0.19.0"])
108
+ s.add_dependency(%q<rake>, [">= 11.3.0"])
109
+ s.add_dependency(%q<sqlite3>, [">= 1.3.12"])
110
+ s.add_dependency(%q<yard>, [">= 0.9.5"])
111
+ s.add_dependency(%q<rubysl>, [">= 0"])
112
+ s.add_dependency(%q<rubysl-bundler>, [">= 0"])
113
+ s.add_dependency(%q<rubysl-rake>, [">= 0"])
114
+ end
115
+ else
116
+ s.add_dependency(%q<rails>, [">= 5.0.0.1"])
117
+ s.add_dependency(%q<bundler>, [">= 1.6.6"])
118
+ s.add_dependency(%q<jeweler>, [">= 2.1.1"])
119
+ s.add_dependency(%q<mocha>, [">= 1.2.1"])
120
+ s.add_dependency(%q<mysql2>, [">= 0.4.4"])
121
+ s.add_dependency(%q<omniauth>, [">= 1.3.1"])
122
+ s.add_dependency(%q<pg>, [">= 0.19.0"])
123
+ s.add_dependency(%q<rake>, [">= 11.3.0"])
124
+ s.add_dependency(%q<sqlite3>, [">= 1.3.12"])
125
+ s.add_dependency(%q<yard>, [">= 0.9.5"])
126
+ s.add_dependency(%q<rubysl>, [">= 0"])
127
+ s.add_dependency(%q<rubysl-bundler>, [">= 0"])
128
+ s.add_dependency(%q<rubysl-rake>, [">= 0"])
129
+ end
130
+ end
131
+
@@ -0,0 +1,60 @@
1
+ require 'action_controller'
2
+
3
+ module FileBlobs
4
+
5
+ # Module mixed into ActionController::DataStreaming.
6
+ module ActionControllerDataStreamingExtensions
7
+ ETAG = 'ETag'.freeze
8
+ HTTP_IF_NONE_MATCH = 'HTTP_IF_NONE_MATCH'.freeze
9
+
10
+ # Sends a file blob to the browser.
11
+ #
12
+ # This method uses HTTP's strong etag feature to facilitate serving the files
13
+ # from a cache whenever possible.
14
+ #
15
+ # @param [FileBlobs::FileBlobProxy] proxy a proxy for a collection of
16
+ # attributes generated by has_file_blob
17
+ # @param [Hash<Symbol, Object>] options tweaks the options passed to the
18
+ # underlying send_data call; this method sets the :filename and :type
19
+ # options, but their values can be overridden by the options hash
20
+ # @see ActionController::DataStreaming#send_data
21
+ def send_file_blob(proxy, options = {})
22
+ if request.get_header(HTTP_IF_NONE_MATCH) == proxy.blob_id
23
+ head :not_modified
24
+ else
25
+ response.headers[ETAG] = proxy.blob_id
26
+ send_options = { type: proxy.mime_type, filename: proxy.original_name }
27
+ send_options.merge! options
28
+ send_data proxy.data, send_options
29
+ end
30
+ end
31
+
32
+ # Creates the table used to hold file blobs.
33
+ #
34
+ # @param [Symbol] table_name the name of the table used to hold file data
35
+ # @param [Hash<Symbol, Object>] options
36
+ # @option options [Boolean] null true
37
+ # @option options [Integer] blob_limit the maximum file size that can be
38
+ # stored in the table; defaults to 1 megabyte
39
+ def file_blob(column_name_base = :file, options = {}, &block)
40
+ allow_null = options[:null] || false
41
+ mime_type_limit = options[:mime_type_limit] || 64
42
+ file_name_limit = options[:file_name_limit] || 256
43
+
44
+ # The index is needed for garbage-collection eligibility checks.
45
+ string :"#{column_name_base}_blob_id", limit: 48, null: allow_null,
46
+ index: true
47
+
48
+ integer :"#{column_name_base}_size", null: allow_null
49
+ string :"#{column_name_base}_mime_type", limit: mime_type_limit,
50
+ null: allow_null
51
+ string :"#{column_name_base}_original_name", limit: file_name_limit,
52
+ null: allow_null
53
+ end
54
+ end # module FileBlobs::ActionControllerDataStreamingExtensions
55
+
56
+ end # namespace FileBlobs
57
+
58
+ ActionController::DataStreaming.class_eval do
59
+ include FileBlobs::ActionControllerDataStreamingExtensions
60
+ end
@@ -0,0 +1,257 @@
1
+ require 'active_record'
2
+ require 'active_support'
3
+
4
+ module FileBlobs
5
+
6
+ # Module mixed into ActiveRecord::Base.
7
+ module ActiveRecordExtensions
8
+ extend ActiveSupport::Concern
9
+ end # module FileBlobs::ActiveRecordExtensions
10
+
11
+ module ActiveRecordExtensions::ClassMethods
12
+ # Creates a reference to a FileBlob storing a file.
13
+ #
14
+ # `has_file_blob :file` creates the following
15
+ #
16
+ # * file - synthetic accessor
17
+ # * file_blob - belongs_to relationship pointing to a FileBlob
18
+ # * file_blob_id - attribute used by the belongs_to relationship; stores the
19
+ # SHA-256 of the file's contents
20
+ # * file_size - attribute storing the file's length in bytes; this is stored
21
+ # in the model as an optimization, so the length can be displayed / used
22
+ # for decisions without fetching the blob model storing the contents
23
+ # * file_mime_type - attribute storing the MIME type associated with the
24
+ # file; this is stored outside the blob model because it is possible to
25
+ # have the same bytes uploaded with different MIME types
26
+ # * file_original_name - attribute storing the name supplied by the browser
27
+ # that uploaded the file; this should not be trusted, as it is controlled
28
+ # by the user
29
+ #
30
+ # @param [String] attribute_name the name of the relationship pointing to the
31
+ # file blob, and the root of the names of the related attributes
32
+ # @param [Hash{Symbol, Object}] options
33
+ # @option options [String] blob_model the name of the model used to store the
34
+ # file's contents; defaults to 'FileBlob'
35
+ # @option options [Boolean] allow_nil if true, allows saving a model without
36
+ # an associated file
37
+ def has_file_blob(attribute_name = 'file', options = {})
38
+ blob_model = (options[:blob_model] || 'FileBlob'.freeze).to_s
39
+ allow_nil = options[:allow_nil] || false
40
+
41
+ self.class_eval <<ENDRUBY, __FILE__, __LINE__ + 1
42
+ # Saves the old blob model id, so the de-referenced blob can be GCed.
43
+ before_save :#{attribute_name}_stash_old_blob, on: :update
44
+
45
+ # Checks if the de-referenced FileBlob in an update should be GCed.
46
+ after_update :#{attribute_name}_maybe_garbage_collect_old_blob
47
+
48
+ # Checks if the FileBlob of a deleted entry should be GCed.
49
+ after_destroy :#{attribute_name}_maybe_garbage_collect_blob
50
+
51
+ # The FileBlob storing the file's content.
52
+ belongs_to :#{attribute_name}_blob,
53
+ { class_name: #{blob_model.inspect} }, -> { select :id }
54
+
55
+ class #{attribute_name.to_s.classify}Proxy < FileBlobs::FileBlobProxy
56
+ # Creates a proxy for the given model.
57
+ #
58
+ # The proxied model remains constant for the life of the proxy.
59
+ def initialize(owner)
60
+ @owner = owner
61
+ end
62
+
63
+ # Virtual attribute that proxies to the model's _blob attribute.
64
+ #
65
+ # This attribute does not have a corresponding setter because a _blob
66
+ # setter would encourage sub-optimal code. The owner model's file blob
67
+ # setter should be used instead, as it has a fast path that avoids
68
+ # fetching the blob's data. By comparison, a _blob setter would always
69
+ # have to fetch the blob data, to determine the blob's size.
70
+ def blob
71
+ @owner.#{attribute_name}_blob
72
+ end
73
+
74
+ # Virtual attribute that proxies to the model's _mime_type attribute.
75
+ def mime_type
76
+ @owner.#{attribute_name}_mime_type
77
+ end
78
+ def mime_type=(new_mime_type)
79
+ @owner.#{attribute_name}_mime_type = new_mime_type
80
+ end
81
+
82
+ # Virtual attribute that proxies to the model's _original_name attribute.
83
+ def original_name
84
+ @owner.#{attribute_name}_original_name
85
+ end
86
+ def original_name=(new_name)
87
+ @owner.#{attribute_name}_original_name = new_name
88
+ end
89
+
90
+ # Virtual getter that proxies to the model's _size attribute.
91
+ #
92
+ # This attribute does not have a corresponding setter because the _size
93
+ # attribute automatically tracks the _data attribute, so it should not
94
+ # be set on its own.
95
+ def size
96
+ @owner.#{attribute_name}_size
97
+ end
98
+
99
+ # Virtual attribute that proxies to the model's _blob_id attribute.
100
+ #
101
+ # This attribute is an optimization that allows some code paths to
102
+ # avoid fetching the associated blob model. It should only be used in
103
+ # these cases.
104
+ #
105
+ # This attribute does not have a corresponding setter because the
106
+ # contents blob should be set using the model's _blob attribute (with
107
+ # the blob proxy), which updates the model _size attribute and checks
108
+ # that the blob is an instance of the correct blob model.
109
+ def blob_id
110
+ @owner.#{attribute_name}_blob_id
111
+ end
112
+
113
+ # Virtual attribute that proxies to the model's _data attribute.
114
+ def data
115
+ @owner.#{attribute_name}_data
116
+ end
117
+ def data=(new_data)
118
+ @owner.#{attribute_name}_data = new_data
119
+ end
120
+
121
+ # Reflection.
122
+ def blob_class
123
+ #{blob_model}
124
+ end
125
+ def allows_nil?
126
+ #{allow_nil}
127
+ end
128
+ attr_reader :owner
129
+ end
130
+
131
+ # Getter for the file's convenience proxy.
132
+ def #{attribute_name}
133
+ @_#{attribute_name}_proxy ||=
134
+ #{attribute_name.to_s.classify}Proxy.new self
135
+ end
136
+
137
+ # Convenience setter for all the file attributes.
138
+ #
139
+ # @param {ActionDispatch::Http::UploadedFile, Proxy} new_file either an
140
+ # object representing a file uploaded to a controller, or an object
141
+ # obtained from another model's blob accessor
142
+ def #{attribute_name}=(new_file)
143
+ if new_file.respond_to? :read
144
+ # ActionDispatch::Http::UploadedFile
145
+ self.#{attribute_name}_mime_type = new_file.content_type
146
+ self.#{attribute_name}_original_name = new_file.original_filename
147
+ self.#{attribute_name}_data = new_file.read
148
+ elsif new_file.respond_to? :blob_class
149
+ # Blob owner proxy.
150
+ self.#{attribute_name}_mime_type = new_file.mime_type
151
+ self.#{attribute_name}_original_name = new_file.original_name
152
+ if new_file.blob_class == #{blob_model}
153
+ # Fast path: when the two files are backed by the same blob table,
154
+ # we can create a new reference to the existing blob.
155
+ self.#{attribute_name}_blob_id = new_file.blob_id
156
+ self.#{attribute_name}_size = new_file.size
157
+ else
158
+ # Slow path: we need to copy data across blob tables.
159
+ self.#{attribute_name}_data = new_file.data
160
+ end
161
+ end
162
+ end
163
+
164
+ # Convenience getter for the file's content.
165
+ #
166
+ # @return [String] a string with the binary encoding that holds the
167
+ # file's contents
168
+ def #{attribute_name}_data
169
+ # NOTE: we're not using the ActiveRecord association on purpose, so
170
+ # that the large FileBlob doesn't hang off of the object
171
+ # referencing it; this way, the blob's data can be
172
+ # garbage-collected by the Ruby VM as early as possible
173
+ blob = #{blob_model}.where(id: #{attribute_name}_blob_id).first!
174
+ blob.data
175
+ end
176
+
177
+ # Convenience setter for the file's content.
178
+ #
179
+ # @param new_blob_contents [String] a string with the binary encoding
180
+ # that holds the new file contents to be stored by this model
181
+ def #{attribute_name}_data=(new_blob_contents)
182
+ sha = new_blob_contents && #{blob_model}.id_for(new_blob_contents)
183
+ return if self.#{attribute_name}_blob_id == sha
184
+
185
+ if sha && #{blob_model}.where(id: sha).length == 0
186
+ self.#{attribute_name}_blob = #{blob_model}.new id: sha,
187
+ data: new_blob_contents
188
+ else
189
+ self.#{attribute_name}_blob_id = sha
190
+ end
191
+
192
+ self.#{attribute_name}_size =
193
+ new_blob_contents && new_blob_contents.bytesize
194
+ end
195
+
196
+ # Saves the old blob model id, so the de-referenced blob can be GCed.
197
+ def #{attribute_name}_stash_old_blob
198
+ @_#{attribute_name}_old_blob_id = #{attribute_name}_blob_id_change &&
199
+ #{attribute_name}_blob_id_change.first
200
+ end
201
+ private :#{attribute_name}_stash_old_blob
202
+
203
+ # Checks if the de-referenced blob model in an update should be GCed.
204
+ def #{attribute_name}_maybe_garbage_collect_old_blob
205
+ return unless @_#{attribute_name}_old_blob_id
206
+ old_blob = #{blob_model}.find @_#{attribute_name}_old_blob_id
207
+ old_blob.maybe_garbage_collect
208
+ @_#{attribute_name}_old_blob_id = nil
209
+ end
210
+ private :#{attribute_name}_maybe_garbage_collect_old_blob
211
+
212
+ # Checks if the FileBlob of a deleted entry should be GCed.
213
+ def #{attribute_name}_maybe_garbage_collect_blob
214
+ #{attribute_name}_blob && #{attribute_name}_blob.maybe_garbage_collect
215
+ end
216
+ private :#{attribute_name}_maybe_garbage_collect_blob
217
+
218
+ unless self.respond_to? :file_blob_id_attributes
219
+ @@file_blob_id_attributes = {}
220
+ cattr_reader :file_blob_id_attributes, instance_reader: false
221
+ end
222
+
223
+ unless self.respond_to? :file_blob_eligible_for_garbage_collection?
224
+ # Checks if a contents blob is referenced by a model of this class.
225
+ #
226
+ # @param {FileBlobs::BlobModel} file_blob a blob to be checked
227
+ def self.file_blob_eligible_for_garbage_collection?(file_blob)
228
+ attributes = file_blob_id_attributes[file_blob.class.name]
229
+ file_blob_id = file_blob.id
230
+
231
+ # TODO(pwnall): Use or to issue a single SQL query for multiple
232
+ # attributes.
233
+ !attributes.any? do |attribute|
234
+ where(attribute => file_blob_id).exists?
235
+ end
236
+ end
237
+ end
238
+ ENDRUBY
239
+
240
+ file_blob_id_attributes[blob_model] ||= []
241
+ file_blob_id_attributes[blob_model] << :"#{attribute_name}_blob_id"
242
+
243
+ if !allow_nil
244
+ self.class_eval <<ENDRUBY, __FILE__, __LINE__ + 1
245
+ validates :#{attribute_name}_blob, presence: true
246
+ validates :#{attribute_name}_mime_type, presence: true
247
+ validates :#{attribute_name}_size, presence: true
248
+ ENDRUBY
249
+ end
250
+ end
251
+ end # module FileBlobs::ActiveRecordExtensions::ClassMethods
252
+
253
+ end # namespace FileBlobs
254
+
255
+ ActiveRecord::Base.class_eval do
256
+ include FileBlobs::ActiveRecordExtensions
257
+ end
@@ -0,0 +1,58 @@
1
+ require 'base64'
2
+
3
+ require 'active_record/fixtures'
4
+
5
+ module FileBlobs
6
+
7
+ # Module mixed into ActiveRecord::FixtureSet.
8
+ module ActiveRecordFixtureSetExtensions
9
+ # Computes the ID assigned to a blob.
10
+ #
11
+ # @param [String] path the path of the file whose contents is used in the
12
+ # fixture, relative to the Rails application's test/fixtures directory
13
+ # @return [String] the ID used to represent the blob contents
14
+ def file_blob_id(path)
15
+ file_path = Rails.root.join('test/fixtures'.freeze).join(path)
16
+ blob_contents = File.binread file_path
17
+
18
+ # This needs to be kept in sync with blob_model.rb.
19
+ Base64.urlsafe_encode64(Digest::SHA256.digest(blob_contents)).inspect
20
+ end
21
+
22
+ # The contents of a blob, in a YAML-friendly format.
23
+ #
24
+ # @param [String] path the path of the file whose contents is used in the
25
+ # fixture, relative to the Rails application's test/fixtures directory
26
+ # @param [Hash] options optionally specify the current indentation level
27
+ # @option options [Integer] indent the number of spaces that the current line
28
+ # in the YAML fixture file is indented by
29
+ # @return [String] the base64-encoded blob contents
30
+ def file_blob_data(path, options = {})
31
+ # The line with base64 data must be indented further than the current line.
32
+ indent = ' ' * ((options[:indent] || 2) + 2)
33
+
34
+ file_path = Rails.root.join('test/fixtures'.freeze).join(path)
35
+ blob_contents = File.binread file_path
36
+ base64_data = Base64.encode64 blob_contents
37
+ base64_data.gsub! "\n", "\n#{indent}"
38
+ base64_data.strip!
39
+
40
+ "!!binary |\n#{indent}#{base64_data}"
41
+ end
42
+
43
+ # The number of bytes in a file.
44
+ #
45
+ # @param [String] path the path of the file whose contents is used in the
46
+ # fixture, relative to the Rails application's test/fixtures directory
47
+ # @return [Integer] the nubmer of bytes in the file
48
+ def file_blob_size(path)
49
+ file_path = Rails.root.join('test/fixtures'.freeze).join(path)
50
+ File.stat(file_path).size
51
+ end
52
+ end # module FileBlobs::ActiveRecordFixtureSetExtensions
53
+
54
+ end # namespace FileBlobs
55
+
56
+ ActiveRecord::FixtureSet.context_class.class_eval do
57
+ include FileBlobs::ActiveRecordFixtureSetExtensions
58
+ end
@@ -0,0 +1,31 @@
1
+ require 'active_record'
2
+
3
+ module FileBlobs
4
+
5
+ # Module mixed into ActiveRecord::Migration.
6
+ module ActiveRecordMigrationExtensions
7
+ # Creates the table used to hold file blobs.
8
+ #
9
+ # @param [Symbol] table_name the name of the table used to hold file data
10
+ # @param [Hash<Symbol, Object>] options
11
+ # @option options [Integer] blob_limit the maximum file size that can be
12
+ # stored in the table; defaults to 1 megabyte
13
+ def create_file_blobs_table(table_name = :file_blobs, options = {}, &block)
14
+ blob_limit = options[:blob_limit] || 1.megabyte
15
+
16
+ create_table table_name, id: false do |t|
17
+ t.primary_key :id, :string, null: false, limit: 48
18
+ t.binary :data, null: false, limit: blob_limit
19
+
20
+ # Block capturing and calling is a bit slower than using yield. This is
21
+ # not a concern because migrations aren't run in tight loops.
22
+ block.call t
23
+ end
24
+ end
25
+ end # module FileBlobs::ActiveRecordMigrationExtensions
26
+
27
+ end # namespace FileBlobs
28
+
29
+ ActiveRecord::Migration.class_eval do
30
+ include FileBlobs::ActiveRecordMigrationExtensions
31
+ end
@@ -0,0 +1,35 @@
1
+ require 'active_record'
2
+
3
+ module FileBlobs
4
+
5
+ # Module mixed into ActiveRecord::ConnectionAdapters::TableDefinition.
6
+ module ActiveRecordTableDefinitionExtensions
7
+ # Creates the table used to hold file blobs.
8
+ #
9
+ # @param [Symbol] table_name the name of the table used to hold file data
10
+ # @param [Hash<Symbol, Object>] options
11
+ # @option options [Boolean] null true
12
+ # @option options [Integer] blob_limit the maximum file size that can be
13
+ # stored in the table; defaults to 1 megabyte
14
+ def file_blob(column_name_base = :file, options = {}, &block)
15
+ allow_null = options[:null] || false
16
+ mime_type_limit = options[:mime_type_limit] || 64
17
+ file_name_limit = options[:file_name_limit] || 256
18
+
19
+ # The index is needed for garbage-collection eligibility checks.
20
+ string :"#{column_name_base}_blob_id", limit: 48, null: allow_null,
21
+ index: true
22
+
23
+ integer :"#{column_name_base}_size", null: allow_null
24
+ string :"#{column_name_base}_mime_type", limit: mime_type_limit,
25
+ null: allow_null
26
+ string :"#{column_name_base}_original_name", limit: file_name_limit,
27
+ null: allow_null
28
+ end
29
+ end # module FileBlobs::ActiveRecordTableDefinitionExtensions
30
+
31
+ end # namespace FileBlobs
32
+
33
+ ActiveRecord::ConnectionAdapters::TableDefinition.class_eval do
34
+ include FileBlobs::ActiveRecordTableDefinitionExtensions
35
+ end
@@ -0,0 +1,44 @@
1
+ require 'base64'
2
+
3
+ require 'active_support'
4
+
5
+ module FileBlobs
6
+
7
+ # Module mixed into ActiveRecord::FixtureSet.
8
+ module ActiveSupportTestFixtures
9
+ # The contents of a blob.
10
+ #
11
+ # @param [String] path the path of the file whose contents is used in the
12
+ # fixture, relative to the Rails application's test/fixtures directory
13
+ # @return [String] the blob contents
14
+ def file_blob_data(path)
15
+ file_path = Rails.root.join('test/fixtures'.freeze).join(path)
16
+ File.binread file_path
17
+ end
18
+
19
+ # Computes the ID assigned to a blob.
20
+ #
21
+ # @param [String] path the path of the file whose contents is used in the
22
+ # fixture, relative to the Rails application's test/fixtures directory
23
+ # @return [String] the ID used to represent the blob contents
24
+ def file_blob_id(path)
25
+ # This needs to be kept in sync with blob_model.rb.
26
+ Base64.urlsafe_encode64(Digest::SHA256.digest(file_blob_data(path)))
27
+ end
28
+
29
+ # The size of a blob.
30
+ #
31
+ # @param [String] path the path of the file whose contents is used in the
32
+ # fixture, relative to the Rails application's test/fixtures directory
33
+ # @return [String] the blob contents
34
+ def file_blob_size(path)
35
+ file_path = Rails.root.join('test/fixtures'.freeze).join(path)
36
+ File.stat(file_path).size
37
+ end
38
+ end # module FileBlobs::ActiveSupportTestFixtures
39
+
40
+ end # namespace FileBlobs
41
+
42
+ ActiveSupport::TestCase.class_eval do
43
+ include FileBlobs::ActiveSupportTestFixtures
44
+ end