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.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/.travis.yml +16 -0
- data/Gemfile +18 -0
- data/Gemfile.lock +397 -0
- data/Gemfile.rails5 +18 -0
- data/LICENSE.txt +20 -0
- data/README.md +62 -0
- data/Rakefile +38 -0
- data/VERSION +1 -0
- data/file_blobs_rails.gemspec +131 -0
- data/lib/file_blobs_rails/action_controller_data_streaming_extensions.rb +60 -0
- data/lib/file_blobs_rails/active_record_extensions.rb +257 -0
- data/lib/file_blobs_rails/active_record_fixture_set_extensions.rb +58 -0
- data/lib/file_blobs_rails/active_record_migration_extensions.rb +31 -0
- data/lib/file_blobs_rails/active_record_table_definition_extensions.rb +35 -0
- data/lib/file_blobs_rails/active_support_test_extensions.rb +44 -0
- data/lib/file_blobs_rails/blob_model.rb +105 -0
- data/lib/file_blobs_rails/engine.rb +10 -0
- data/lib/file_blobs_rails/file_blob_proxy.rb +7 -0
- data/lib/file_blobs_rails/generators/blob_model_generator.rb +25 -0
- data/lib/file_blobs_rails/generators/blob_owner_generator.rb +28 -0
- data/lib/file_blobs_rails/generators/templates/001_create_file_blobs.rb.erb +7 -0
- data/lib/file_blobs_rails/generators/templates/002_create_blob_owners.rb.erb +7 -0
- data/lib/file_blobs_rails/generators/templates/blob_owner.rb.erb +3 -0
- data/lib/file_blobs_rails/generators/templates/blob_owner_test.rb.erb +13 -0
- data/lib/file_blobs_rails/generators/templates/blob_owners.yml.erb +11 -0
- data/lib/file_blobs_rails/generators/templates/file_blob.rb.erb +11 -0
- data/lib/file_blobs_rails/generators/templates/file_blob_test.rb.erb +9 -0
- data/lib/file_blobs_rails/generators/templates/file_blobs.yml.erb +7 -0
- data/lib/file_blobs_rails/generators/templates/files/invoice.pdf +137 -0
- data/lib/file_blobs_rails/generators/templates/files/ruby.png +0 -0
- data/lib/file_blobs_rails.rb +21 -0
- data/test/blob_model_test.rb +23 -0
- data/test/blob_owner_test.rb +9 -0
- data/test/controller_extensions_test.rb +80 -0
- data/test/file_blob_proxy_test.rb +100 -0
- data/test/file_blob_test.rb +8 -0
- data/test/file_blobs_fixture_test.rb +21 -0
- data/test/fixtures/003_create_gem_test_blobs.rb +8 -0
- data/test/fixtures/004_create_gem_test_messages.rb +15 -0
- data/test/fixtures/files/invoice.pdf +137 -0
- data/test/fixtures/files/ruby.png +0 -0
- data/test/fixtures/gem_test_blob.rb +5 -0
- data/test/fixtures/gem_test_message.rb +5 -0
- data/test/garbage_collection_test.rb +84 -0
- data/test/helpers/action_controller.rb +5 -0
- data/test/helpers/db_setup.rb +22 -0
- data/test/helpers/fixtures.rb +41 -0
- data/test/helpers/i18n.rb +1 -0
- data/test/helpers/migrations.rb +41 -0
- data/test/helpers/rails.rb +9 -0
- data/test/helpers/routes.rb +18 -0
- data/test/helpers/test_order.rb +1 -0
- data/test/test_extensions_test.rb +21 -0
- data/test/test_helper.rb +36 -0
- 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
|