eluvia-base 3.39.0 → 3.41.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: 691157713c8474d969d76eaee1106d331696a45b9458ee64a563762391e43ed3
4
- data.tar.gz: 280074f623f0c865a349edaf49c277875c6eeb28374ab8062b0f2aff5f061d67
3
+ metadata.gz: 8d7c48fd8f44bcda78e3e702b68e2d1fc1da1f16c5c5231f9243c1a82c3132ca
4
+ data.tar.gz: 9eba98e2363832aea7e3015cfdc9312b7fb88fccae0d45def95cb34b8606a0ef
5
5
  SHA512:
6
- metadata.gz: 1e202828e32b3c665d109ec6584c68fe4ff047b46a9af05b3119c68118d692e306ab3deac2534bc46779dcfc3e5a51b5af057e21940f0aea56ee076f6df9c8c9
7
- data.tar.gz: 6eff59b834d789c738f7499d14770bc89cd2793d38ac337c2c7baf158b166170c94a38f55e7d4a6cad31cee49ba1937f81c1f9e15a9acd6748d711b8eb02356b
6
+ metadata.gz: eb92181c8003d8da9d2647734357877fc8e75aed3ae362ccd8fe91dad0da757f28648fbe1fffd9fcadb367ae97b5884670ae202847a5bf8842730b45b5355438
7
+ data.tar.gz: c6aa908b43d905ba1080d12b5c6c2cbeda73917c00a58ae45fbd3ef3c9ddf293ab87726036945ff27fce510dbac61d4ced691a1341d2997babeaa4c0c2e6b0d6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ 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.41.0] — 2026-10-27
9
+
10
+ ### Added
11
+ - `Eluvia::ActiveRecord::FileArrayAttribute` registers each `file_array_attr` like `file_attr`
12
+ does: `file_array_attrs`, `file_array_attr_names`, `has_file_array_attr?` and
13
+ `find_file_array_attr` return its `underlying_attachments`, `underlying_filter`, `nature` and
14
+ `append_only` (HAB-991).
15
+ - `append_only:` option on `file_array_attr`: the setter takes a list of flat
16
+ `{filename, content}` files, attaches each one with content, never removes an existing
17
+ attachment, and ignores `nil`. Requires `underlying_attachments` (HAB-991).
18
+
19
+ ## [3.40.0] — 2026-09-23
20
+
21
+ ### Added
22
+ - `Eluvia::ActiveRecord::EnvFilterable` — a generic, model-agnostic filter driven by deploy-time
23
+ config (typically a base64-encoded JSON ENV var). `env_filter_fields` declares the column
24
+ whitelist (the only part ever interpolated into SQL; all values stay bound parameters),
25
+ `parse_env_filter_config` decodes the base64 JSON, and `apply_env_filter(scope, config)` applies
26
+ the AND-combined `field`/`operator`/`value` conditions to a scope. Operators: `eq`, `not_eq`,
27
+ `in`, `not_in`, `like`, `not_like` (case-insensitive substring; negative operators are
28
+ NULL-safe). Extracted from the payments service so services need only declare their whitelist
29
+ (VD-2811).
30
+
8
31
  ## [3.39.0] — 2026-09-18
9
32
 
10
33
  ### Added
data/README.md CHANGED
@@ -276,7 +276,53 @@ end
276
276
  If `upload_key` is present on the assigned `Eluvia::File` but no finalizer is registered, a
277
277
  `Eluvia::Errors::StandardError` is raised.
278
278
 
279
- ### 2.7. Translations
279
+ ### 2.7. ENV-driven scope restriction
280
+
281
+ `Eluvia::ActiveRecord::EnvFilterable` applies a deploy-time restriction — typically a base64-encoded
282
+ JSON blob in an ENV var — to any scope. It is generic and model-agnostic: a model only declares which
283
+ columns the config may reference, and the config lists `field` / `operator` / `value` conditions that
284
+ are AND-combined. Only declared fields are ever interpolated into SQL (as a column name); every value
285
+ stays a bound parameter, so the config cannot inject SQL. Conditions with an unknown field or operator
286
+ are silently ignored.
287
+
288
+ ```ruby
289
+
290
+ class BankTransaction < ApplicationRecord
291
+ include Eluvia::ActiveRecord::EnvFilterable
292
+
293
+ # Only these columns may appear in a filter config.
294
+ env_filter_fields %i[id direction variable_symbol date amount is_hidden]
295
+ end
296
+ ```
297
+
298
+ Read and apply the config (e.g. from a Pundit `policy_scope`):
299
+
300
+ ```ruby
301
+
302
+ config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
303
+ scope = BankTransaction.apply_env_filter(BankTransaction.all, config)
304
+ ```
305
+
306
+ `parse_env_filter_config` returns `nil` for a blank/missing ENV var (which disables the filter) and
307
+ raises `ArgumentError` on malformed input, so a broken deploy config fails loudly instead of silently
308
+ letting everything through. The config shape is:
309
+
310
+ ```json
311
+ {
312
+ "conditions": [
313
+ { "field": "direction", "operator": "eq", "value": "in" },
314
+ { "field": "is_hidden", "operator": "eq", "value": false },
315
+ { "field": "variable_symbol", "operator": "like", "value": ["12", "34"] }
316
+ ]
317
+ }
318
+ ```
319
+
320
+ Operators: `eq` / `not_eq` (equality), `in` / `not_in` (membership; value may be a scalar or array),
321
+ `like` / `not_like` (case-insensitive `ILIKE '%…%'` substring match; value may be a scalar or array —
322
+ `like` matches any, `not_like` excludes any). The negative operators (`not_eq`, `not_in`, `not_like`)
323
+ are NULL-safe: a row whose value is `NULL` is kept.
324
+
325
+ ### 2.8. Translations
280
326
 
281
327
  All validation error messages raised by this gem (ordering, filtering, fieldset and pagination errors) go through
282
328
  `I18n.t` with an English `default:`, so the gem works out of the box without any locale setup. In addition,
@@ -0,0 +1,110 @@
1
+ require 'base64'
2
+
3
+ module Eluvia
4
+ module ActiveRecord
5
+ # Generic, model-agnostic filter driven by deploy-time configuration (typically a base64-encoded
6
+ # JSON blob in an ENV var). The config is a list of `field` / `operator` / `value` conditions,
7
+ # AND-combined, that narrow a scope — e.g. to restrict what an admin interface may ever see.
8
+ #
9
+ # Only fields declared via `env_filter_fields` are ever interpolated into SQL (as a column name);
10
+ # every value is a bound parameter, so the config cannot be used to inject arbitrary SQL. Unknown
11
+ # fields and operators are silently ignored.
12
+ #
13
+ # @example Declaring the whitelist on a model
14
+ # class BankTransaction < ApplicationRecord
15
+ # include Eluvia::ActiveRecord::EnvFilterable
16
+ # env_filter_fields %i[id direction variable_symbol date amount is_hidden]
17
+ # end
18
+ #
19
+ # @example Applying it to a scope (e.g. from a Pundit `policy_scope`)
20
+ # config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
21
+ # BankTransaction.apply_env_filter(scope, config)
22
+ module EnvFilterable
23
+ extend ::ActiveSupport::Concern
24
+
25
+ class_methods do
26
+
27
+ # Declares (with arguments) or returns (without arguments) the fields that may appear in an
28
+ # env filter config. Only these column names are ever interpolated into SQL. Names are stored
29
+ # as strings so they compare directly against the config's `field` values.
30
+ #
31
+ # @param field_names [Array<String, Symbol>] whitelisted column names
32
+ # @return [Array<String>] the declared whitelist
33
+ def env_filter_fields(*field_names)
34
+ if field_names.empty?
35
+ @_env_filter_fields ||= []
36
+ else
37
+ @_env_filter_fields = field_names.flatten.map(&:to_s)
38
+ end
39
+ end
40
+
41
+ # Decodes a base64-encoded JSON filter config (typically read straight from an ENV var) into a
42
+ # Hash. Returns `nil` for blank input so a missing ENV var disables the filter. Raises
43
+ # `ArgumentError` on malformed input so a broken deploy-time config fails loudly rather than
44
+ # silently letting everything through.
45
+ #
46
+ # @param encoded [String, nil] base64-encoded JSON, or `nil`/blank
47
+ # @return [Hash, nil]
48
+ def parse_env_filter_config(encoded)
49
+ return nil if encoded.blank?
50
+
51
+ JSON.parse(Base64.strict_decode64(encoded.strip))
52
+ rescue ArgumentError, JSON::ParserError => e
53
+ raise ArgumentError, "invalid env filter config: #{e.message}"
54
+ end
55
+
56
+ # Applies an env filter config to +scope+. Config is a Hash with a `"conditions"` array; each
57
+ # condition has `"field"`, `"operator"` and `"value"`. All conditions are AND-combined. A blank
58
+ # config returns the scope unchanged; conditions with an unknown field or operator are ignored.
59
+ #
60
+ # Operators:
61
+ # eq / not_eq equality (not_eq keeps NULLs)
62
+ # in / not_in membership; value may be a scalar or array (not_in keeps NULLs)
63
+ # like / not_like case-insensitive substring match; value may be a scalar or an array
64
+ # (like → matches any; not_like → excludes any, keeps NULLs)
65
+ #
66
+ # @param scope [ActiveRecord::Relation]
67
+ # @param config [Hash, nil]
68
+ # @return [ActiveRecord::Relation]
69
+ def apply_env_filter(scope, config)
70
+ return scope if config.blank?
71
+
72
+ Array(config['conditions']).each do |cond|
73
+ field = cond['field'].to_s
74
+ op = cond['operator'].to_s
75
+ next unless env_filter_fields.include?(field)
76
+
77
+ col = "#{table_name}.#{field}" # field is whitelisted → safe to interpolate
78
+ val = cond['value']
79
+
80
+ scope =
81
+ case op
82
+ when 'eq'
83
+ scope.where(field => val)
84
+ when 'not_eq'
85
+ scope.where("#{col} IS NULL OR #{col} <> ?", val)
86
+ when 'in'
87
+ scope.where(field => Array(val))
88
+ when 'not_in'
89
+ scope.where("#{col} IS NULL OR #{col} NOT IN (?)", Array(val))
90
+ when 'like'
91
+ Array(val).each_with_index.reduce(scope) do |s, (v, i)|
92
+ clause = scope.where("#{col} ILIKE ('%' || ? || '%')", v)
93
+ i.zero? ? clause : s.or(clause)
94
+ end
95
+ when 'not_like'
96
+ Array(val).reduce(scope) do |s, v|
97
+ s.where("#{col} IS NULL OR #{col} NOT ILIKE ('%' || ? || '%')", v)
98
+ end
99
+ else
100
+ scope
101
+ end
102
+ end
103
+
104
+ scope
105
+ end
106
+
107
+ end
108
+ end
109
+ end
110
+ end
@@ -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
data/lib/eluvia-base.rb CHANGED
@@ -20,6 +20,7 @@ require 'eluvia/active_model/object_attribute'
20
20
  require 'eluvia/active_model/range_attribute' # depends on object_attribute
21
21
 
22
22
  # Active record
23
+ require 'eluvia/active_record/env_filterable'
23
24
  require 'eluvia/active_record/file_array_attribute'
24
25
  require 'eluvia/active_record/file_attribute'
25
26
  require 'eluvia/active_record/filtering'
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.39.0
4
+ version: 3.41.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-18 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.19'
69
+ - !ruby/object:Gem::Dependency
70
+ name: base64
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: ostruct
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -128,6 +142,7 @@ files:
128
142
  - lib/eluvia/active_model/enum_attribute.rb
129
143
  - lib/eluvia/active_model/object_attribute.rb
130
144
  - lib/eluvia/active_model/range_attribute.rb
145
+ - lib/eluvia/active_record/env_filterable.rb
131
146
  - lib/eluvia/active_record/file_array_attribute.rb
132
147
  - lib/eluvia/active_record/file_attribute.rb
133
148
  - lib/eluvia/active_record/filtering.rb