ecoportal-api-graphql 1.3.10 → 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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/diff_pairing_engine.md +243 -0
  3. data/.ai-assistance/code/filter_contract_matrix.md +177 -0
  4. data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
  5. data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
  6. data/.ai-assistance/code/workflow-command-guide.md +28 -0
  7. data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
  8. data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
  9. data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
  10. data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
  11. data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
  12. data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
  13. data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
  14. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +87 -0
  15. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +108 -0
  16. data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
  17. data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
  18. data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
  19. data/.ai-assistance/projects/template-maintenance/README.md +14 -0
  20. data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
  21. data/.ai-assistance/standards-version.json +21 -20
  22. data/CHANGELOG.md +119 -0
  23. data/CLAUDE.md +10 -0
  24. data/docs/self-docs/ARCHITECTURE.md +88 -0
  25. data/docs/self-docs/CHANGES.jsonl +7 -0
  26. data/docs/self-docs/CONVENTIONS.md +74 -0
  27. data/docs/self-docs/INTEGRATIONS.md +63 -0
  28. data/docs/self-docs/OVERVIEW.md +51 -0
  29. data/docs/self-docs/STATUS.md +69 -0
  30. data/docs/self-docs/self-docs-index.json +39 -0
  31. data/docs/worklog.md +256 -0
  32. data/ecoportal-api-graphql.gemspec +1 -1
  33. data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
  34. data/lib/ecoportal/api/graphql/base/action.rb +43 -43
  35. data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
  36. data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
  37. data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
  38. data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
  39. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
  40. data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
  41. data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
  42. data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
  43. data/lib/ecoportal/api/graphql/base/register.rb +18 -18
  44. data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
  45. data/lib/ecoportal/api/graphql/builder.rb +17 -16
  46. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
  47. data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
  48. data/lib/ecoportal/api/graphql/concerns.rb +14 -14
  49. data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
  50. data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
  51. data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
  52. data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
  53. data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
  54. data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
  55. data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
  56. data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
  57. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
  58. data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
  59. data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
  60. data/lib/ecoportal/api/graphql/diff.rb +34 -0
  61. data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
  62. data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
  63. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
  64. data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
  65. data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
  66. data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
  67. data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
  68. data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
  69. data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
  70. data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
  71. data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
  72. data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
  73. data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
  74. data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
  75. data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
  76. data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
  77. data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
  78. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
  79. data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
  80. data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
  81. data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
  82. data/lib/ecoportal/api/graphql.rb +1 -0
  83. data/lib/ecoportal/api/graphql_version.rb +1 -1
  84. data/tests/dump_template_model.rb +90 -0
  85. data/tests/validate_queries.rb +31 -9
  86. metadata +40 -3
@@ -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
- # Filter: date_filter
97
- # ---------------------------------------------------------------------------
98
- class DateRange
99
- include Composable
100
-
101
- OPERATION = 'date_filter'.freeze
102
-
103
- def initialize(key, gte: nil, lte: nil, time_zone: 'UTC')
104
- @key = key.to_s
105
- @gte = gte
106
- @lte = lte
107
- @time_zone = time_zone
108
- end
109
-
110
- def to_h
111
- params = { key: @key, time_zone: @time_zone }
112
- params[:gte] = @gte if @gte
113
- params[:lte] = @lte if @lte
114
- { operation: OPERATION, params: params }
115
- end
116
-
117
- def substitute(subs)
118
- self.class.new(
119
- @key,
120
- gte: subs.fetch(:gte, @gte),
121
- lte: subs.fetch(:lte, @lte),
122
- time_zone: subs.fetch(:time_zone, @time_zone)
123
- )
124
- end
125
- end
126
-
127
- # ---------------------------------------------------------------------------
128
- # Filter: register_filter (org search only scope to specific registers)
129
- # ---------------------------------------------------------------------------
130
- class Register
131
- include Composable
132
-
133
- OPERATION = 'register_filter'.freeze
134
-
135
- def initialize(*ids)
136
- @ids = ids.flatten
137
- end
138
-
139
- def to_h
140
- { operation: OPERATION, params: { ids: @ids } }
141
- end
142
-
143
- def substitute(subs)
144
- self.class.new(*Array(subs.fetch(:register_ids, @ids)))
145
- end
146
- end
147
-
148
- # ---------------------------------------------------------------------------
149
- # Filter: and_filter (explicit AND group; top-level filters array is implicit AND)
150
- # ---------------------------------------------------------------------------
151
- class And
152
- include Composable
153
-
154
- OPERATION = 'and_filter'.freeze
155
-
156
- def initialize(*filters)
157
- @filters = filters.flatten
158
- end
159
-
160
- def to_h
161
- { operation: OPERATION, params: { filters: @filters.map(&:to_h) } }
162
- end
163
-
164
- def substitute(subs)
165
- self.class.new(*@filters.map { |f| f.respond_to?(:substitute) ? f.substitute(subs) : f })
166
- end
167
- end
168
-
169
- # ---------------------------------------------------------------------------
170
- # Filter: or_filter
171
- # ---------------------------------------------------------------------------
172
- class Or
173
- include Composable
174
-
175
- OPERATION = 'or_filter'.freeze
176
-
177
- def initialize(*filters)
178
- @filters = filters.flatten
179
- end
180
-
181
- def to_h
182
- { operation: OPERATION, params: { filters: @filters.map(&:to_h) } }
183
- end
184
-
185
- def substitute(subs)
186
- self.class.new(*@filters.map { |f| f.respond_to?(:substitute) ? f.substitute(subs) : f })
187
- end
188
- end
189
-
190
- # ---------------------------------------------------------------------------
191
- # RawFilter — escape hatch for operations not yet wrapped as SearchConf classes.
192
- # Supports & and | composition just like the typed filter classes.
193
- # Usage:
194
- # SearchConf.new.filter(SearchConf::RawFilter.new('has_any_filter', key: 'external_id'))
195
- # ---------------------------------------------------------------------------
196
- class RawFilter
197
- include Composable
198
-
199
- def initialize(operation, **params)
200
- @operation = operation
201
- @params = params
202
- end
203
-
204
- def to_h
205
- { 'operation' => @operation, 'params' => @params.transform_keys(&:to_s) }
206
- end
207
-
208
- def substitute(_subs)
209
- self
210
- end
211
- end
212
-
213
- # ---------------------------------------------------------------------------
214
- # Fluent DSL class methods — factory for filter expressions.
215
- #
216
- # SearchConf[:field] → FieldRef (then call .eq, .since, .matches, etc.)
217
- # SearchConf.in_register(id) → Register filter
218
- # SearchConf.raw(op, **params) → RawFilter
219
- #
220
- # Examples:
221
- # SearchConf[:external_id].eq('ABC')
222
- # SearchConf[:state].is(:active)
223
- # SearchConf[:updated_at].since('2025-01-01T00:00:00Z')
224
- # SearchConf[:state].is(:active) & SearchConf.in_register('REG')
225
- # SearchConf[:state].is(:active) | SearchConf[:state].is(:complete)
226
- # ---------------------------------------------------------------------------
227
- class << self
228
- def [](key)
229
- FieldRef.new(key.to_s)
230
- end
231
-
232
- def in_register(*ids)
233
- Register.new(*ids.flatten)
234
- end
235
-
236
- def raw(operation, **params)
237
- RawFilter.new(operation, **params)
238
- end
239
- end
240
-
241
- # FieldRef — intermediate object produced by SearchConf[:field].
242
- # Calling an operation method returns a concrete Composable filter object.
243
- class FieldRef
244
- def initialize(key)
245
- @key = key
246
- end
247
-
248
- def eq(value)
249
- Exact.new(@key, value)
250
- end
251
-
252
- def is(value)
253
- eq(value.to_s)
254
- end
255
-
256
- def one_of(*values)
257
- Or.new(*values.flatten.map { |v| eq(v.to_s) })
258
- end
259
-
260
- def not_eq(value)
261
- RawFilter.new('isnt_exact_filter', key: @key, value: value)
262
- end
263
-
264
- def matches(text)
265
- RawFilter.new('match_filter', key: @key, value: text)
266
- end
267
-
268
- def since(date, time_zone: 'UTC')
269
- DateRange.new(@key, gte: date, time_zone: time_zone)
270
- end
271
-
272
- def before(date, time_zone: 'UTC')
273
- DateRange.new(@key, lte: date, time_zone: time_zone)
274
- end
275
-
276
- def between(from, til, time_zone: 'UTC')
277
- DateRange.new(@key, gte: from, lte: til, time_zone: time_zone)
278
- end
279
-
280
- # Human date mode — Search[:updated_at].in_period(:last_month)
281
- # See search_filters.md for full list of mode values.
282
- def in_period(mode, time_zone: 'Pacific/Auckland', **extra)
283
- RawFilter.new('date_filter', key: @key, mode: mode.to_s, time_zone: time_zone, **extra)
284
- end
285
-
286
- def present
287
- RawFilter.new('has_any_filter', key: @key)
288
- end
289
-
290
- alias_method :exists, :present
291
-
292
- def blank
293
- RawFilter.new('doesnt_exist_filter', field: @key)
294
- end
295
-
296
- def true?
297
- RawFilter.new('boolean_filter', key: @key, value: true)
298
- end
299
-
300
- def false?
301
- RawFilter.new('boolean_filter', key: @key, value: false)
302
- end
303
-
304
- def between_nums(min, max)
305
- RawFilter.new('numeric_range_filter', key: @key, gte: min, lte: max)
306
- end
307
- end
308
-
309
- # ---------------------------------------------------------------------------
310
- # SearchConf builder
311
- # ---------------------------------------------------------------------------
312
- def initialize
313
- @filters = []
314
- @sorters = []
315
- @search_query = nil
316
- end
317
-
318
- # Add one or more filter objects. Multiple calls accumulate (implicit AND at top level).
319
- def filter(*filter_objs)
320
- @filters.concat(filter_objs.flatten)
321
- self
322
- end
323
-
324
- # Add a sorter. direction: :asc or :desc.
325
- # Multiple sorters are supported (applied in order).
326
- def sort(key, direction = :desc)
327
- @sorters << { key: key.to_s, direction: direction.to_s }
328
- self
329
- end
330
-
331
- # Set a full-text search string.
332
- def query(text)
333
- @search_query = text
334
- self
335
- end
336
-
337
- # Returns a new SearchConf with filter values substituted.
338
- # Substitution keys match the filter's field key (e.g. external_id:, gte:, lte:).
339
- # Filters that don't recognise a key are returned unchanged.
340
- def with(**substitutions)
341
- clone.tap do |copy|
342
- copy.instance_variable_set(
343
- :@filters,
344
- @filters.map { |f| f.respond_to?(:substitute) ? f.substitute(substitutions) : f }
345
- )
346
- end
347
- end
348
-
349
- # Serialises to the Hash structure expected by searchConf arguments.
350
- # Sorters: single object when one sorter (API convention), array when multiple.
351
- def to_h
352
- h = {}
353
- h[:filters] = @filters.map(&:to_h) unless @filters.empty?
354
- h[:sorters] = @sorters.length == 1 ? @sorters.first : @sorters unless @sorters.empty?
355
- h[:query] = @search_query if @search_query
356
- h
357
- end
358
-
359
- # Allow JSON serialisation directly (via json gem as_json protocol).
360
- def as_json(_ = nil)
361
- to_h
362
- end
363
- end
364
- end
365
- end
366
- end
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