ecoportal-api-graphql 1.3.11 → 1.3.12
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 +4 -4
- data/.ai-assistance/code/filter_contract_matrix.md +177 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +10 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +11 -0
- data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
- data/.ai-assistance/standards-version.json +21 -20
- data/CHANGELOG.md +32 -0
- data/CLAUDE.md +10 -0
- data/docs/self-docs/ARCHITECTURE.md +88 -0
- data/docs/self-docs/CHANGES.jsonl +7 -0
- data/docs/self-docs/CONVENTIONS.md +74 -0
- data/docs/self-docs/INTEGRATIONS.md +63 -0
- data/docs/self-docs/OVERVIEW.md +51 -0
- data/docs/self-docs/STATUS.md +69 -0
- data/docs/self-docs/self-docs-index.json +39 -0
- data/docs/worklog.md +0 -23
- data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
- data/lib/ecoportal/api/graphql/base/action.rb +43 -43
- data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
- data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
- data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
- data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
- data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
- data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
- data/lib/ecoportal/api/graphql/base/register.rb +18 -18
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
- data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
- data/lib/ecoportal/api/graphql/concerns.rb +14 -14
- data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
- data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
- data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
- data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
- data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
- data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
- data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
- data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
- data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
- data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +10 -1
|
@@ -1,367 +1,436 @@
|
|
|
1
|
-
module Ecoportal
|
|
2
|
-
module API
|
|
3
|
-
class GraphQL
|
|
4
|
-
module Input
|
|
5
|
-
# Builder for the searchConf hash argument used by pages, contractorEntities,
|
|
6
|
-
# personMembers, and other paginated queries.
|
|
7
|
-
#
|
|
8
|
-
# Usage:
|
|
9
|
-
# conf = SearchConf.new
|
|
10
|
-
# .filter(SearchConf::Exact.new(:external_id, 'ABC'))
|
|
11
|
-
# .sort(:created_at, :desc)
|
|
12
|
-
#
|
|
13
|
-
# # OR filter (find by one of several externalIds)
|
|
14
|
-
# conf = SearchConf.new.filter(
|
|
15
|
-
# SearchConf::Or.new(
|
|
16
|
-
# SearchConf::Exact.new(:external_id, '1111'),
|
|
17
|
-
# SearchConf::Exact.new(:external_id, '2222')
|
|
18
|
-
# )
|
|
19
|
-
# )
|
|
20
|
-
#
|
|
21
|
-
# # Parametrize — reuse the same structure with a different value
|
|
22
|
-
# base = SearchConf.new.filter(SearchConf::Exact.new(:external_id, 'X'))
|
|
23
|
-
# copy = base.with(external_id: 'Y')
|
|
24
|
-
#
|
|
25
|
-
# # Pass to any query:
|
|
26
|
-
# org.pages(searchConf: conf.to_h, first: 10)
|
|
27
|
-
#
|
|
28
|
-
# Filter operations (snake_case — CamelCase silently returns no results):
|
|
29
|
-
# SearchConf::Exact — exact_filter
|
|
30
|
-
# SearchConf::DateRange — date_filter
|
|
31
|
-
# SearchConf::Register — register_filter (org search only)
|
|
32
|
-
# SearchConf::And — and_filter (explicit AND group)
|
|
33
|
-
# SearchConf::Or — or_filter
|
|
34
|
-
class SearchConf
|
|
35
|
-
# ---------------------------------------------------------------------------
|
|
36
|
-
# AI-assisted filter generation (optional — requires 'anthropic' gem)
|
|
37
|
-
# ---------------------------------------------------------------------------
|
|
38
|
-
class << self
|
|
39
|
-
# Build a SearchConf from a natural-language description using Claude.
|
|
40
|
-
# See SearchConf::AIGenerator for full documentation and options.
|
|
41
|
-
#
|
|
42
|
-
# Usage:
|
|
43
|
-
# require 'anthropic'
|
|
44
|
-
# conf = SearchConf.from_description(
|
|
45
|
-
# "active pages in Safety register updated this month",
|
|
46
|
-
# register_id: 'REG_123'
|
|
47
|
-
# )
|
|
48
|
-
def from_description(text, **opts)
|
|
49
|
-
require_relative 'search_conf/ai_generator'
|
|
50
|
-
AIGenerator.from_description(text, **opts)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# ---------------------------------------------------------------------------
|
|
55
|
-
# Composable — adds & and | operators to filter classes so they can be
|
|
56
|
-
# combined using natural Ruby operator syntax:
|
|
57
|
-
# filter1 & filter2 → SearchConf::And.new(filter1, filter2)
|
|
58
|
-
# filter1 | filter2 → SearchConf::Or.new(filter1, filter2)
|
|
59
|
-
# ---------------------------------------------------------------------------
|
|
60
|
-
module Composable
|
|
61
|
-
def &(other)
|
|
62
|
-
SearchConf::And.new(self, other)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def |(other)
|
|
66
|
-
SearchConf::Or.new(self, other)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# ---------------------------------------------------------------------------
|
|
71
|
-
# Filter: exact_filter
|
|
72
|
-
# ---------------------------------------------------------------------------
|
|
73
|
-
class Exact
|
|
74
|
-
include Composable
|
|
75
|
-
|
|
76
|
-
OPERATION = 'exact_filter'.freeze
|
|
77
|
-
|
|
78
|
-
def initialize(key, value)
|
|
79
|
-
@key = key.to_s
|
|
80
|
-
@value = value
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def to_h
|
|
84
|
-
{ operation: OPERATION, params: { key: @key, value: @value } }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def substitute(subs)
|
|
88
|
-
key_sym = @key.to_sym
|
|
89
|
-
return self unless subs.key?(key_sym)
|
|
90
|
-
|
|
91
|
-
self.class.new(@key, subs[key_sym])
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# ---------------------------------------------------------------------------
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
@
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
# ---------------------------------------------------------------------------
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
end
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Input
|
|
5
|
+
# Builder for the searchConf hash argument used by pages, contractorEntities,
|
|
6
|
+
# personMembers, and other paginated queries.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# conf = SearchConf.new
|
|
10
|
+
# .filter(SearchConf::Exact.new(:external_id, 'ABC'))
|
|
11
|
+
# .sort(:created_at, :desc)
|
|
12
|
+
#
|
|
13
|
+
# # OR filter (find by one of several externalIds)
|
|
14
|
+
# conf = SearchConf.new.filter(
|
|
15
|
+
# SearchConf::Or.new(
|
|
16
|
+
# SearchConf::Exact.new(:external_id, '1111'),
|
|
17
|
+
# SearchConf::Exact.new(:external_id, '2222')
|
|
18
|
+
# )
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# # Parametrize — reuse the same structure with a different value
|
|
22
|
+
# base = SearchConf.new.filter(SearchConf::Exact.new(:external_id, 'X'))
|
|
23
|
+
# copy = base.with(external_id: 'Y')
|
|
24
|
+
#
|
|
25
|
+
# # Pass to any query:
|
|
26
|
+
# org.pages(searchConf: conf.to_h, first: 10)
|
|
27
|
+
#
|
|
28
|
+
# Filter operations (snake_case — CamelCase silently returns no results):
|
|
29
|
+
# SearchConf::Exact — exact_filter
|
|
30
|
+
# SearchConf::DateRange — date_filter
|
|
31
|
+
# SearchConf::Register — register_filter (org search only)
|
|
32
|
+
# SearchConf::And — and_filter (explicit AND group)
|
|
33
|
+
# SearchConf::Or — or_filter
|
|
34
|
+
class SearchConf
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# AI-assisted filter generation (optional — requires 'anthropic' gem)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
class << self
|
|
39
|
+
# Build a SearchConf from a natural-language description using Claude.
|
|
40
|
+
# See SearchConf::AIGenerator for full documentation and options.
|
|
41
|
+
#
|
|
42
|
+
# Usage:
|
|
43
|
+
# require 'anthropic'
|
|
44
|
+
# conf = SearchConf.from_description(
|
|
45
|
+
# "active pages in Safety register updated this month",
|
|
46
|
+
# register_id: 'REG_123'
|
|
47
|
+
# )
|
|
48
|
+
def from_description(text, **opts)
|
|
49
|
+
require_relative 'search_conf/ai_generator'
|
|
50
|
+
AIGenerator.from_description(text, **opts)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Composable — adds & and | operators to filter classes so they can be
|
|
56
|
+
# combined using natural Ruby operator syntax:
|
|
57
|
+
# filter1 & filter2 → SearchConf::And.new(filter1, filter2)
|
|
58
|
+
# filter1 | filter2 → SearchConf::Or.new(filter1, filter2)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
module Composable
|
|
61
|
+
def &(other)
|
|
62
|
+
SearchConf::And.new(self, other)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def |(other)
|
|
66
|
+
SearchConf::Or.new(self, other)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Filter: exact_filter
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
class Exact
|
|
74
|
+
include Composable
|
|
75
|
+
|
|
76
|
+
OPERATION = 'exact_filter'.freeze
|
|
77
|
+
|
|
78
|
+
def initialize(key, value)
|
|
79
|
+
@key = key.to_s
|
|
80
|
+
@value = value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_h
|
|
84
|
+
{ operation: OPERATION, params: { key: @key, value: @value } }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def substitute(subs)
|
|
88
|
+
key_sym = @key.to_sym
|
|
89
|
+
return self unless subs.key?(key_sym)
|
|
90
|
+
|
|
91
|
+
self.class.new(@key, subs[key_sym])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# A register FIELD key carries a `<type>.<hash>` shape (e.g. 'date.zab1bddc3');
|
|
97
|
+
# system/top-level keys (created_at, external_id, state) have no such prefix.
|
|
98
|
+
# Field filters need the nested `membranes.<type>` path — see
|
|
99
|
+
# .ai-assistance/code/filter_contract_matrix.md.
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
module MembranePath
|
|
102
|
+
module_function
|
|
103
|
+
|
|
104
|
+
def field_key?(key)
|
|
105
|
+
key.to_s.include?('.')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def membrane_path_for(key)
|
|
109
|
+
"membranes.#{key.to_s.split('.').first}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Filter: date_filter (system date) / range_date_filter (register Date FIELD)
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
class DateRange
|
|
117
|
+
include Composable
|
|
118
|
+
|
|
119
|
+
def initialize(key, gte: nil, lte: nil, time_zone: 'UTC')
|
|
120
|
+
@key = key.to_s
|
|
121
|
+
@gte = gte
|
|
122
|
+
@lte = lte
|
|
123
|
+
@time_zone = time_zone
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A register Date FIELD ('date.zXXXX') filters via range_date_filter on its nested
|
|
127
|
+
# membranes.date path; a system date (created_at/updated_at) uses date_filter (no
|
|
128
|
+
# path). Both share gte/lte/time_zone. See filter_contract_matrix.md (date rows) +
|
|
129
|
+
# backend Pages::RangeDateFilter.
|
|
130
|
+
def to_h
|
|
131
|
+
field = MembranePath.field_key?(@key)
|
|
132
|
+
params = { key: @key, time_zone: @time_zone }
|
|
133
|
+
params[:path] = MembranePath.membrane_path_for(@key) if field
|
|
134
|
+
params[:gte] = @gte if @gte
|
|
135
|
+
params[:lte] = @lte if @lte
|
|
136
|
+
{ operation: field ? 'range_date_filter' : 'date_filter', params: params }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def substitute(subs)
|
|
140
|
+
self.class.new(
|
|
141
|
+
@key,
|
|
142
|
+
gte: subs.fetch(:gte, @gte),
|
|
143
|
+
lte: subs.fetch(:lte, @lte),
|
|
144
|
+
time_zone: subs.fetch(:time_zone, @time_zone)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Filter: has_any_filter / doesnt_have_any_filter — register FIELD value membership
|
|
151
|
+
# (Select, CrossReference, People, …). Distinct from `one_of` (an OR of exact_filter,
|
|
152
|
+
# for SYSTEM keys): a register field matches via the nested membranes.<type> path +
|
|
153
|
+
# a field_key subfield ('selected' for selects). See filter_contract_matrix.md +
|
|
154
|
+
# backend Pages::*::HasAnyFilter.
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
class HasAny
|
|
157
|
+
include Composable
|
|
158
|
+
|
|
159
|
+
def initialize(key, values, field_key: 'selected', exclude: false)
|
|
160
|
+
@key = key.to_s
|
|
161
|
+
@values = Array(values)
|
|
162
|
+
@field_key = field_key
|
|
163
|
+
@exclude = exclude
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def to_h
|
|
167
|
+
op = @exclude ? 'doesnt_have_any_filter' : 'has_any_filter'
|
|
168
|
+
{
|
|
169
|
+
operation: op,
|
|
170
|
+
params: {
|
|
171
|
+
key: @key,
|
|
172
|
+
path: MembranePath.membrane_path_for(@key),
|
|
173
|
+
field_key: @field_key,
|
|
174
|
+
values: @values
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def substitute(subs)
|
|
180
|
+
self.class.new(@key, subs.fetch(:values, @values), field_key: @field_key, exclude: @exclude)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Filter: register_filter (org search only — scope to specific registers)
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
class Register
|
|
188
|
+
include Composable
|
|
189
|
+
|
|
190
|
+
OPERATION = 'register_filter'.freeze
|
|
191
|
+
|
|
192
|
+
def initialize(*ids)
|
|
193
|
+
@ids = ids.flatten
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_h
|
|
197
|
+
{ operation: OPERATION, params: { ids: @ids } }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def substitute(subs)
|
|
201
|
+
self.class.new(*Array(subs.fetch(:register_ids, @ids)))
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Filter: and_filter (explicit AND group; top-level filters array is implicit AND)
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
class And
|
|
209
|
+
include Composable
|
|
210
|
+
|
|
211
|
+
OPERATION = 'and_filter'.freeze
|
|
212
|
+
|
|
213
|
+
def initialize(*filters)
|
|
214
|
+
@filters = filters.flatten
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def to_h
|
|
218
|
+
{ operation: OPERATION, params: { filters: @filters.map(&:to_h) } }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def substitute(subs)
|
|
222
|
+
self.class.new(*@filters.map { |f| f.respond_to?(:substitute) ? f.substitute(subs) : f })
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Filter: or_filter
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
class Or
|
|
230
|
+
include Composable
|
|
231
|
+
|
|
232
|
+
OPERATION = 'or_filter'.freeze
|
|
233
|
+
|
|
234
|
+
def initialize(*filters)
|
|
235
|
+
@filters = filters.flatten
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def to_h
|
|
239
|
+
{ operation: OPERATION, params: { filters: @filters.map(&:to_h) } }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def substitute(subs)
|
|
243
|
+
self.class.new(*@filters.map { |f| f.respond_to?(:substitute) ? f.substitute(subs) : f })
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# RawFilter — escape hatch for operations not yet wrapped as SearchConf classes.
|
|
249
|
+
# Supports & and | composition just like the typed filter classes.
|
|
250
|
+
# Usage:
|
|
251
|
+
# SearchConf.new.filter(SearchConf::RawFilter.new('has_any_filter', key: 'external_id'))
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
class RawFilter
|
|
254
|
+
include Composable
|
|
255
|
+
|
|
256
|
+
def initialize(operation, **params)
|
|
257
|
+
@operation = operation
|
|
258
|
+
@params = params
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def to_h
|
|
262
|
+
{ 'operation' => @operation, 'params' => @params.transform_keys(&:to_s) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def substitute(_subs)
|
|
266
|
+
self
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Fluent DSL class methods — factory for filter expressions.
|
|
272
|
+
#
|
|
273
|
+
# SearchConf[:field] → FieldRef (then call .eq, .since, .matches, etc.)
|
|
274
|
+
# SearchConf.in_register(id) → Register filter
|
|
275
|
+
# SearchConf.raw(op, **params) → RawFilter
|
|
276
|
+
#
|
|
277
|
+
# Examples:
|
|
278
|
+
# SearchConf[:external_id].eq('ABC')
|
|
279
|
+
# SearchConf[:state].is(:active)
|
|
280
|
+
# SearchConf[:updated_at].since('2025-01-01T00:00:00Z')
|
|
281
|
+
# SearchConf[:state].is(:active) & SearchConf.in_register('REG')
|
|
282
|
+
# SearchConf[:state].is(:active) | SearchConf[:state].is(:complete)
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
class << self
|
|
285
|
+
def [](key)
|
|
286
|
+
FieldRef.new(key.to_s)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def in_register(*ids)
|
|
290
|
+
Register.new(*ids.flatten)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def raw(operation, **params)
|
|
294
|
+
RawFilter.new(operation, **params)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# FieldRef — intermediate object produced by SearchConf[:field].
|
|
299
|
+
# Calling an operation method returns a concrete Composable filter object.
|
|
300
|
+
class FieldRef
|
|
301
|
+
def initialize(key)
|
|
302
|
+
@key = key
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def eq(value)
|
|
306
|
+
Exact.new(@key, value)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def is(value)
|
|
310
|
+
eq(value.to_s)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def one_of(*values)
|
|
314
|
+
Or.new(*values.flatten.map { |v| eq(v.to_s) })
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Register FIELD value membership → has_any_filter (Select/CrossReference/People/…).
|
|
318
|
+
# Use this for a register field ('select_str.zXXXX'), NOT one_of (which ORs
|
|
319
|
+
# exact_filter and is for system keys). field_key defaults to 'selected' (selects);
|
|
320
|
+
# pass e.g. 'id' for cross_reference. See filter_contract_matrix.md.
|
|
321
|
+
def any_of(*values, field_key: 'selected')
|
|
322
|
+
HasAny.new(@key, values.flatten, field_key: field_key)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def none_of(*values, field_key: 'selected')
|
|
326
|
+
HasAny.new(@key, values.flatten, field_key: field_key, exclude: true)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def not_eq(value)
|
|
330
|
+
RawFilter.new('isnt_exact_filter', key: @key, value: value)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def matches(text)
|
|
334
|
+
RawFilter.new('match_filter', key: @key, value: text)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def since(date, time_zone: 'UTC')
|
|
338
|
+
DateRange.new(@key, gte: date, time_zone: time_zone)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def before(date, time_zone: 'UTC')
|
|
342
|
+
DateRange.new(@key, lte: date, time_zone: time_zone)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def between(from, til, time_zone: 'UTC')
|
|
346
|
+
DateRange.new(@key, gte: from, lte: til, time_zone: time_zone)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Human date mode — Search[:updated_at].in_period(:last_month)
|
|
350
|
+
# See search_filters.md for full list of mode values.
|
|
351
|
+
def in_period(mode, time_zone: 'Pacific/Auckland', **extra)
|
|
352
|
+
RawFilter.new('date_filter', key: @key, mode: mode.to_s, time_zone: time_zone, **extra)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def present
|
|
356
|
+
RawFilter.new('has_any_filter', key: @key)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
alias_method :exists, :present
|
|
360
|
+
|
|
361
|
+
def blank
|
|
362
|
+
RawFilter.new('doesnt_exist_filter', field: @key)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def true?
|
|
366
|
+
RawFilter.new('boolean_filter', key: @key, value: true)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def false?
|
|
370
|
+
RawFilter.new('boolean_filter', key: @key, value: false)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def between_nums(min, max)
|
|
374
|
+
RawFilter.new('numeric_range_filter', key: @key, gte: min, lte: max)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# SearchConf builder
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
def initialize
|
|
382
|
+
@filters = []
|
|
383
|
+
@sorters = []
|
|
384
|
+
@search_query = nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Add one or more filter objects. Multiple calls accumulate (implicit AND at top level).
|
|
388
|
+
def filter(*filter_objs)
|
|
389
|
+
@filters.concat(filter_objs.flatten)
|
|
390
|
+
self
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Add a sorter. direction: :asc or :desc.
|
|
394
|
+
# Multiple sorters are supported (applied in order).
|
|
395
|
+
def sort(key, direction = :desc)
|
|
396
|
+
@sorters << { key: key.to_s, direction: direction.to_s }
|
|
397
|
+
self
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Set a full-text search string.
|
|
401
|
+
def query(text)
|
|
402
|
+
@search_query = text
|
|
403
|
+
self
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Returns a new SearchConf with filter values substituted.
|
|
407
|
+
# Substitution keys match the filter's field key (e.g. external_id:, gte:, lte:).
|
|
408
|
+
# Filters that don't recognise a key are returned unchanged.
|
|
409
|
+
def with(**substitutions)
|
|
410
|
+
clone.tap do |copy|
|
|
411
|
+
copy.instance_variable_set(
|
|
412
|
+
:@filters,
|
|
413
|
+
@filters.map { |f| f.respond_to?(:substitute) ? f.substitute(substitutions) : f }
|
|
414
|
+
)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Serialises to the Hash structure expected by searchConf arguments.
|
|
419
|
+
# Sorters: single object when one sorter (API convention), array when multiple.
|
|
420
|
+
def to_h
|
|
421
|
+
h = {}
|
|
422
|
+
h[:filters] = @filters.map(&:to_h) unless @filters.empty?
|
|
423
|
+
h[:sorters] = @sorters.length == 1 ? @sorters.first : @sorters unless @sorters.empty?
|
|
424
|
+
h[:query] = @search_query if @search_query
|
|
425
|
+
h
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Allow JSON serialisation directly (via json gem as_json protocol).
|
|
429
|
+
def as_json(_ = nil)
|
|
430
|
+
to_h
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|