eluvia-base 3.40.0 → 3.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcf7c769d60f4347beee0d63aff1e1e8944e5fb2412985f531465dd833cfe58e
4
- data.tar.gz: dc7491fcac983ef84e9f33396139370ce94fb65ce8718742acc4806d4e35e0b6
3
+ metadata.gz: 69890341f7733c2669578108d2e8dec34c753a9cd9dcfc2ff1125d1ca710492a
4
+ data.tar.gz: f59a4a2012c88fa70479feb651857458b8ad9f9580904d340478fa26c51c429b
5
5
  SHA512:
6
- metadata.gz: 1a8eff124c578195160772b542e7ece4419b76ada79214f3b7d7ba3cb5c24768249b8d8c24ea207e37a33e6dc5d4d3b7196f83d369df428ae9724b36702af1e4
7
- data.tar.gz: 2df71bbb4195fde554b62bc8052d02894be13b14c652325f08af43b94d4b23385499bf0dbe5847ae16786519e59de04be8b6d8a65f4e734f8202bc99f793a55a
6
+ metadata.gz: c7f8f95b0d3b6948862cb21080001e1f35e891a4cb01dfa92c847b33145054de9cbc7f67be561f3ffd450e6e51d40b14568949f5e2dc7c1bcc860d52ded15135
7
+ data.tar.gz: 263c3e8f9a6769695aebade49c3bea01562571b6ea6e104ef6fddb9812d9c6f7b188b82a863f453a4a3bc44e00545a19e64f42ca23d1ad5f656a9cab32b9d4f9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.42.0] — 2026-10-28
9
+
10
+ ### Fixed
11
+ - `Eluvia::PaginationHandler#set_pagination`: `fallback_order_by` no longer overrides the
12
+ direction of a field that the client's `order_by` (or `default_order_by`) already orders by; it
13
+ only appends the fields not ordered by yet. This changes behavior: with the default
14
+ `fallback_order_by: 'created_at:asc'`, `?order_by=created_at:desc` now really sorts descending
15
+ (before it was silently turned into ascending), and `?order_by=id:desc` with
16
+ `fallback_order_by: 'id:asc'` keeps `id` descending (HAB-1064).
17
+
18
+ ## [3.41.0] — 2026-10-27
19
+
20
+ ### Added
21
+ - `Eluvia::ActiveRecord::FileArrayAttribute` registers each `file_array_attr` like `file_attr`
22
+ does: `file_array_attrs`, `file_array_attr_names`, `has_file_array_attr?` and
23
+ `find_file_array_attr` return its `underlying_attachments`, `underlying_filter`, `nature` and
24
+ `append_only` (HAB-991).
25
+ - `append_only:` option on `file_array_attr`: the setter takes a list of flat
26
+ `{filename, content}` files, attaches each one with content, never removes an existing
27
+ attachment, and ignores `nil`. Requires `underlying_attachments` (HAB-991).
28
+
8
29
  ## [3.40.0] — 2026-09-23
9
30
 
10
31
  ### Added
data/README.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # Eluvia Base
2
2
 
3
- This library provides a set of Ruby on Rails customizations and other helpers in order to meet common Eluvia standards
4
- for API communication (https://sparktech.myjetbrains.com/youtrack/articles/BASE-A-4/Communication-interface-FE-BE) and
5
- Eluvia requirements for backend application behavior.
3
+ Ruby on Rails customizations and helpers implementing the Eluvia
4
+ [API communication standard](https://sparktech.myjetbrains.com/youtrack/articles/BASE-A-4/Communication-interface-FE-BE)
5
+ and common backend behavior:
6
6
 
7
- It applies to both client and server sides:
8
7
  - Pagination and ordering is handled for the API requests (server side).
9
8
  - Extended params parsing is implemented for the API requests (server side).
10
9
  - Extended filtering is handled for the API requests (server side).
@@ -45,8 +44,7 @@ For full list of configuration options, see `lib/eluvia/base/config.rb` file.
45
44
 
46
45
  ### 2.1. Application controller setup
47
46
 
48
- In order to use all the implemented functionality, you should integrate all defined handlers to your
49
- main `ApplicationController`:
47
+ Include the handlers in your `ApplicationController`:
50
48
 
51
49
  ```ruby
52
50
 
@@ -61,78 +59,39 @@ end
61
59
 
62
60
  ### 2.2. Pagination
63
61
 
64
- In order to interpret `limit`, `offset` and `order_by` parameters, you can user `set_pagination` decorator. It will
65
- transform the input parameters into the `@order_by`, `@page`, `@limit` and `@padding` instance variables which can be
66
- easily used in Active Record and Kaminari interfaces.
62
+ `set_pagination` reads the `limit`, `offset` and `order_by` query params and sets `@limit`, `@page`, `@padding` and
63
+ `@order_by` for Active Record and Kaminari:
67
64
 
68
65
  ```ruby
69
-
70
66
  class TestRecordsController < ApplicationController
71
- # ...
72
-
73
- before_action -> { set_pagination(20, 0, 'created_at:desc') }, only: [:index]
74
-
75
- # ...
67
+ before_action -> { set_pagination(default_order_by: 'created_at:desc', fallback_order_by: 'id:asc') }, only: [:index]
76
68
 
77
69
  def index
78
70
  @test_records = TestRecord.all.order(@order_by).page(@page).per(@limit).padding(@padding)
79
71
  end
80
-
81
- # ...
82
- end
83
- ```
84
-
85
- **BREAKING CHANGE:** the `limit` param is now capped. Requests with a `limit` above the configured ceiling are
86
- rejected with a `422 Unprocessable Entity` (error detail on the `limit` field) instead of being silently accepted.
87
- A negative or non-numeric `limit` no longer falls through to Kaminari — it now also raises the same `422`. Only a
88
- missing or blank `limit` param still falls back to `default_limit`, unchanged. `limit=0` is treated as a deliberate
89
- meta-only request (see below) rather than an error, mirroring `ListPagination` in eluvia-base-django.
90
-
91
- The ceiling defaults to `Eluvia::Base::Config.max_page_limit` (`1000` by default, `nil` disables the check):
92
-
93
- ```ruby
94
- Eluvia::Base::Config.max_page_limit = 500
95
- ```
96
-
97
- It can be overridden per controller by overriding `pagination_max_limit`, or per call via the `max_limit:` keyword
98
- (an explicit `max_limit: nil` removes the ceiling for that call only, regardless of `pagination_max_limit`):
99
-
100
- ```ruby
101
- class TestRecordsController < ApplicationController
102
- def pagination_max_limit
103
- 50
104
- end
105
72
  end
106
73
  ```
107
74
 
108
- ```ruby
109
- before_action -> { set_pagination(default_limit: 20, default_offset: 0, max_limit: 50) }, only: [:index]
110
- ```
111
-
112
- To restore the pre-validation behavior (invalid or above-ceiling `limit` silently falls back to `default_limit`
113
- instead of raising `422`), enable `disable_errors` — globally via `Eluvia::Base::Config.disable_pagination_errors =
114
- true` (default: `false`), or per call via the `disable_errors:` keyword, which takes precedence over the global
115
- setting:
116
-
117
- ```ruby
118
- before_action -> { set_pagination(default_limit: 20, default_offset: 0, disable_errors: true) }, only: [:index]
119
- ```
120
-
121
- `limit=0` is accepted by default as a way to request only pagination metadata without fetching any records — check
122
- `@limit.zero?` in the action and skip the query:
123
-
124
- ```ruby
125
- def index
126
- @test_records = @limit.zero? ? TestRecord.none : TestRecord.all.order(@order_by).page(@page).per(@limit).padding(@padding)
127
- end
128
- ```
129
-
130
- Pass `allow_meta_only_limit: false` (e.g. for a search-backed resource, mirroring
131
- `AnySearchCursorBasedPagination` in eluvia-base-django) to reject `limit=0` like any other invalid value instead:
132
-
133
- ```ruby
134
- before_action -> { set_pagination(default_limit: 20, default_offset: 0, allow_meta_only_limit: false) }, only: [:index]
135
- ```
75
+ | Keyword | Default | Meaning |
76
+ |-------------------------|------------------------------------|---------------------------------------------------------------|
77
+ | `default_limit` | `20` | `limit` when the param is missing or blank |
78
+ | `default_offset` | `0` | `offset` when the param is missing |
79
+ | `max_limit` | `pagination_max_limit` | upper bound for `limit`, `nil` for none |
80
+ | `allow_meta_only_limit` | `true` | accept `limit=0` |
81
+ | `disable_errors` | `Config.disable_pagination_errors` | use `default_limit` instead of raising 422 on invalid `limit` |
82
+ | `default_order_by` | `'created_at:asc'` | ordering when `order_by` is missing or blank |
83
+ | `force_order_by` | `false` | ignore the `order_by` param, always use `default_order_by` |
84
+ | `fallback_order_by` | `'created_at:asc'` | tiebreaker appended to the ordering |
85
+
86
+ **Limit.** `limit` must be a non-negative integer not above the ceiling, otherwise the request fails with
87
+ `422 Unprocessable Entity` on `limit`. The ceiling is `Eluvia::Base::Config.max_page_limit` (default `1000`, `nil`
88
+ for none); a controller can override the `pagination_max_limit` method to set its own. `limit=0` returns only the
89
+ pagination metadata, so skip the query when `@limit.zero?`.
90
+
91
+ **Ordering.** `order_by` is a comma-separated list of `field:asc` or `field:desc` entries; anything else fails with
92
+ `422`. `fallback_order_by` only appends fields the ordering does not contain yet, so it never changes the requested
93
+ direction; set it to the primary key to keep pages stable when ordered values are equal. For example,
94
+ `?order_by=name:asc,id:desc` with `fallback_order_by: 'id:asc'` yields `{ 'name' => :asc, 'id' => :desc }`.
136
95
 
137
96
  ### 2.3. Params parser
138
97
 
@@ -195,8 +154,7 @@ For the full list of features, see `Eluvia::EluviaIntegration` interface.
195
154
 
196
155
  ### 2.5. Raising error
197
156
 
198
- You can raise en exception `Eluvia::Errors::XXX` anywhere in the code and this exception will be automatically
199
- formatted as standardized error response.
157
+ An `Eluvia::Errors::XXX` exception raised anywhere in the code is rendered as a standardized error response.
200
158
 
201
159
  For example, this exception:
202
160
 
@@ -256,12 +214,10 @@ For the full list of available exceptions, see content of `lib/eluvia/errors` fo
256
214
 
257
215
  ### 2.6. Chunked/direct upload finalization
258
216
 
259
- `Eluvia::File` carries an `upload_key` attribute, an alternative to `content` (base64 encoded file data)
260
- for `file_attr` fields backed by an underlying ActiveStorage attachment. When `upload_key` is present,
261
- the setter no longer base64-decodes `content` — instead it delegates to `Eluvia::Uploads.finalizer`, a
262
- registrable extension point that finalizes a previously uploaded (chunked/direct) temp object into the
263
- ActiveStorage attachment. This lets a separate library (e.g. a chunked upload provider) implement the
264
- actual storage logic (S3 `copy_object`, tus, ...) without eluvia-base depending on it.
217
+ An `Eluvia::File` assigned to a `file_attr` field may carry an `upload_key` instead of base64 `content`. The key
218
+ identifies an already uploaded (chunked/direct) temp object, and the setter passes it to `Eluvia::Uploads.finalizer`,
219
+ which attaches it to the ActiveStorage record. The storage logic (S3 `copy_object`, tus, ...) thus lives in the
220
+ application or another library, not in this gem. `file_array_attr` fields reject `upload_key` with `400 Bad Request`.
265
221
 
266
222
  Register a finalizer, typically from an initializer:
267
223
 
@@ -273,8 +229,7 @@ Eluvia::Uploads.finalizer = ->(attachment_record, upload_key, filename) do
273
229
  end
274
230
  ```
275
231
 
276
- If `upload_key` is present on the assigned `Eluvia::File` but no finalizer is registered, a
277
- `Eluvia::Errors::StandardError` is raised.
232
+ Assigning a file with `upload_key` while no finalizer is registered raises `Eluvia::Errors::StandardError`.
278
233
 
279
234
  ### 2.7. ENV-driven scope restriction
280
235
 
@@ -324,13 +279,10 @@ are NULL-safe: a row whose value is `NULL` is kept.
324
279
 
325
280
  ### 2.8. Translations
326
281
 
327
- All validation error messages raised by this gem (ordering, filtering, fieldset and pagination errors) go through
328
- `I18n.t` with an English `default:`, so the gem works out of the box without any locale setup. In addition,
329
- `lib/eluvia/locales/*.yml` ships ready-made translations — under the `eluvia.errors.*` key namespace — for the
330
- same set of languages supported by `habarico`: `cs`, `de`, `en`, `es`, `fr`, `it`, `ja`, `nl`, `pl`, `pt`, `ro`,
331
- `sk`, `uk`. These are appended to `I18n.load_path` automatically when the gem loads; a host application only
332
- needs to add the relevant locale to its own `I18n.available_locales` and set `I18n.locale` as usual. A host app's
333
- own `config/locales/*.yml` takes precedence over these if it defines the same key.
282
+ Validation error messages of this gem go through `I18n.t` with an English default, so no locale setup is needed.
283
+ Translations for `cs`, `de`, `en`, `es`, `fr`, `it`, `ja`, `nl`, `pl`, `pt`, `ro`, `sk` and `uk` (under
284
+ `eluvia.errors.*`) are added to `I18n.load_path` when the gem loads. The application's own `config/locales/*.yml`
285
+ takes precedence for the same key.
334
286
 
335
287
  ## 3. Development
336
288
 
@@ -6,6 +6,30 @@ module Eluvia
6
6
 
7
7
  class_methods do
8
8
 
9
+ # *********************************************************************
10
+ # Attribute store methods
11
+ # *********************************************************************
12
+
13
+ def _file_array_attrs
14
+ @file_array_attrs ||= {}
15
+ end
16
+
17
+ def file_array_attrs
18
+ _file_array_attrs.values
19
+ end
20
+
21
+ def file_array_attr_names
22
+ file_array_attrs.map { |a| a[:attr_name] }
23
+ end
24
+
25
+ def has_file_array_attr?(attr_name)
26
+ _file_array_attrs.key?(attr_name.to_sym)
27
+ end
28
+
29
+ def find_file_array_attr(attr_name)
30
+ _file_array_attrs.fetch(attr_name.to_sym, nil)
31
+ end
32
+
9
33
  # *********************************************************************
10
34
  # Attribute definition methods for file array
11
35
  # *********************************************************************
@@ -16,78 +40,101 @@ module Eluvia
16
40
  # @param underlying_attachments [Symbol, nil] Name of the attribute holding underlying Active Storage attachments
17
41
  # @param underlying_filter [Proc, nil] Optional filter to be applied to scope underlying attachments
18
42
  # @param nature [Symbol] Nature of file attribute (:file, :image, etc.)
43
+ # @param append_only [Boolean] Whether the setter only attaches new `{filename, content}` files and never removes existing ones
19
44
  # @param declare [Boolean] Whether to declare the underlying attribute
20
- def file_array_attr(attr_name, underlying_attachments: nil, underlying_filter: nil, nature: :file, declare: false)
45
+ def file_array_attr(attr_name, underlying_attachments: nil, underlying_filter: nil, nature: :file, append_only: false, declare: false)
21
46
  attr_name = attr_name.to_sym
22
47
  nature = nature.to_sym
23
48
 
49
+ raise ArgumentError, "`append_only` file array attribute `#{attr_name}` requires `underlying_attachments`." if append_only && !underlying_attachments
50
+
24
51
  # Define it using "has many" attribute
25
52
  if declare
26
53
  has_many_attr(attr_name, nature == :image ? 'Eluvia::ImageWrapper' : 'Eluvia::FileWrapper')
27
54
  end
28
55
 
56
+ # Register metadata
57
+ self._file_array_attrs[attr_name] = {
58
+ attr_name: attr_name,
59
+ underlying_attachments: underlying_attachments,
60
+ underlying_filter: underlying_filter,
61
+ nature: nature,
62
+ append_only: append_only
63
+ }
64
+
29
65
  # *******************************************************************
30
66
  # Dynamically defined instance methods for file array
31
67
  # *******************************************************************
32
68
 
33
69
  if underlying_attachments
34
70
 
35
- # Set method
36
- define_method(:"#{attr_name}=") do |new_values|
37
-
38
- # Check input
39
- raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}`, expecting Array.") unless new_values.is_a?(::Array)
40
-
41
- # Current values
42
- current_values = self.send(attr_name)
43
- current_ids = current_values.map(&:id)
44
- new_ids = []
45
-
46
- # Create missing attachments
47
- new_values.each do |value|
48
- # Normalize and check value
49
- if value.is_a?(::Hash)
50
- if nature == :image
51
- value = Eluvia::ImageWrapper.map(value)
52
- else
53
- value = Eluvia::FileWrapper.map(value)
71
+ if append_only
72
+
73
+ # Set method - attaches each new file, leaves the existing attachments untouched
74
+ define_method(:"#{attr_name}=") do |new_values|
75
+ append_file_array_uploads(attr_name, underlying_attachments, new_values)
76
+ end
77
+
78
+ else
79
+
80
+ # Set method
81
+ define_method(:"#{attr_name}=") do |new_values|
82
+
83
+ # Check input
84
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}`, expecting Array.") unless new_values.is_a?(::Array)
85
+
86
+ # Current values
87
+ current_values = self.send(attr_name)
88
+ current_ids = current_values.map(&:id)
89
+ new_ids = []
90
+
91
+ # Create missing attachments
92
+ new_values.each do |value|
93
+ # Normalize and check value
94
+ if value.is_a?(::Hash)
95
+ if nature == :image
96
+ value = Eluvia::ImageWrapper.map(value)
97
+ else
98
+ value = Eluvia::FileWrapper.map(value)
99
+ end
54
100
  end
55
- end
56
- raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[]`, expecting Eluvia::FileWrapper or Eluvia::ImageWrapper.") unless value.is_a?(Eluvia::FileWrapper) || value.is_a?(Eluvia::ImageWrapper)
57
- raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[].file`, can't be blank.") if value.file.blank?
101
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[]`, expecting Eluvia::FileWrapper or Eluvia::ImageWrapper.") unless value.is_a?(Eluvia::FileWrapper) || value.is_a?(Eluvia::ImageWrapper)
102
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[].file`, can't be blank.") if value.file.blank?
58
103
 
59
- # Keep track of new ids
60
- new_ids << value.id if value.id
104
+ # Keep track of new ids
105
+ new_ids << value.id if value.id
61
106
 
62
- # Retrieve filename and content
63
- filename = value.file&.filename
64
- content = value.file&.content
107
+ # Retrieve filename and content
108
+ filename = value.file&.filename
109
+ content = value.file&.content
65
110
 
66
- # Sanitize filename
67
- filename = ActiveStorage::Filename.new(filename).sanitized
111
+ # Sanitize filename
112
+ filename = ActiveStorage::Filename.new(filename).sanitized
68
113
 
69
- # Create or update attachment only if both filename and content are passed (this means that user uploaded a new file)
70
- if !filename.blank? && !content.blank?
114
+ # Create or update attachment only if both filename and content are passed (this means that user uploaded a new file)
115
+ if !filename.blank? && !content.blank?
71
116
 
72
- # Delete the existing attachment
73
- if value.id && current_ids.include?(value.id)
74
- attachment_record = self.send("#{underlying_attachments}_attachments").find(value.id)
75
- attachment_record.purge_later
117
+ # Delete the existing attachment
118
+ if value.id && current_ids.include?(value.id)
119
+ attachment_record = self.send("#{underlying_attachments}_attachments").find(value.id)
120
+ attachment_record.purge_later
121
+ end
122
+
123
+ # Create a new attachment
124
+ self.send(underlying_attachments).attach(io: StringIO.new(Base64.decode64(content)), filename: filename)
76
125
  end
77
126
 
78
- # Create a new attachment
79
- self.send(underlying_attachments).attach(io: StringIO.new(Base64.decode64(content)), filename: filename)
80
127
  end
81
128
 
82
- end
83
-
84
- # Delete no longer wanted attachments
85
- current_values.each do |value|
86
- unless new_ids.include?(value.id)
87
- attachment_record = self.send("#{underlying_attachments}_attachments").find(value.id)
88
- attachment_record.purge_later
129
+ # Delete no longer wanted attachments
130
+ current_values.each do |value|
131
+ unless new_ids.include?(value.id)
132
+ attachment_record = self.send("#{underlying_attachments}_attachments").find(value.id)
133
+ attachment_record.purge_later
134
+ end
89
135
  end
90
136
  end
137
+
91
138
  end
92
139
 
93
140
  # Get method
@@ -111,6 +158,32 @@ module Eluvia
111
158
  end
112
159
 
113
160
  end
161
+
162
+ private
163
+
164
+ # Attaches each `{filename, content}` upload of an append-only file array attribute
165
+ def append_file_array_uploads(attr_name, underlying_attachments, new_values)
166
+ return if new_values.nil?
167
+
168
+ # Check input
169
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}`, expecting Array.") unless new_values.is_a?(::Array)
170
+
171
+ new_values.each do |value|
172
+ # Normalize and check value
173
+ value = Eluvia::File.map(value) if value.is_a?(::Hash)
174
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[]`, expecting Eluvia::File.") unless value.is_a?(Eluvia::File)
175
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[]`, `upload_key` is not supported.") if value.upload_key.present?
176
+
177
+ # An item without content carries no new file
178
+ next if value.content.blank?
179
+
180
+ filename = ActiveStorage::Filename.new(value.filename.to_s).sanitized
181
+ raise Eluvia::Errors::BadRequest.new("Invalid value for attribute `#{attr_name}[].filename`, can't be blank.") if filename.blank?
182
+
183
+ self.send(underlying_attachments).attach(io: StringIO.new(Base64.decode64(value.content)), filename: filename)
184
+ end
185
+ end
186
+
114
187
  end
115
188
  end
116
189
  end
@@ -75,11 +75,8 @@ module Eluvia
75
75
  value = Eluvia::File.map(value) if value.is_a?(::Hash)
76
76
  raise Eluvia::Errors::BadRequest.new("Invalid value for file attribute `#{attr_name}`, expecting Eluvia::File.") unless value.is_a?(Eluvia::File)
77
77
 
78
- # Retrieve and check filename - for a chunked/direct upload the frontend only sends
79
- # `upload_key` (see habarico's `uploadFileChunked`), not a separate filename; the key
80
- # itself already encodes the original filename as its last path segment (providers
81
- # build it as `<tmp_prefix><uuid>/<filename>`), so fall back to that instead of
82
- # requiring it to be sent twice.
78
+ # Retrieve and check filename - a chunked/direct upload may send only `upload_key`,
79
+ # whose last path segment is the original filename (`<tmp_prefix><uuid>/<filename>`)
83
80
  filename = value.filename
84
81
  filename = ::File.basename(value.upload_key) if filename.blank? && value.upload_key.present?
85
82
  raise Eluvia::Errors::BadRequest.new("Expected `filename` in the attribute `#{attr_name}`.") if filename.blank?
@@ -40,16 +40,14 @@ module Eluvia
40
40
  mattr_accessor :search_use_unaccent
41
41
  @@search_use_unaccent = true
42
42
 
43
- # Upper bound for the `limit` pagination param on `set_pagination`. Requests with a
44
- # higher `limit` are rejected with 422 instead of being silently truncated. `nil`
45
- # disables the check. Mirrors `MAX_PAGE_LIMIT` in eluvia-base-django. (default: 1000)
43
+ # Upper bound for the `limit` param of `set_pagination`; a higher `limit` is rejected with 422.
44
+ # `nil` disables the check. (default: 1000)
46
45
  mattr_accessor :max_page_limit
47
46
  @@max_page_limit = 1000
48
47
 
49
- # Whether an invalid `limit` param (non-numeric, negative, above `max_page_limit`, or zero
50
- # without `allow_meta_only_limit:`) on `set_pagination` silently falls back to
51
- # `default_limit` instead of being rejected with 422. Mirrors `disable_errors` on
52
- # `LimitResolutionMixin` in eluvia-base-django. (default: false)
48
+ # Whether `set_pagination` uses `default_limit` for an invalid `limit` param (non-numeric,
49
+ # negative, above `max_page_limit`, or zero without `allow_meta_only_limit:`) instead of
50
+ # rejecting it with 422. (default: false)
53
51
  mattr_accessor :disable_pagination_errors
54
52
  @@disable_pagination_errors = false
55
53
 
@@ -44,10 +44,18 @@ module Eluvia
44
44
  # A blank `order_by` is treated as "not supplied" (falls through to defaults/fallback)
45
45
  # rather than as a literal empty ordering, since a Kaminari scope always needs some order.
46
46
  order_by = !force_order_by && user_order_by.present? ? user_order_by : default_order_by
47
- order_by = "#{order_by},#{fallback_order_by}"
48
47
 
48
+ # Within `order_by` a repeated field takes its last direction; `fallback_order_by` only
49
+ # appends fields not already ordered by, so it never overrides the requested direction.
49
50
  @order_by = {}
50
- order_by.split(',').each do |entry|
51
+ parse_order_by(order_by) { |field_name, direction| @order_by[field_name] = direction }
52
+ parse_order_by(fallback_order_by) { |field_name, direction| @order_by[field_name] ||= direction }
53
+ end
54
+
55
+ private
56
+
57
+ def parse_order_by(order_by)
58
+ order_by.to_s.split(',').each do |entry|
51
59
  entry = entry.strip
52
60
  # `-1` keeps a trailing empty segment (e.g. `name:`) instead of Ruby's default of
53
61
  # dropping it, so it is caught below as an invalid direction rather than as a bare field.
@@ -68,13 +76,10 @@ module Eluvia
68
76
  })
69
77
  end
70
78
 
71
- @order_by[field_name] = direction.to_sym
79
+ yield field_name, direction.to_sym
72
80
  end
73
-
74
81
  end
75
82
 
76
- private
77
-
78
83
  def resolve_pagination_limit(default_limit:, max_limit:, disable_errors:, allow_meta_only_limit:)
79
84
  return default_limit unless params[:limit].present?
80
85
 
@@ -2,8 +2,8 @@ module Eluvia
2
2
  module Uploads
3
3
 
4
4
  # Extension point for finalizing chunked/direct uploads (identified by `Eluvia::File#upload_key`)
5
- # into the underlying ActiveStorage attachment. Left unset by default so that eluvia-base has no
6
- # dependency on any specific storage provider (e.g. aws-sdk-s3) or on habarico.
5
+ # into the underlying ActiveStorage attachment. Unset by default, so the gem does not depend on
6
+ # any storage provider (e.g. aws-sdk-s3).
7
7
  #
8
8
  # Consumers register a callable of the form:
9
9
  # ->(attachment_record, upload_key, filename) { ... }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eluvia-base
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.40.0
4
+ version: 3.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matěj Outlý
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client