forest_admin_datasource_customizer 1.41.0 → 1.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: 47ebd7f781910278fa96ced0b35acfb2a97023c0b003fc1a775ce667a90c2206
4
- data.tar.gz: 4678cd29c78725b64e12b8b569e41860c948da2611f91544b387cfe88331a08f
3
+ metadata.gz: 883e1fca817fa83c79ade24f1c4a19a60b9881b75c79f736d8b7b4a69aa4f873
4
+ data.tar.gz: 494a125d5aafa29c096059da6097cb721fa29261a7e5c5a56b6f34ae17cc6715
5
5
  SHA512:
6
- metadata.gz: c4489f21c237024936e1003175a7e8368fafabe50d689d1efa18c6bef3299d2e051df2819b2774816cdb6e05f8dc1f44e147600ab205f4ccbf237fc8ee5fe704
7
- data.tar.gz: 1af83a9d78d9298d2d34b7fa3312279a158deb6df63775cc5e81475b6ea63c2ae305316d91adf3463d128eb4a62dcfd88e7f59d4989a95e0e976a503e53d7e9e
6
+ metadata.gz: afd67ab9b9462e3698407fb54ca1f6d142b7353bec9b8207979d8a3b956cef83e6dcfcd6ad4c6c23255cd4e6428bd3b1500624be65c70b1971b9d4ac889facf7
7
+ data.tar.gz: a5408ea7f8b5e8fc1e0abdddd8c529e0c5858e422ef047ae14a7233bc107823ccfb171bd51595addde110a8125cf4f293d4634c5cd727dbbe947f2f44bdf10da
@@ -31,8 +31,33 @@ module ForestAdminDatasourceCustomizer
31
31
  push_customization { @stack.schema.get_collection(@name).override_schema(countable: false) }
32
32
  end
33
33
 
34
- def replace_search(&definition)
35
- push_customization { @stack.search.get_collection(@name).replace_search(definition) }
34
+ # On a natively searchable datasource, a selection does not narrow that search — it replaces it
35
+ # with the agent's own per-column one.
36
+ #
37
+ # A relation path into a collection the role cannot read is refused; own columns carry no check,
38
+ # there being no field-level permissions. A block names nothing, so its extended half is refused
39
+ # where permissions are enabled.
40
+ #
41
+ # An included relation path is read on a plain search too, so +searchExtended=0+ no longer means
42
+ # no relation traversal; with +only_fields+, +extended+ becomes inert entirely.
43
+ #
44
+ # Names resolve against the collection below this layer, and +rename_field+ sits above it: a field
45
+ # renamed +name+ -> +title+ is named +name+ here. Resolved in declaration order, at boot, so put
46
+ # +replace_search+ after the +add_field+ / +add_relation+ calls it depends on.
47
+ #
48
+ # Example:
49
+ # collection.replace_search(include_fields: ['project:name'], exclude_fields: ['description'])
50
+ # collection.replace_search { |value, _extended, _context| { field: 'name', operator: Operators::CONTAINS, value: value } }
51
+ def replace_search(include_fields: nil, exclude_fields: nil, only_fields: nil, &definition)
52
+ selection = {
53
+ include_fields: include_fields,
54
+ exclude_fields: exclude_fields,
55
+ only_fields: only_fields
56
+ }.compact
57
+
58
+ assert_search_replacement(selection, definition, only_fields: only_fields)
59
+
60
+ push_customization { @stack.search.get_collection(@name).replace_search(definition || selection) }
36
61
  end
37
62
 
38
63
  # Disable the search bar
@@ -310,6 +335,30 @@ module ForestAdminDatasourceCustomizer
310
335
 
311
336
  private
312
337
 
338
+ def assert_search_replacement(selection, definition, only_fields:)
339
+ if definition && selection.any?
340
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
341
+ 'replace_search accepts either a block or a field selection, not both'
342
+ end
343
+
344
+ if definition.nil? && selection.empty?
345
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
346
+ 'replace_search needs a block, or one of include_fields, exclude_fields, only_fields'
347
+ end
348
+
349
+ empty = selection.select { |_name, names| Array(names).empty? }
350
+ if empty.any?
351
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
352
+ "replace_search cannot take an empty #{empty.keys.join(" or ")}: use disable_search to " \
353
+ 'turn the search bar off'
354
+ end
355
+
356
+ return unless only_fields && selection.keys != [:only_fields]
357
+
358
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
359
+ 'replace_search accepts only_fields on its own, not alongside include_fields or exclude_fields'
360
+ end
361
+
313
362
  def push_customization(&customization)
314
363
  @stack.queue_customization(customization)
315
364
 
@@ -4,6 +4,7 @@ module ForestAdminDatasourceCustomizer
4
4
  class SearchCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator
5
5
  include ForestAdminDatasourceToolkit::Schema
6
6
  include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
7
+ include ForestAdminDatasourceToolkit::Exceptions
7
8
 
8
9
  POLYMORPHIC_TYPES = %w[PolymorphicManyToOne PolymorphicOneToOne].freeze
9
10
  TO_ONE_RELATIONS = %w[ManyToOne OneToOne].freeze
@@ -20,8 +21,10 @@ module ForestAdminDatasourceCustomizer
20
21
  end
21
22
 
22
23
  def replace_search(replacer)
24
+ assert_selection_resolves(replacer)
23
25
  @replacer = replacer
24
26
  @disabled_search = false
27
+ warn_extended_search_refused if handler
25
28
  mark_schema_as_dirty
26
29
  end
27
30
 
@@ -33,27 +36,20 @@ module ForestAdminDatasourceCustomizer
33
36
  # Search string is not significant
34
37
  return filter.override({ search: nil }) if !filter || !filter.search || filter.search.strip&.empty?
35
38
 
36
- # Implement search ourselves
37
- if @replacer || !@child_collection.schema[:searchable]
38
- ctx = ForestAdminDatasourceCustomizer::Context::CollectionCustomizationContext.new(self, caller)
39
- tree = default_replacer(filter.search, filter.search_extended)
40
-
41
- if @replacer
42
- plain_tree = @replacer.call(filter.search, filter.search_extended, ctx)
43
- tree = ConditionTreeFactory.from_plain_object(plain_tree)
44
- end
45
-
46
- # Note that if no fields are searchable with the provided searchString, the conditions
47
- # array might be empty, which will create a condition returning zero records
48
- # (this is the desired behavior).
49
- return filter.override({
50
- condition_tree: ConditionTreeFactory.intersect([filter.condition_tree, tree]),
51
- search: nil
52
- })
53
- end
54
-
55
39
  # Let sub-collection deal with the search
56
- filter
40
+ return filter unless implements_search?
41
+
42
+ tree = if handler
43
+ ctx = ForestAdminDatasourceCustomizer::Context::CollectionCustomizationContext.new(self, caller)
44
+ ConditionTreeFactory.from_plain_object(handler.call(filter.search, filter.search_extended, ctx))
45
+ else
46
+ search_condition_tree(filter.search, filter.search_extended)
47
+ end
48
+
49
+ filter.override({
50
+ condition_tree: ConditionTreeFactory.intersect([filter.condition_tree, tree]),
51
+ search: nil
52
+ })
57
53
  end
58
54
 
59
55
  # Answers against +@child_collection+, which is what the search actually reads: a field
@@ -63,21 +59,72 @@ module ForestAdminDatasourceCustomizer
63
59
  # field the search cannot match — a number column for a word, a uuid column for anything
64
60
  # else — is left out rather than reported as reached.
65
61
  #
66
- # +nil+ whenever this layer does not choose the fields — a replacer is installed, or the
67
- # child collection searches natively — because then no enumeration made here is true.
62
+ # +nil+ whenever this layer does not choose the fields — a callable replacer is installed, or
63
+ # the child collection searches natively — because then no enumeration made here is true.
68
64
  def searched_fields(search, extended)
69
65
  return nil unless enumerable_search?
70
66
  return [] if insignificant_search?(search)
71
67
 
72
- get_fields(extended).filter_map do |path, schema|
68
+ searchable_fields(extended).filter_map do |path, schema|
73
69
  searched_field(path) if build_condition(path, schema, search)
74
70
  end
75
71
  end
76
72
 
73
+ def search_handler?
74
+ !handler.nil?
75
+ end
76
+
77
77
  private
78
78
 
79
+ # Warned at boot, not left to the first caller who trips the 403: blocks installed before this
80
+ # version were served. Nil-guarded because the RPC agent runs its own +AgentFactory+ subclass,
81
+ # so the base facade's container is never built there — a customization must not become a boot
82
+ # failure over a warning.
83
+ def warn_extended_search_refused
84
+ logger = ForestAdminAgent::Facades::Container.logger
85
+
86
+ return if logger.nil?
87
+
88
+ logger.log(
89
+ 'Warn',
90
+ "An extended search on #{name} is refused where permissions are enabled: a " \
91
+ '`replace_search` block names no field, so the agent cannot check what it reads against ' \
92
+ "the caller's permissions. Declaring the search with " \
93
+ '`replace_search(include_fields: [...])` makes it checkable.'
94
+ )
95
+ end
96
+
97
+ def handler
98
+ @replacer.respond_to?(:call) ? @replacer : nil
99
+ end
100
+
101
+ def field_selection
102
+ @replacer.respond_to?(:call) ? nil : @replacer
103
+ end
104
+
105
+ def implements_search?
106
+ !@replacer.nil? || !@child_collection.schema[:searchable]
107
+ end
108
+
79
109
  def enumerable_search?
80
- @replacer.nil? && !@child_collection.schema[:searchable]
110
+ handler.nil? && implements_search?
111
+ end
112
+
113
+ def assert_selection_resolves(replacer)
114
+ return if replacer.nil? || replacer.respond_to?(:call)
115
+
116
+ selected = field_paths(replacer[:only_fields]) + field_paths(replacer[:include_fields])
117
+ excluded = field_paths(replacer[:exclude_fields])
118
+
119
+ selected.each { |path| selected_field(path) }
120
+ excluded.each { |path| excluded_field(path) }
121
+
122
+ overlap = selected.select { |path| excluded?(path, excluded) }
123
+
124
+ return if overlap.empty?
125
+
126
+ raise ForestException,
127
+ "Cannot both search and exclude #{overlap.map { |path| "'#{path}'" }.join(", ")}"
81
128
  end
82
129
 
83
130
  def insignificant_search?(search)
@@ -93,16 +140,95 @@ module ForestAdminDatasourceCustomizer
93
140
  }
94
141
  end
95
142
 
96
- def default_replacer(search, extended)
97
- searchable_fields = get_fields(extended)
98
-
99
- conditions = searchable_fields.map do |field, schema|
143
+ def search_condition_tree(search, extended)
144
+ conditions = searchable_fields(extended).filter_map do |field, schema|
100
145
  build_condition(field, schema, search)
101
146
  end
102
147
 
148
+ return ConditionTreeFactory.match_none if conditions.empty?
149
+
103
150
  ConditionTreeFactory.union(conditions)
104
151
  end
105
152
 
153
+ # Both the condition tree +refine_filter+ builds and the footprint +searched_fields+ reports
154
+ # come from here: a path the search reads without appearing in the footprint is a column read
155
+ # unchecked.
156
+ def searchable_fields(extended)
157
+ selection = field_selection || {}
158
+ only_fields = selection[:only_fields]
159
+
160
+ defaults = only_fields ? {} : get_fields(extended).to_h
161
+ selected = field_paths(only_fields) + field_paths(selection[:include_fields])
162
+
163
+ excluded = field_paths(selection[:exclude_fields])
164
+
165
+ defaults
166
+ .merge(selected.to_h { |path| resolved_field(path) })
167
+ .reject { |path, _schema| excluded?(path, excluded) }
168
+ end
169
+
170
+ def excluded?(path, excluded)
171
+ excluded.any? { |name| path == name || path.start_with?("#{name}:") }
172
+ end
173
+
174
+ # Unlike a selected path, a bare to-one relation is legal here: it drops every path through it,
175
+ # where naming the target's columns would have to be revisited each time it gains one.
176
+ #
177
+ # A name the search does not read is reported rather than refused. An exclusion states an
178
+ # intent that only becomes more true as the schema moves — a column turning unsearchable
179
+ # satisfies "never search this" — so refusing it would stop the agent booting over a
180
+ # configuration that was defensive on purpose. A typo still raises, from +get_field_schema+.
181
+ def excluded_field(path)
182
+ schema = ForestAdminDatasourceToolkit::Utils::Collection.get_field_schema(@child_collection, path)
183
+
184
+ unless excludable?(path, schema)
185
+ ForestAdminAgent::Facades::Container.logger&.log(
186
+ 'Debug',
187
+ "Excluding '#{path}' from the search on #{name} changes nothing: the search does not read it"
188
+ )
189
+ end
190
+
191
+ schema
192
+ end
193
+
194
+ def excludable?(path, schema)
195
+ return path.count(':') <= 1 && searchable_field?(schema) if schema.type == 'Column'
196
+
197
+ !path.include?(':') && TO_ONE_RELATIONS.include?(schema.type)
198
+ end
199
+
200
+ def field_paths(names)
201
+ Array(names).map(&:to_s)
202
+ end
203
+
204
+ # Strict where an end-user term would be interpreted: this list is written by the developer,
205
+ # so a name that names nothing, or names a relation the search cannot compare a term to, is a
206
+ # mistake to report.
207
+ def resolved_field(path)
208
+ schema = ForestAdminDatasourceToolkit::Utils::Collection.get_field_schema(@child_collection, path)
209
+
210
+ unless schema.type == 'Column'
211
+ raise ForestException, "Cannot search on '#{path}': a #{schema.type} is not a column"
212
+ end
213
+
214
+ [path, schema]
215
+ end
216
+
217
+ # A Number, Enum or UUID column reaching `build_condition` gets an EQUAL leaf its datasource
218
+ # never declared, and `get_fields` skips it silently — so a named one is refused instead.
219
+ def selected_field(path)
220
+ resolved = resolved_field(path)
221
+ schema = resolved.last
222
+
223
+ unless searchable_field?(schema)
224
+ raise ForestException,
225
+ "Cannot search on '#{path}': its #{schema.column_type} column declares no filter " \
226
+ 'operator a search term can use'
227
+ end
228
+
229
+ resolved
230
+ end
231
+
106
232
  def build_condition(field, schema, search_string)
107
233
  column_type = schema.column_type
108
234
  enum_values = schema.enum_values
@@ -151,7 +277,7 @@ module ForestAdminDatasourceCustomizer
151
277
  fields.push([name, field]) if field.type == 'Column' && searchable_field?(field)
152
278
 
153
279
  if POLYMORPHIC_TYPES.include?(field.type) && extended
154
- ForestAdminAgent::Facades::Container.logger.log(
280
+ ForestAdminAgent::Facades::Container.logger&.log(
155
281
  'Debug',
156
282
  "We're not searching through #{self.name}.#{name} because it's a polymorphic relation. " \
157
283
  "You can override the default search behavior with 'replace_search'. " \
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceCustomizer
2
- VERSION = "1.41.0"
2
+ VERSION = "1.42.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_datasource_customizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.41.0
4
+ version: 1.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-01 00:00:00.000000000 Z
12
+ date: 2026-09-02 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport