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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 883e1fca817fa83c79ade24f1c4a19a60b9881b75c79f736d8b7b4a69aa4f873
|
|
4
|
+
data.tar.gz: 494a125d5aafa29c096059da6097cb721fa29261a7e5c5a56b6f34ae17cc6715
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
data/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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
|
|
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'. " \
|
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.
|
|
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-
|
|
12
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|