mobility_active_storage 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d4544d367fbdc080f61260f4706289e6fa009088ab73399ee5ee4efbe1597f2f
4
+ data.tar.gz: cb31f79f1ba41e277fe74bd2a707e3a861a756b1e6c4659434ae5dac4312a19c
5
+ SHA512:
6
+ metadata.gz: 260385aedbb5bf84fa7c244a669b108fa3b6a7671d48228e70f617fa2656c53bf03fc90ee1993ee3d9513b26701226dad5572afdcea6be1dd53e00253eb8c941
7
+ data.tar.gz: 8ffb358c2898f8ae304c43f135e97ad4105e9fa97ba419b1c6af823e8afe26f621c85f343b2f092273441f777a1f1bc50d84089ddb440283dc7283005d2337bf
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ Pre-release fixes from a security review of the initial commit.
6
+
7
+ - A `fallback:` read option is now honoured only where the attribute actually configures
8
+ fallbacks. Previously `document(fallback: :ja)` cross-read another locale even on an attribute
9
+ declaring `fallbacks: false`, so the declaration was not enforceable by a caller forwarding
10
+ untrusted options. Matches Mobility, whose fallbacks plugin is inert when `fallbacks: false`.
11
+ - `with_attached_*` scopes now validate the current locale against the attribute's configured
12
+ locales, raising the same `MobilityActiveStorage::Error` as the read path instead of an
13
+ `ActiveRecord::AssociationNotFoundError` about a generated association name.
14
+ - The lazily built fallback proxy classes are now guarded by a mutex.
15
+ - Translated attachments are no longer merged into `attributes`, `translated_attributes` or
16
+ `attribute_names_for_serialization` by Mobility's `attribute_methods` plugin. The value there
17
+ was a live `ActiveStorage::Attached` proxy referencing the record, so `attributes.to_json` and
18
+ `as_json` raised `SystemStackError` and `Model.new(record.attributes)` raised `ArgumentError`.
19
+ Rails' own `has_one_attached` puts nothing in `attributes`; this now matches. (`attribute_methods:
20
+ false` is not a workaround -- Mobility 1.3.2 accepts and silently ignores it.)
21
+ - Querying a translated attachment now raises `MobilityActiveStorage::Error` with an explanation
22
+ instead of a `NoMethodError` from inside Mobility's query plugin.
23
+ - The test harness now enables `attribute_methods`, `query`, `dirty` and `fallthrough_accessors`,
24
+ which it previously did not, so the suite exercises a maximal Mobility configuration.
25
+ - Raised the `activerecord` / `activestorage` floor to `>= 7.2.3.2`, the earliest release clearing
26
+ every current Active Storage advisory. Rails 7.0 and 7.1 are end-of-life with no fix available for
27
+ those advisories, and this gem serves more attachments through exactly the affected paths.
28
+ - CI runs a Rails 7.2/8.0/latest matrix on Ruby 3.2 through 4.0, plus `bundler-audit`. Actions are
29
+ pinned by SHA.
30
+ - README: strong-parameters guidance for the per-locale writers, and Rails version security notes.
31
+
32
+ ## [0.1.0] - 2026-08-07
33
+
34
+ - Initial release.
35
+ - `has_one_translated_attached` and `has_many_translated_attached` macros on Active Record models.
36
+ - `Mobility::Backends` registrations for `:active_storage` and `:active_storage_many`.
37
+ - Per-locale attachments stored under locale-suffixed Active Storage attachment names
38
+ (`document_en`, `document_fr`), requiring no migration.
39
+ - Opt-in per-attribute fallbacks, where reads resolve through the fallback chain while writes
40
+ stay in the current locale.
41
+ - `#{attribute}_locales` and a current-locale `with_attached_#{attribute}` scope.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "mobility_active_storage" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["barnaclebarnes@mac.com"](mailto:"barnaclebarnes@mac.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Glen Barnes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,247 @@
1
+ # mobility_active_storage
2
+
3
+ Translated [Active Storage](https://guides.rubyonrails.org/active_storage_overview.html)
4
+ attachments for [Mobility](https://github.com/shioyama/mobility).
5
+
6
+ Attach a different file per locale under a single attribute name:
7
+
8
+ ```ruby
9
+ class Product < ApplicationRecord
10
+ extend Mobility
11
+
12
+ translates :title, :description, type: :string # ordinary Mobility text
13
+ has_one_translated_attached :document
14
+ has_many_translated_attached :photos
15
+ end
16
+ ```
17
+
18
+ ```ruby
19
+ product.document.attach(io: pdf, filename: "manual.pdf", content_type: "application/pdf")
20
+
21
+ Mobility.with_locale(:fr) do
22
+ product.document.attach(io: pdf_fr, filename: "manuel.pdf", content_type: "application/pdf")
23
+ end
24
+
25
+ product.document # => the :en file
26
+ Mobility.with_locale(:fr) { product.document } # => the :fr file
27
+ product.document_fr # => the :fr file
28
+ ```
29
+
30
+ ## No migration required
31
+
32
+ Active Storage's `active_storage_attachments.name` column is a plain string, so each locale is
33
+ stored as an ordinary attachment whose name carries the locale:
34
+
35
+ | name | record_type | record_id | blob_id |
36
+ | ------------- | ----------- | --------- | ------- |
37
+ | `document_en` | Product | 1 | 10 |
38
+ | `document_fr` | Product | 1 | 11 |
39
+
40
+ Each is declared with a real `has_one_attached` / `has_many_attached` under the hood, so variants,
41
+ previews, direct uploads, `purge`, `dependent: :purge_later` on destroy, and the generated
42
+ `document_en_attachment` / `document_en_blob` / `with_attached_document_en` all keep working
43
+ exactly as Rails defines them.
44
+
45
+ ## Installation
46
+
47
+ ```ruby
48
+ # Gemfile
49
+ gem "mobility_active_storage"
50
+ ```
51
+
52
+ You need Active Storage installed (`bin/rails active_storage:install`) and Mobility configured.
53
+ Mobility does not need any translation tables for attachments — if attachments are all you
54
+ translate, [installing Mobility without tables](https://github.com/shioyama/mobility#activerecord-rails)
55
+ is enough.
56
+
57
+ `extend Mobility` is optional: the macros extend the model for you if it isn't already.
58
+
59
+ ## Usage
60
+
61
+ ### Reading and writing
62
+
63
+ Everything Active Storage's own proxy supports works, scoped to the current locale:
64
+
65
+ ```ruby
66
+ product.document.attach(uploaded_file)
67
+ product.document.attached? # => true
68
+ product.document.filename # => "manual.pdf"
69
+ product.document.purge # purges only the current locale's file
70
+ product.document = nil # detaches only the current locale's file
71
+
72
+ product.photos.attach(one, two) # appends, per locale
73
+ product.photos = [one] # replaces the current locale's collection
74
+ ```
75
+
76
+ Assignment works on unsaved records and in `create`, so form params flow through normally:
77
+
78
+ ```ruby
79
+ Product.create!(title: "Widget", document: params[:document])
80
+ ```
81
+
82
+ ### Generated methods
83
+
84
+ For `has_one_translated_attached :document`:
85
+
86
+ | Method | Description |
87
+ | ----------------------------- | -------------------------------------------------------- |
88
+ | `document` | `ActiveStorage::Attached::One` for the current locale |
89
+ | `document=` | Attach/detach in the current locale |
90
+ | `document_en`, `document_fr` | A specific locale (dashed locales normalize: `pt_br`) |
91
+ | `document?` | Whether a file is attached in the current locale |
92
+ | `document_locales` | Locales that actually have an attachment, e.g. `[:en, :fr]` |
93
+ | `Product.with_attached_document` | Eager-loads whichever locale is current |
94
+
95
+ `has_many_translated_attached :photos` mirrors these with `ActiveStorage::Attached::Many`.
96
+
97
+ ### Fallbacks
98
+
99
+ Off by default. Enable per attribute:
100
+
101
+ ```ruby
102
+ has_one_translated_attached :document, fallbacks: true # I18n's chain, ending at the default locale
103
+ has_one_translated_attached :document, fallbacks: { fr: :en } # explicit
104
+ ```
105
+
106
+ Reads then resolve through the chain, while **writes always stay in the current locale** — so
107
+ attaching, purging or detaching under a fallback never touches the file you fell back to:
108
+
109
+ ```ruby
110
+ product.document.attach(en_file) # :en has a file, :fr does not
111
+
112
+ Mobility.with_locale(:fr) do
113
+ product.document.filename # => the :en file
114
+ product.document(fallback: false).attached? # => false
115
+ product.document.attach(fr_file) # creates document_fr; document_en untouched
116
+ end
117
+ ```
118
+
119
+ Pass `fallback: :ja` or `fallback: [:ja, :en]` to override the chain for a single read. That
120
+ override only applies where fallbacks are configured — an attribute declaring no fallbacks cannot be
121
+ talked into one by a read option, so `fallbacks: false` is enforceable even if a caller forwards
122
+ untrusted options into the reader. This matches Mobility, whose fallbacks plugin is inert when
123
+ `fallbacks: false`.
124
+
125
+ ### Options
126
+
127
+ Both macros accept:
128
+
129
+ - `fallbacks:` — `true`, or a hash such as `{ fr: :en }`. Default `false`.
130
+ - `locales:` — which locales to declare attachments for. Defaults to `Mobility.available_locales`.
131
+ - any remaining options are forwarded to Rails, e.g. `service:`, `strict_loading:`, `dependent:`.
132
+
133
+ ```ruby
134
+ has_one_translated_attached :document, locales: %i[en fr], service: :s3, strict_loading: true
135
+ ```
136
+
137
+ ### Using the backend directly
138
+
139
+ The macros are sugar over ordinary Mobility backends, which you can also use directly:
140
+
141
+ ```ruby
142
+ translates :document, backend: :active_storage
143
+ translates :photos, backend: :active_storage_many
144
+ ```
145
+
146
+ The macros additionally switch off the Mobility plugins that assume a scalar value. Declaring the
147
+ backend directly means doing that yourself, for whichever of them you have enabled:
148
+
149
+ ```ruby
150
+ translates :document, backend: :active_storage, fallbacks: false, cache: false, dirty: false
151
+ ```
152
+
153
+ Passing an option for a plugin you have *not* enabled raises `Mobility::Pluggable::InvalidOptionKey`,
154
+ so pass only the ones that apply. Keeping attachments out of `attributes` is handled by the backend
155
+ itself, so it applies on this path too.
156
+
157
+ ## Strong parameters
158
+
159
+ Each locale gets its own set of writers. `has_one_translated_attached :document` on a four-locale
160
+ app defines `document=`, `document_en=`, `document_fr=`, `document_ja=`, `document_pt_br=` (plus the
161
+ `_attachment=` / `_blob=` association writers Rails generates), and they all work through mass
162
+ assignment. That is inherent to `has_one_attached`, but the names are not obvious from the single
163
+ macro call in your model.
164
+
165
+ Permit only the bare attribute name, which routes through Mobility to the current locale:
166
+
167
+ ```ruby
168
+ params.expect(product: [:title, :document, photos: []])
169
+ ```
170
+
171
+ Permit the `_<locale>` variants only where a locale switcher genuinely needs them. A blanket
172
+ `permit!`, or a filter matching `/\Adocument/`, would let a user overwrite a locale they were never
173
+ editing — and with fallbacks enabled, that changes what other locales serve too.
174
+
175
+ ## Rails compatibility
176
+
177
+ Requires **Rails >= 7.2.3.2** and Ruby >= 3.2. Tested in CI against Rails 7.2, 8.0 and latest, on
178
+ Ruby 3.2 through 4.0.
179
+
180
+ The floor is deliberately a patch level rather than a minor version. Rails 7.0 and 7.1 are
181
+ end-of-life and carry Active Storage advisories with no fix available — path traversal and glob
182
+ injection in `DiskService`, a content-type bypass in direct uploads, and DoS via `Range` requests in
183
+ proxy mode. Since this gem's whole purpose is to serve more attachments through exactly those paths,
184
+ it does not support them. 7.2.3.2 is the earliest release that clears every current Active Storage
185
+ advisory; the equivalent for the 8.0 series is 8.0.5.1.
186
+
187
+ Run [bundler-audit](https://github.com/rubysec/bundler-audit) or Dependabot in your application to
188
+ stay ahead of new advisories.
189
+
190
+ ## Notes and limitations
191
+
192
+ **Locales are read when the class body runs.** Attachments are declared for
193
+ `Mobility.available_locales` at that moment, so adding a locale needs a code reload. Pass
194
+ `locales:` to pin the set explicitly.
195
+
196
+ **Mobility's `fallbacks` plugin is disabled for these attributes**, and this gem implements
197
+ fallbacks itself. Mobility's plugin triggers on a `nil` read, but an attachment reader has to
198
+ return a proxy even when nothing is attached, or `product.document.attach(...)` would fail on a
199
+ record with no file yet.
200
+
201
+ **The `cache` plugin is disabled** for these attributes, since it would memoize a proxy resolved
202
+ through a fallback and keep returning the fallback locale's file after a later attach.
203
+
204
+ **Dirty tracking is not supported.** Mobility's `dirty` plugin compares scalar values; it is
205
+ disabled for these attributes. Active Storage's own `attachment_changes` still works.
206
+
207
+ **Attachment attributes are kept out of `attributes`.** Mobility's `attribute_methods` plugin
208
+ merges every translated attribute into `attributes`, `translated_attributes` and
209
+ `attribute_names_for_serialization`. For an attachment that value would be a live
210
+ `ActiveStorage::Attached` proxy holding a reference back to the record, which makes the hash
211
+ unserialisable (`attributes.to_json` and `as_json` recurse until the stack overflows) and unusable
212
+ for mass assignment (`Model.new(record.attributes)` raises `ArgumentError`, since Active Storage
213
+ rejects a proxy as an attachable). Rails' own `has_one_attached` puts nothing in `attributes`, and
214
+ neither does this gem — a proxy is not a serialisable value. Translated *text* attributes are
215
+ unaffected and still appear as Mobility intends.
216
+
217
+ Note that `attribute_methods: false` is not a workaround: Mobility 1.3.2 accepts the option and
218
+ silently ignores it, because the plugin's `initialize_hook` is gated on `dependencies_satisfied?`
219
+ rather than on the option value.
220
+
221
+ **Querying is not supported.** `Product.i18n.where(document: ...)` is not meaningful for
222
+ attachments — there is no comparable column, only rows in `active_storage_attachments`. The
223
+ backend raises `MobilityActiveStorage::Error` explaining this rather than failing obscurely inside
224
+ Arel. Query the attachments directly, filtering on `name` (for example `"document_en"`).
225
+
226
+ ## Development
227
+
228
+ ```sh
229
+ bin/setup
230
+ bundle exec rake # tests + rubocop
231
+
232
+ # against a specific Rails version
233
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
234
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
235
+ ```
236
+
237
+ The suite boots a minimal `Rails::Application` in `test/test_helper.rb` against in-memory SQLite,
238
+ so there is no dummy app to maintain.
239
+
240
+ ## Contributing
241
+
242
+ Bug reports and pull requests are welcome on GitHub at
243
+ https://github.com/barnaclebarnes/mobility_active_storage.
244
+
245
+ ## License
246
+
247
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MobilityActiveStorage
4
+ # Keeps translated attachments out of the attribute hashes.
5
+ #
6
+ # Mobility's attribute_methods plugin merges every translated attribute into +attributes+. For
7
+ # an attachment that value is a live +ActiveStorage::Attached+ proxy holding a reference back to
8
+ # the record, so the hash cannot be serialised (+to_json+ recurses until the stack blows) and
9
+ # cannot be fed back into +Model.new+ (Active Storage rejects a proxy as an attachable). Rails'
10
+ # own +has_one_attached+ puts nothing in +attributes+; this restores that.
11
+ #
12
+ # Overriding +translated_attributes+ also cleans +attributes+: the plugin defines the latter as
13
+ # +super.merge(translated_attributes)+, and that inner call dispatches from the record, so it
14
+ # reaches this module first.
15
+ #
16
+ # Prepended rather than included, which matters. +include+ places this module *behind* any
17
+ # Mobility module added afterwards, and re-including an already-included module does not move it
18
+ # forward -- so a second +translates+ call (a second attachment macro, or a later
19
+ # +translates :title+) would merge its name back in after the filter had run. A prepended module
20
+ # stays ahead of every later include. Verified against Ruby's method resolution, not assumed.
21
+ module AttributeMethodsExclusion
22
+ # Registers +attribute+ on +model_class+ and installs the filter. Called from the backends'
23
+ # setup blocks rather than from the macros, so that declaring the backend directly with
24
+ # `translates :document, backend: :active_storage` is covered too.
25
+ def self.install(model_class, attribute)
26
+ model_class.translated_attachment_attribute_names << attribute.to_s
27
+
28
+ # Nothing to filter unless the plugin that does the merging is loaded -- and `super` in
29
+ # #translated_attributes would have nothing to reach.
30
+ return unless Mobility.translations_class.included_plugins
31
+ .map { |plugin| Mobility::Plugins.lookup_name(plugin) }
32
+ .include?(:attribute_methods)
33
+
34
+ model_class.prepend(self)
35
+ end
36
+
37
+ def translated_attributes
38
+ super.except(*self.class.translated_attachment_attribute_names)
39
+ end
40
+
41
+ private
42
+
43
+ def attribute_names_for_serialization
44
+ return unless defined?(super)
45
+
46
+ super - self.class.translated_attachment_attribute_names
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backend_methods"
4
+
5
+ module MobilityActiveStorage
6
+ # Mobility backend for a single translated Active Storage attachment.
7
+ #
8
+ # @example
9
+ # class Product < ApplicationRecord
10
+ # extend Mobility
11
+ # translates :document, backend: :active_storage
12
+ # end
13
+ #
14
+ # product.document.attach(io: file, filename: "en.pdf", content_type: "application/pdf")
15
+ # Mobility.with_locale(:fr) { product.document.attach(...) }
16
+ class Backend
17
+ include Mobility::Backend
18
+ include BackendMethods
19
+
20
+ setup do |attributes, options, backend_class|
21
+ attributes.each do |attribute|
22
+ options[:locales].each do |locale|
23
+ has_one_attached :"#{attribute}_#{Mobility.normalize_locale(locale)}",
24
+ **options[:attached_options]
25
+ end
26
+
27
+ # Eager-loads whichever locale is current when the scope is evaluated. Validates the
28
+ # locale the same way the read path does.
29
+ scope :"with_attached_#{attribute}", lambda {
30
+ name = backend_class.attachment_name_for(attribute, Mobility.locale)
31
+ includes("#{name}_attachment": :blob)
32
+ }
33
+
34
+ define_method(:"#{attribute}_locales") { mobility_backends[attribute].locales }
35
+
36
+ # An attachment proxy is not a serialisable value, and Rails does not put one in
37
+ # `attributes` either. Installed here rather than in the macro so that declaring
38
+ # this backend directly through `translates` is covered too.
39
+ AttributeMethodsExclusion.install(self, attribute)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def attached_class
46
+ ActiveStorage::Attached::One
47
+ end
48
+
49
+ def fallback_attached_class
50
+ FallbackAttached.one
51
+ end
52
+
53
+ def build_change(name, value)
54
+ if value.nil? || value == ""
55
+ ActiveStorage::Attached::Changes::DeleteOne.new(name, model)
56
+ else
57
+ ActiveStorage::Attached::Changes::CreateOne.new(name, model, value)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n/locale/fallbacks"
4
+
5
+ module MobilityActiveStorage
6
+ # Behaviour shared by the has_one and has_many attachment backends.
7
+ #
8
+ # A translated attachment is stored as N ordinary Active Storage attachments whose
9
+ # +name+ column carries the locale, e.g. +document_en+ and +document_fr+. Each one is
10
+ # declared with a real +has_one_attached+ / +has_many_attached+ in the backend's setup
11
+ # block, so Rails' own associations, upload callbacks, scopes and purge-on-destroy
12
+ # behaviour apply unchanged.
13
+ #
14
+ # Backends must +include Mobility::Backend+ *before* this module, so that the overrides here
15
+ # (notably +each_locale+ and +present?+) take precedence over Mobility::Backend's generic
16
+ # implementations.
17
+ module BackendMethods
18
+ def self.included(base)
19
+ raise Error, "#{base} must include Mobility::Backend before #{self}" unless base < Mobility::Backend
20
+
21
+ base.extend(ClassMethods)
22
+ end
23
+
24
+ # Class-level configuration shared by both backends.
25
+ module ClassMethods
26
+ def valid_keys
27
+ %i[locales attachment_fallbacks attached_options]
28
+ end
29
+
30
+ def configure(options)
31
+ options[:locales] = Array(options[:locales] || Mobility.available_locales).map(&:to_sym)
32
+ options[:attached_options] ||= {}
33
+ options[:attachment_fallbacks] = build_fallbacks(options[:attachment_fallbacks])
34
+ end
35
+
36
+ # Locale-suffixed attachment name, rejecting locales this attribute was not configured
37
+ # for. Shared by the read path and by the eager-loading scopes, so both fail the same way
38
+ # rather than the scope leaking an ActiveRecord::AssociationNotFoundError about a
39
+ # generated association name.
40
+ def attachment_name_for(attribute, locale)
41
+ normalized = Mobility.normalize_locale(locale)
42
+ return "#{attribute}_#{normalized}" if normalized_locales.include?(normalized)
43
+
44
+ raise Error, "#{model_class.name} has no translated #{attribute} attachment for " \
45
+ "locale #{locale.inspect}. Configured locales: " \
46
+ "#{options[:locales].map(&:to_s).join(", ")}."
47
+ end
48
+
49
+ def configured_locale?(locale)
50
+ normalized_locales.include?(Mobility.normalize_locale(locale))
51
+ end
52
+
53
+ # Mobility's query plugin asks the backend for an Arel node so it can build a predicate.
54
+ # An attachment is a row in active_storage_attachments, not a comparable column value, so
55
+ # there is nothing meaningful to compare. Fail with an explanation rather than the
56
+ # NoMethodError a missing `[]` would otherwise produce.
57
+ def [](name, _locale)
58
+ raise Error, "cannot query translated attachment #{name} -- attachments are not " \
59
+ "comparable values. Query ActiveStorage::Attachment directly, filtering " \
60
+ "on name (for example \"#{name}_#{Mobility.normalize_locale}\")."
61
+ end
62
+
63
+ def normalized_locales
64
+ @normalized_locales ||= options[:locales].map { |locale| Mobility.normalize_locale(locale) }
65
+ end
66
+
67
+ private
68
+
69
+ def build_fallbacks(option)
70
+ case option
71
+ when true then FallbackChain.new({})
72
+ when Hash then FallbackChain.new(option)
73
+ else false
74
+ end
75
+ end
76
+ end
77
+
78
+ # Resolves a locale to the chain of locales to try, in order.
79
+ #
80
+ # Mirrors Mobility's own fallbacks plugin: an explicit map is honoured first, then I18n's
81
+ # configured fallbacks when the application has them enabled, and the default locale always
82
+ # terminates the chain. Resolved on each read so that changes to +I18n.default_locale+ or
83
+ # +I18n.fallbacks+ are picked up.
84
+ class FallbackChain
85
+ def initialize(map)
86
+ @fallbacks = I18n::Locale::Fallbacks.new(map)
87
+ end
88
+
89
+ def [](locale)
90
+ chain = @fallbacks[locale]
91
+ chain |= I18n.fallbacks[locale] if I18n.respond_to?(:fallbacks)
92
+ chain | [I18n.default_locale]
93
+ end
94
+ end
95
+
96
+ # Returns the Active Storage proxy for +locale+.
97
+ #
98
+ # Always returns a proxy, never nil, so that +record.document.attach(...)+ works on a
99
+ # record with nothing attached yet. Falls back to another locale only when the attribute
100
+ # was configured with fallbacks and the current locale has nothing attached.
101
+ #
102
+ # @param [Symbol] locale
103
+ # @return [ActiveStorage::Attached::One, ActiveStorage::Attached::Many]
104
+ def read(locale, fallback: true, **kwargs)
105
+ proxy = attached(locale)
106
+ return proxy if proxy.attached?
107
+
108
+ # Mobility's convention: an explicitly requested locale never falls back.
109
+ return proxy if fallback == false || kwargs[:locale]
110
+
111
+ fallback_locale = fallback_chain(locale, fallback).find do |candidate|
112
+ configured?(candidate) && attached(candidate).attached?
113
+ end
114
+ return proxy unless fallback_locale
115
+
116
+ # Reads resolve through the fallback locale; writes stay in the requested locale.
117
+ fallback_attached_class.new(attachment_name(locale), model, attachment_name(fallback_locale))
118
+ end
119
+
120
+ # Stages the attachment change for +locale+.
121
+ #
122
+ # Deliberately mutates +attachment_changes+ rather than calling +attach+.
123
+ # +ActiveStorage::Attached::One#attach+ is implemented as
124
+ # +record.public_send("document_en=", attachable)+, and Mobility's locale accessors take
125
+ # precedence over Active Storage's generated writer -- so calling +attach+ here would
126
+ # re-enter this same method. Writing the change directly makes both entry paths converge.
127
+ def write(locale, value, **)
128
+ name = attachment_name(locale)
129
+ model.attachment_changes[name] = build_change(name, value)
130
+ value
131
+ end
132
+
133
+ # Whether the attribute has an attachment in +locale+ (honouring fallbacks).
134
+ def present?(locale, **options)
135
+ read(locale, **options).attached?
136
+ end
137
+
138
+ # Yields each configured locale that actually has an attachment.
139
+ def each_locale
140
+ options[:locales].each { |locale| yield locale if attached(locale).attached? }
141
+ end
142
+
143
+ # The Active Storage proxy for +locale+, without any fallback handling.
144
+ def attached(locale)
145
+ @attached ||= {}
146
+ @attached[Mobility.normalize_locale(locale)] ||=
147
+ attached_class.new(attachment_name(locale), model)
148
+ end
149
+
150
+ private
151
+
152
+ def attachment_name(locale)
153
+ self.class.attachment_name_for(attribute, locale)
154
+ end
155
+
156
+ def configured?(locale)
157
+ self.class.configured_locale?(locale)
158
+ end
159
+
160
+ # +fallback: :fr+ or +fallback: [:fr, :es]+ overrides the configured chain for one read.
161
+ #
162
+ # An attribute that declares no fallbacks cannot be talked into one by a read option, so a
163
+ # +fallbacks: false+ declaration is enforceable: otherwise any reader that forwards
164
+ # caller-controlled options (a serializer, a GraphQL resolver) would become a cross-locale
165
+ # read. This matches Mobility, whose fallbacks plugin is inert when +fallbacks: false+.
166
+ def fallback_chain(locale, fallback)
167
+ fallbacks = options[:attachment_fallbacks]
168
+ return [] unless fallbacks
169
+ return Array(fallback) unless fallback == true
170
+
171
+ fallbacks[locale]
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MobilityActiveStorage
4
+ # Attached proxies used when a read resolves through a fallback locale.
5
+ #
6
+ # Reads (+attached?+, +filename+, +url+, iteration) resolve to the fallback locale's
7
+ # attachment, but every write -- +attach+, +purge+, +purge_later+, +detach+ -- stays bound to
8
+ # the locale actually being read. Without that split, +product.document.attach(file)+ under a
9
+ # fallback would overwrite the fallback locale's file instead of creating one for the
10
+ # current locale.
11
+ #
12
+ # The classes are built lazily: Active Storage's classes are autoloaded by its engine, which
13
+ # has not necessarily run at the point this gem is required from a Gemfile.
14
+ module FallbackAttached
15
+ # Guards the lazy build. Two threads racing here would both +const_set+, emitting an
16
+ # "already initialized constant" warning and handing callers structurally distinct classes,
17
+ # which would then fail each other's +is_a?+ checks.
18
+ BUILD_MUTEX = Mutex.new
19
+
20
+ class << self
21
+ def one
22
+ @one || BUILD_MUTEX.synchronize { @one ||= const_set(:One, build_one) }
23
+ end
24
+
25
+ def many
26
+ @many || BUILD_MUTEX.synchronize { @many ||= const_set(:Many, build_many) }
27
+ end
28
+
29
+ private
30
+
31
+ def build_one
32
+ build(::ActiveStorage::Attached::One) do
33
+ # The fallback locale's attachment stands in when this locale has none.
34
+ def attachment
35
+ own.attachment || record.public_send(:"#{fallback_name}_attachment")
36
+ end
37
+ end
38
+ end
39
+
40
+ def build_many
41
+ build(::ActiveStorage::Attached::Many) do
42
+ def attachments
43
+ own.attachments.presence || record.public_send(:"#{fallback_name}_attachments")
44
+ end
45
+
46
+ def blobs
47
+ own.blobs.presence || record.public_send(:"#{fallback_name}_blobs")
48
+ end
49
+ end
50
+ end
51
+
52
+ def build(plain_class, &reads)
53
+ Class.new(plain_class) do
54
+ attr_reader :fallback_name
55
+
56
+ define_method(:initialize) do |name, record, fallback_name|
57
+ super(name, record)
58
+ @fallback_name = fallback_name
59
+ end
60
+
61
+ # This locale as an ordinary Active Storage proxy. Writes delegate here so that
62
+ # Rails' own logic runs, bound to this locale's attachment name.
63
+ define_method(:own) { plain_class.new(name, record) }
64
+
65
+ def attach(*attachables) = own.attach(*attachables)
66
+ def purge = own.purge
67
+ def purge_later = own.purge_later
68
+ def detach = own.detach
69
+
70
+ class_eval(&reads)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backend_methods"
4
+
5
+ module MobilityActiveStorage
6
+ # Mobility backend for a translated collection of Active Storage attachments.
7
+ #
8
+ # @example
9
+ # class Product < ApplicationRecord
10
+ # extend Mobility
11
+ # translates :photos, backend: :active_storage_many
12
+ # end
13
+ #
14
+ # product.photos.attach(first, second)
15
+ # Mobility.with_locale(:fr) { product.photos.attach(other) }
16
+ class ManyBackend
17
+ include Mobility::Backend
18
+ include BackendMethods
19
+
20
+ setup do |attributes, options, backend_class|
21
+ attributes.each do |attribute|
22
+ options[:locales].each do |locale|
23
+ has_many_attached :"#{attribute}_#{Mobility.normalize_locale(locale)}",
24
+ **options[:attached_options]
25
+ end
26
+
27
+ scope :"with_attached_#{attribute}", lambda {
28
+ name = backend_class.attachment_name_for(attribute, Mobility.locale)
29
+ includes("#{name}_attachments": :blob)
30
+ }
31
+
32
+ define_method(:"#{attribute}_locales") { mobility_backends[attribute].locales }
33
+
34
+ # An attachment proxy is not a serialisable value, and Rails does not put one in
35
+ # `attributes` either. Installed here rather than in the macro so that declaring
36
+ # this backend directly through `translates` is covered too.
37
+ AttributeMethodsExclusion.install(self, attribute)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def attached_class
44
+ ActiveStorage::Attached::Many
45
+ end
46
+
47
+ def fallback_attached_class
48
+ FallbackAttached.many
49
+ end
50
+
51
+ # Mirrors the writer that has_many_attached generates, including carrying pending uploads
52
+ # across successive assignments so `attach` can append to an unsaved record.
53
+ def build_change(name, value)
54
+ attachables = Array(value).compact_blank
55
+
56
+ return ActiveStorage::Attached::Changes::DeleteMany.new(name, model) if attachables.none?
57
+
58
+ ActiveStorage::Attached::Changes::CreateMany.new(
59
+ name, model, attachables,
60
+ pending_uploads: model.attachment_changes[name].try(:pending_uploads)
61
+ )
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MobilityActiveStorage
4
+ # Class macros mixed into ActiveRecord::Base.
5
+ module Model
6
+ # Declares a single translated attachment.
7
+ #
8
+ # class Product < ApplicationRecord
9
+ # has_one_translated_attached :document
10
+ # has_one_translated_attached :manual, fallbacks: true, service: :s3
11
+ # end
12
+ #
13
+ # @param name [Symbol] attribute name
14
+ # @param fallbacks [Boolean, Hash] +true+ to fall back through I18n's chain, or an explicit
15
+ # hash such as <tt>{ fr: :en }</tt>. Defaults to no fallback.
16
+ # @param locales [Array<Symbol>, nil] locales to declare attachments for. Defaults to
17
+ # +Mobility.available_locales+ at the time the class body runs.
18
+ # @param attached_options [Hash] forwarded to +has_one_attached+ (+:service+,
19
+ # +:strict_loading+, +:dependent+).
20
+ def has_one_translated_attached(name, fallbacks: false, locales: nil, **attached_options)
21
+ translated_attachment(name, :active_storage, fallbacks, locales, attached_options)
22
+ end
23
+
24
+ # Declares a translated collection of attachments. Options match
25
+ # {#has_one_translated_attached}; +attached_options+ is forwarded to +has_many_attached+.
26
+ def has_many_translated_attached(name, fallbacks: false, locales: nil, **attached_options)
27
+ translated_attachment(name, :active_storage_many, fallbacks, locales, attached_options)
28
+ end
29
+
30
+ # Attribute names declared through the attachment macros, including any inherited from a
31
+ # superclass. Used to keep attachments out of the hashes Mobility's attribute_methods plugin
32
+ # builds; see {AttributeMethodsExclusion}.
33
+ def translated_attachment_attribute_names
34
+ @translated_attachment_attribute_names ||=
35
+ if superclass.respond_to?(:translated_attachment_attribute_names)
36
+ superclass.translated_attachment_attribute_names.dup
37
+ else
38
+ []
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def translated_attachment(name, backend, fallbacks, locales, attached_options)
45
+ extend Mobility unless singleton_class.include?(Mobility)
46
+
47
+ enabled = enabled_mobility_plugins
48
+
49
+ translates name, **{
50
+ backend: backend,
51
+ locales: locales,
52
+ attachment_fallbacks: fallbacks,
53
+ attached_options: attached_options
54
+ }.merge(plugin_overrides(locales, enabled))
55
+ end
56
+
57
+ # Mobility plugins that assume a scalar value, and so must be turned off per attribute.
58
+ # Only keys for plugins actually enabled are returned: Mobility raises InvalidOptionKey for
59
+ # an option belonging to a plugin that is not loaded.
60
+ def plugin_overrides(locales, enabled)
61
+ overrides = {}
62
+
63
+ # The fallbacks plugin consumes the `fallback:` read option before the backend sees it,
64
+ # and triggers only on a nil read -- but an attachment reader must always return a proxy.
65
+ # This gem implements attachment fallbacks itself instead.
66
+ overrides[:fallbacks] = false if enabled.include?(:fallbacks)
67
+
68
+ # The cache plugin would memoize a proxy that was resolved through a fallback, so a later
69
+ # attach in the current locale would keep returning the fallback locale's file.
70
+ overrides[:cache] = false if enabled.include?(:cache)
71
+
72
+ # Dirty tracking compares scalar values; attachment proxies are not comparable.
73
+ overrides[:dirty] = false if enabled.include?(:dirty)
74
+
75
+ # Keep locale accessors in step with the locales we actually declared attachments for.
76
+ overrides[:locale_accessors] = locales if locales && enabled.include?(:locale_accessors)
77
+
78
+ overrides
79
+ end
80
+
81
+ def enabled_mobility_plugins
82
+ Mobility.translations_class.included_plugins.map { |plugin| Mobility::Plugins.lookup_name(plugin) }
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MobilityActiveStorage
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mobility"
4
+ require "active_support"
5
+
6
+ require_relative "mobility_active_storage/version"
7
+
8
+ module MobilityActiveStorage
9
+ class Error < StandardError; end
10
+ end
11
+
12
+ require_relative "mobility_active_storage/attribute_methods_exclusion"
13
+ require_relative "mobility_active_storage/fallback_attached"
14
+ require_relative "mobility_active_storage/backend"
15
+ require_relative "mobility_active_storage/many_backend"
16
+ require_relative "mobility_active_storage/model"
17
+
18
+ Mobility::Backends.register_backend(:active_storage, MobilityActiveStorage::Backend)
19
+ Mobility::Backends.register_backend(:active_storage_many, MobilityActiveStorage::ManyBackend)
20
+
21
+ ActiveSupport.on_load(:active_record) do
22
+ extend MobilityActiveStorage::Model
23
+ end
@@ -0,0 +1,49 @@
1
+ module MobilityActiveStorage
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ # Class macros mixed into ActiveRecord::Base.
8
+ module Model
9
+ def has_one_translated_attached: (
10
+ Symbol name,
11
+ ?fallbacks: bool | Hash[Symbol, Symbol | Array[Symbol]],
12
+ ?locales: Array[Symbol]?,
13
+ **untyped attached_options
14
+ ) -> void
15
+
16
+ def has_many_translated_attached: (
17
+ Symbol name,
18
+ ?fallbacks: bool | Hash[Symbol, Symbol | Array[Symbol]],
19
+ ?locales: Array[Symbol]?,
20
+ **untyped attached_options
21
+ ) -> void
22
+ end
23
+
24
+ module BackendMethods
25
+ def read: (Symbol locale, ?fallback: bool | Symbol | Array[Symbol], **untyped) -> untyped
26
+ def write: (Symbol locale, untyped value, **untyped) -> untyped
27
+ def present?: (Symbol locale, **untyped) -> bool
28
+ def each_locale: () { (Symbol) -> void } -> void
29
+ def attached: (Symbol locale) -> untyped
30
+
31
+ class FallbackChain
32
+ def initialize: (Hash[Symbol, untyped] map) -> void
33
+ def []: (Symbol locale) -> Array[Symbol]
34
+ end
35
+ end
36
+
37
+ class Backend
38
+ include BackendMethods
39
+ end
40
+
41
+ class ManyBackend
42
+ include BackendMethods
43
+ end
44
+
45
+ module FallbackAttached
46
+ def self.one: () -> Class
47
+ def self.many: () -> Class
48
+ end
49
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mobility_active_storage
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Glen Barnes
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 7.2.3.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 7.2.3.2
26
+ - !ruby/object:Gem::Dependency
27
+ name: activestorage
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 7.2.3.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 7.2.3.2
40
+ - !ruby/object:Gem::Dependency
41
+ name: mobility
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.2'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '2.0'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '1.2'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ description: |
61
+ Adds has_one_translated_attached and has_many_translated_attached to Active Record models,
62
+ letting a single attachment name hold a different file per locale via Mobility. Requires no
63
+ migration: each locale is stored as an ordinary Active Storage attachment whose name carries
64
+ the locale suffix.
65
+ email:
66
+ - barnaclebarnes@mac.com
67
+ executables: []
68
+ extensions: []
69
+ extra_rdoc_files: []
70
+ files:
71
+ - CHANGELOG.md
72
+ - CODE_OF_CONDUCT.md
73
+ - LICENSE.txt
74
+ - README.md
75
+ - Rakefile
76
+ - lib/mobility_active_storage.rb
77
+ - lib/mobility_active_storage/attribute_methods_exclusion.rb
78
+ - lib/mobility_active_storage/backend.rb
79
+ - lib/mobility_active_storage/backend_methods.rb
80
+ - lib/mobility_active_storage/fallback_attached.rb
81
+ - lib/mobility_active_storage/many_backend.rb
82
+ - lib/mobility_active_storage/model.rb
83
+ - lib/mobility_active_storage/version.rb
84
+ - sig/mobility_active_storage.rbs
85
+ homepage: https://github.com/barnaclebarnes/mobility_active_storage
86
+ licenses:
87
+ - MIT
88
+ metadata:
89
+ source_code_uri: https://github.com/barnaclebarnes/mobility_active_storage
90
+ changelog_uri: https://github.com/barnaclebarnes/mobility_active_storage/blob/main/CHANGELOG.md
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 3.2.0
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 4.0.17
107
+ specification_version: 4
108
+ summary: Translated Active Storage attachments for Mobility.
109
+ test_files: []