elasticgraph-schema_definition 0.18.0.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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +7 -0
  4. data/elasticgraph-schema_definition.gemspec +26 -0
  5. data/lib/elastic_graph/schema_definition/api.rb +359 -0
  6. data/lib/elastic_graph/schema_definition/factory.rb +506 -0
  7. data/lib/elastic_graph/schema_definition/indexing/derived_fields/append_only_set.rb +79 -0
  8. data/lib/elastic_graph/schema_definition/indexing/derived_fields/field_initializer_support.rb +59 -0
  9. data/lib/elastic_graph/schema_definition/indexing/derived_fields/immutable_value.rb +99 -0
  10. data/lib/elastic_graph/schema_definition/indexing/derived_fields/min_or_max_value.rb +62 -0
  11. data/lib/elastic_graph/schema_definition/indexing/derived_indexed_type.rb +346 -0
  12. data/lib/elastic_graph/schema_definition/indexing/event_envelope.rb +74 -0
  13. data/lib/elastic_graph/schema_definition/indexing/field.rb +181 -0
  14. data/lib/elastic_graph/schema_definition/indexing/field_reference.rb +51 -0
  15. data/lib/elastic_graph/schema_definition/indexing/field_type/enum.rb +65 -0
  16. data/lib/elastic_graph/schema_definition/indexing/field_type/object.rb +113 -0
  17. data/lib/elastic_graph/schema_definition/indexing/field_type/scalar.rb +51 -0
  18. data/lib/elastic_graph/schema_definition/indexing/field_type/union.rb +70 -0
  19. data/lib/elastic_graph/schema_definition/indexing/index.rb +318 -0
  20. data/lib/elastic_graph/schema_definition/indexing/json_schema_field_metadata.rb +34 -0
  21. data/lib/elastic_graph/schema_definition/indexing/json_schema_with_metadata.rb +234 -0
  22. data/lib/elastic_graph/schema_definition/indexing/list_counts_mapping.rb +53 -0
  23. data/lib/elastic_graph/schema_definition/indexing/relationship_resolver.rb +96 -0
  24. data/lib/elastic_graph/schema_definition/indexing/rollover_config.rb +25 -0
  25. data/lib/elastic_graph/schema_definition/indexing/update_target_factory.rb +54 -0
  26. data/lib/elastic_graph/schema_definition/indexing/update_target_resolver.rb +195 -0
  27. data/lib/elastic_graph/schema_definition/json_schema_pruner.rb +61 -0
  28. data/lib/elastic_graph/schema_definition/mixins/can_be_graphql_only.rb +31 -0
  29. data/lib/elastic_graph/schema_definition/mixins/has_derived_graphql_type_customizations.rb +119 -0
  30. data/lib/elastic_graph/schema_definition/mixins/has_directives.rb +65 -0
  31. data/lib/elastic_graph/schema_definition/mixins/has_documentation.rb +74 -0
  32. data/lib/elastic_graph/schema_definition/mixins/has_indices.rb +281 -0
  33. data/lib/elastic_graph/schema_definition/mixins/has_readable_to_s_and_inspect.rb +46 -0
  34. data/lib/elastic_graph/schema_definition/mixins/has_subtypes.rb +116 -0
  35. data/lib/elastic_graph/schema_definition/mixins/has_type_info.rb +181 -0
  36. data/lib/elastic_graph/schema_definition/mixins/implements_interfaces.rb +122 -0
  37. data/lib/elastic_graph/schema_definition/mixins/supports_default_value.rb +47 -0
  38. data/lib/elastic_graph/schema_definition/mixins/supports_filtering_and_aggregation.rb +267 -0
  39. data/lib/elastic_graph/schema_definition/mixins/verifies_graphql_name.rb +38 -0
  40. data/lib/elastic_graph/schema_definition/rake_tasks.rb +190 -0
  41. data/lib/elastic_graph/schema_definition/results.rb +404 -0
  42. data/lib/elastic_graph/schema_definition/schema_artifact_manager.rb +482 -0
  43. data/lib/elastic_graph/schema_definition/schema_elements/argument.rb +56 -0
  44. data/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +1541 -0
  45. data/lib/elastic_graph/schema_definition/schema_elements/deprecated_element.rb +21 -0
  46. data/lib/elastic_graph/schema_definition/schema_elements/directive.rb +40 -0
  47. data/lib/elastic_graph/schema_definition/schema_elements/enum_type.rb +189 -0
  48. data/lib/elastic_graph/schema_definition/schema_elements/enum_value.rb +73 -0
  49. data/lib/elastic_graph/schema_definition/schema_elements/enum_value_namer.rb +89 -0
  50. data/lib/elastic_graph/schema_definition/schema_elements/enums_for_indexed_types.rb +82 -0
  51. data/lib/elastic_graph/schema_definition/schema_elements/field.rb +1085 -0
  52. data/lib/elastic_graph/schema_definition/schema_elements/field_path.rb +112 -0
  53. data/lib/elastic_graph/schema_definition/schema_elements/field_source.rb +16 -0
  54. data/lib/elastic_graph/schema_definition/schema_elements/graphql_sdl_enumerator.rb +113 -0
  55. data/lib/elastic_graph/schema_definition/schema_elements/input_field.rb +31 -0
  56. data/lib/elastic_graph/schema_definition/schema_elements/input_type.rb +60 -0
  57. data/lib/elastic_graph/schema_definition/schema_elements/interface_type.rb +72 -0
  58. data/lib/elastic_graph/schema_definition/schema_elements/list_counts_state.rb +40 -0
  59. data/lib/elastic_graph/schema_definition/schema_elements/object_type.rb +53 -0
  60. data/lib/elastic_graph/schema_definition/schema_elements/relationship.rb +218 -0
  61. data/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb +310 -0
  62. data/lib/elastic_graph/schema_definition/schema_elements/sort_order_enum_value.rb +36 -0
  63. data/lib/elastic_graph/schema_definition/schema_elements/sub_aggregation_path.rb +66 -0
  64. data/lib/elastic_graph/schema_definition/schema_elements/type_namer.rb +237 -0
  65. data/lib/elastic_graph/schema_definition/schema_elements/type_reference.rb +353 -0
  66. data/lib/elastic_graph/schema_definition/schema_elements/type_with_subfields.rb +579 -0
  67. data/lib/elastic_graph/schema_definition/schema_elements/union_type.rb +157 -0
  68. data/lib/elastic_graph/schema_definition/scripting/file_system_repository.rb +77 -0
  69. data/lib/elastic_graph/schema_definition/scripting/script.rb +48 -0
  70. data/lib/elastic_graph/schema_definition/scripting/scripts/field/as_day_of_week.painless +24 -0
  71. data/lib/elastic_graph/schema_definition/scripting/scripts/field/as_time_of_day.painless +41 -0
  72. data/lib/elastic_graph/schema_definition/scripting/scripts/filter/by_time_of_day.painless +22 -0
  73. data/lib/elastic_graph/schema_definition/scripting/scripts/update/index_data.painless +93 -0
  74. data/lib/elastic_graph/schema_definition/state.rb +212 -0
  75. data/lib/elastic_graph/schema_definition/test_support.rb +113 -0
  76. metadata +513 -0
@@ -0,0 +1,1085 @@
1
+ # Copyright 2024 Block, Inc.
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+ #
7
+ # frozen_string_literal: true
8
+
9
+ require "delegate"
10
+ require "elastic_graph/constants"
11
+ require "elastic_graph/schema_definition/indexing/field"
12
+ require "elastic_graph/schema_definition/indexing/field_reference"
13
+ require "elastic_graph/schema_definition/mixins/has_directives"
14
+ require "elastic_graph/schema_definition/mixins/has_documentation"
15
+ require "elastic_graph/schema_definition/mixins/has_readable_to_s_and_inspect"
16
+ require "elastic_graph/schema_definition/mixins/has_type_info"
17
+ require "elastic_graph/schema_definition/mixins/verifies_graphql_name"
18
+ require "elastic_graph/support/graphql_formatter"
19
+
20
+ module ElasticGraph
21
+ module SchemaDefinition
22
+ module SchemaElements
23
+ # Represents a [GraphQL field](https://spec.graphql.org/October2021/#sec-Language.Fields).
24
+ #
25
+ # @example Define a GraphQL field
26
+ # ElasticGraph.define_schema do |schema|
27
+ # schema.object_type "Widget" do |t|
28
+ # t.field "id", "ID" do |f|
29
+ # # `f` in this block is a Field object
30
+ # end
31
+ # end
32
+ # end
33
+ #
34
+ # @!attribute [r] name
35
+ # @return [String] name of the field
36
+ # @!attribute [r] schema_def_state
37
+ # @return [State] schema definition state
38
+ # @!attribute [r] graphql_only
39
+ # @return [Boolean] true if this field exists only in the GraphQL schema and is not indexed
40
+ #
41
+ # @!attribute [rw] original_type
42
+ # @private
43
+ # @!attribute [rw] parent_type
44
+ # @private
45
+ # @!attribute [rw] original_type_for_derived_types
46
+ # @private
47
+ # @!attribute [rw] accuracy_confidence
48
+ # @private
49
+ # @!attribute [rw] filter_customizations
50
+ # @private
51
+ # @!attribute [rw] grouped_by_customizations
52
+ # @private
53
+ # @!attribute [rw] sub_aggregations_customizations
54
+ # @private
55
+ # @!attribute [rw] aggregated_values_customizations
56
+ # @private
57
+ # @!attribute [rw] sort_order_enum_value_customizations
58
+ # @private
59
+ # @!attribute [rw] args
60
+ # @private
61
+ # @!attribute [rw] sortable
62
+ # @private
63
+ # @!attribute [rw] filterable
64
+ # @private
65
+ # @!attribute [rw] aggregatable
66
+ # @private
67
+ # @!attribute [rw] groupable
68
+ # @private
69
+ # @!attribute [rw] source
70
+ # @private
71
+ # @!attribute [rw] runtime_field_script
72
+ # @private
73
+ # @!attribute [rw] relationship
74
+ # @private
75
+ # @!attribute [rw] singular_name
76
+ # @private
77
+ # @!attribute [rw] runtime_metadata_graphql_field
78
+ # @private
79
+ # @!attribute [rw] non_nullable_in_json_schema
80
+ # @private
81
+ # @!attribute [rw] backing_indexing_field
82
+ # @private
83
+ # @!attribute [rw] as_input
84
+ # @private
85
+ # @!attribute [rw] legacy_grouping_schema
86
+ # @private
87
+ class Field < Struct.new(
88
+ :name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
89
+ :filter_customizations, :grouped_by_customizations, :sub_aggregations_customizations,
90
+ :aggregated_values_customizations, :sort_order_enum_value_customizations,
91
+ :args, :sortable, :filterable, :aggregatable, :groupable, :graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
92
+ :runtime_metadata_graphql_field, :non_nullable_in_json_schema, :backing_indexing_field, :as_input,
93
+ :legacy_grouping_schema
94
+ )
95
+ include Mixins::HasDocumentation
96
+ include Mixins::HasDirectives
97
+ include Mixins::HasTypeInfo
98
+ include Mixins::HasReadableToSAndInspect.new { |f| "#{f.parent_type.name}.#{f.name}: #{f.type}" }
99
+
100
+ # @private
101
+ def initialize(
102
+ name:, type:, parent_type:, schema_def_state:,
103
+ accuracy_confidence: :high, name_in_index: name,
104
+ runtime_metadata_graphql_field: SchemaArtifacts::RuntimeMetadata::GraphQLField::EMPTY,
105
+ type_for_derived_types: nil, graphql_only: nil, singular: nil,
106
+ sortable: nil, filterable: nil, aggregatable: nil, groupable: nil,
107
+ backing_indexing_field: nil, as_input: false, legacy_grouping_schema: false
108
+ )
109
+ type_ref = schema_def_state.type_ref(type)
110
+ super(
111
+ name: name,
112
+ original_type: type_ref,
113
+ parent_type: parent_type,
114
+ original_type_for_derived_types: type_for_derived_types ? schema_def_state.type_ref(type_for_derived_types) : type_ref,
115
+ schema_def_state: schema_def_state,
116
+ accuracy_confidence: accuracy_confidence,
117
+ filter_customizations: [],
118
+ grouped_by_customizations: [],
119
+ sub_aggregations_customizations: [],
120
+ aggregated_values_customizations: [],
121
+ sort_order_enum_value_customizations: [],
122
+ args: {},
123
+ sortable: sortable,
124
+ filterable: filterable,
125
+ aggregatable: aggregatable,
126
+ groupable: groupable,
127
+ graphql_only: graphql_only,
128
+ source: nil,
129
+ runtime_field_script: nil,
130
+ # Note: we named the keyword argument `singular` (with no `_name` suffix) for consistency with
131
+ # other schema definition APIs, which also use `singular:` instead of `singular_name:`. We include
132
+ # the `_name` suffix on the attribute for clarity.
133
+ singular_name: singular,
134
+ runtime_metadata_graphql_field: runtime_metadata_graphql_field.with(name_in_index: name_in_index),
135
+ non_nullable_in_json_schema: false,
136
+ backing_indexing_field: backing_indexing_field,
137
+ as_input: as_input,
138
+ legacy_grouping_schema: legacy_grouping_schema
139
+ )
140
+
141
+ if name != name_in_index && name_in_index&.include?(".") && !graphql_only
142
+ raise SchemaError, "#{self} has an invalid `name_in_index`: #{name_in_index.inspect}. Only `graphql_only: true` fields can have a `name_in_index` that references a child field."
143
+ end
144
+
145
+ schema_def_state.register_user_defined_field(self)
146
+ yield self if block_given?
147
+ end
148
+
149
+ # @private
150
+ @@initialize_param_names = instance_method(:initialize).parameters.map(&:last).to_set
151
+
152
+ # must come after we capture the initialize params.
153
+ prepend Mixins::VerifiesGraphQLName
154
+
155
+ # @return [TypeReference] the type of this field
156
+ def type
157
+ # Here we lazily convert the `original_type` to an input type as needed. This must be lazy because
158
+ # the logic of `as_input` depends on detecting whether the type is an enum type, which it may not
159
+ # be able to do right away--we assume not if we can't tell, and retry every time this method is called.
160
+ original_type.to_final_form(as_input: as_input)
161
+ end
162
+
163
+ # @return [TypeReference] the type of the corresponding field on derived types (usually this is the same as {#type}).
164
+ #
165
+ # @private
166
+ def type_for_derived_types
167
+ original_type_for_derived_types.to_final_form(as_input: as_input)
168
+ end
169
+
170
+ # @note For each field defined in your schema that is filterable, a corresponding filtering field will be created on the
171
+ # `*FilterInput` type derived from the parent object type.
172
+ #
173
+ # Registers a customization callback that will be applied to the corresponding filtering field that will be generated for this
174
+ # field.
175
+ #
176
+ # @yield [Field] derived filtering field
177
+ # @return [void]
178
+ # @see #customize_aggregated_values_field
179
+ # @see #customize_grouped_by_field
180
+ # @see #customize_sort_order_enum_values
181
+ # @see #customize_sub_aggregations_field
182
+ # @see #on_each_generated_schema_element
183
+ #
184
+ # @example Mark `CampaignFilterInput.organizationId` with `@deprecated`
185
+ # ElasticGraph.define_schema do |schema|
186
+ # schema.object_type "Campaign" do |t|
187
+ # t.field "id", "ID"
188
+ #
189
+ # t.field "organizationId", "ID" do |f|
190
+ # f.customize_filter_field do |ff|
191
+ # ff.directive "deprecated"
192
+ # end
193
+ # end
194
+ #
195
+ # t.index "campaigns"
196
+ # end
197
+ # end
198
+ def customize_filter_field(&customization_block)
199
+ filter_customizations << customization_block
200
+ end
201
+
202
+ # @note For each field defined in your schema that is aggregatable, a corresponding `aggregatedValues` field will be created on the
203
+ # `*AggregatedValues` type derived from the parent object type.
204
+ #
205
+ # Registers a customization callback that will be applied to the corresponding `aggregatedValues` field that will be generated for
206
+ # this field.
207
+ #
208
+ # @yield [Field] derived aggregated values field
209
+ # @return [void]
210
+ # @see #customize_filter_field
211
+ # @see #customize_grouped_by_field
212
+ # @see #customize_sort_order_enum_values
213
+ # @see #customize_sub_aggregations_field
214
+ # @see #on_each_generated_schema_element
215
+ #
216
+ # @example Mark `CampaignAggregatedValues.adImpressions` with `@deprecated`
217
+ # ElasticGraph.define_schema do |schema|
218
+ # schema.object_type "Campaign" do |t|
219
+ # t.field "id", "ID"
220
+ #
221
+ # t.field "adImpressions", "Int" do |f|
222
+ # f.customize_aggregated_values_field do |avf|
223
+ # avf.directive "deprecated"
224
+ # end
225
+ # end
226
+ #
227
+ # t.index "campaigns"
228
+ # end
229
+ # end
230
+ def customize_aggregated_values_field(&customization_block)
231
+ aggregated_values_customizations << customization_block
232
+ end
233
+
234
+ # @note For each field defined in your schema that is groupable, a corresponding `groupedBy` field will be created on the
235
+ # `*AggregationGroupedBy` type derived from the parent object type.
236
+ #
237
+ # Registers a customization callback that will be applied to the corresponding `groupedBy` field that will be generated for this
238
+ # field.
239
+ #
240
+ # @yield [Field] derived grouped by field
241
+ # @return [void]
242
+ # @see #customize_aggregated_values_field
243
+ # @see #customize_filter_field
244
+ # @see #customize_sort_order_enum_values
245
+ # @see #customize_sub_aggregations_field
246
+ # @see #on_each_generated_schema_element
247
+ #
248
+ # @example Mark `CampaignGroupedBy.organizationId` with `@deprecated`
249
+ # ElasticGraph.define_schema do |schema|
250
+ # schema.object_type "Campaign" do |t|
251
+ # t.field "id", "ID"
252
+ #
253
+ # t.field "organizationId", "ID" do |f|
254
+ # f.customize_grouped_by_field do |gbf|
255
+ # gbf.directive "deprecated"
256
+ # end
257
+ # end
258
+ #
259
+ # t.index "campaigns"
260
+ # end
261
+ # end
262
+ def customize_grouped_by_field(&customization_block)
263
+ grouped_by_customizations << customization_block
264
+ end
265
+
266
+ # @note For each field defined in your schema that is sub-aggregatable (e.g. list fields indexed using the `nested` mapping type),
267
+ # a corresponding field will be created on the `*AggregationSubAggregations` type derived from the parent object type.
268
+ #
269
+ # Registers a customization callback that will be applied to the corresponding `subAggregations` field that will be generated for
270
+ # this field.
271
+ #
272
+ # @yield [Field] derived sub-aggregations field
273
+ # @return [void]
274
+ # @see #customize_aggregated_values_field
275
+ # @see #customize_filter_field
276
+ # @see #customize_grouped_by_field
277
+ # @see #customize_sort_order_enum_values
278
+ # @see #on_each_generated_schema_element
279
+ #
280
+ # @example Mark `TransactionAggregationSubAggregations.fees` with `@deprecated`
281
+ # ElasticGraph.define_schema do |schema|
282
+ # schema.object_type "Transaction" do |t|
283
+ # t.field "id", "ID"
284
+ #
285
+ # t.field "fees", "[Money!]!" do |f|
286
+ # f.mapping type: "nested"
287
+ #
288
+ # f.customize_sub_aggregations_field do |saf|
289
+ # # Adds a `@deprecated` directive to the `PaymentAggregationSubAggregations.fees`
290
+ # # field without also adding it to the `Payment.fees` field.
291
+ # saf.directive "deprecated"
292
+ # end
293
+ # end
294
+ #
295
+ # t.index "transactions"
296
+ # end
297
+ #
298
+ # schema.object_type "Money" do |t|
299
+ # t.field "amount", "Int"
300
+ # t.field "currency", "String"
301
+ # end
302
+ # end
303
+ def customize_sub_aggregations_field(&customization_block)
304
+ sub_aggregations_customizations << customization_block
305
+ end
306
+
307
+ # @note for each sortable field, enum values will be generated on the derived sort order enum type allowing you to
308
+ # sort by the field `ASC` or `DESC`.
309
+ #
310
+ # Registers a customization callback that will be applied to the corresponding enum values that will be generated for this field
311
+ # on the derived `SortOrder` enum type.
312
+ #
313
+ # @yield [SortOrderEnumValue] derived sort order enum value
314
+ # @return [void]
315
+ # @see #customize_aggregated_values_field
316
+ # @see #customize_filter_field
317
+ # @see #customize_grouped_by_field
318
+ # @see #customize_sub_aggregations_field
319
+ # @see #on_each_generated_schema_element
320
+ #
321
+ # @example Mark `CampaignSortOrder.organizationId_(ASC|DESC)` with `@deprecated`
322
+ # ElasticGraph.define_schema do |schema|
323
+ # schema.object_type "Campaign" do |t|
324
+ # t.field "id", "ID"
325
+ #
326
+ # t.field "organizationId", "ID" do |f|
327
+ # f.customize_sort_order_enum_values do |soev|
328
+ # soev.directive "deprecated"
329
+ # end
330
+ # end
331
+ #
332
+ # t.index "campaigns"
333
+ # end
334
+ # end
335
+ def customize_sort_order_enum_values(&customization_block)
336
+ sort_order_enum_value_customizations << customization_block
337
+ end
338
+
339
+ # When you define a {Field} on an {ObjectType} or {InterfaceType}, ElasticGraph generates up to 6 different GraphQL schema elements
340
+ # for it:
341
+ #
342
+ # * A {Field} is generated on the parent {ObjectType} or {InterfaceType} (that is, this field itself). This is used by clients to
343
+ # ask for values for the field in a response.
344
+ # * A {Field} may be generated on the `*FilterInput` {InputType} derived from the parent {ObjectType} or {InterfaceType}. This is
345
+ # used by clients to specify how the query should filter.
346
+ # * A {Field} may be generated on the `*AggregationGroupedBy` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
347
+ # This is used by clients to specify how aggregations should be grouped.
348
+ # * A {Field} may be generated on the `*AggregatedValues` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
349
+ # This is used by clients to apply aggregation functions (e.g. `sum`, `max`, `min`, etc) to a set of field values for a group.
350
+ # * A {Field} may be generated on the `*AggregationSubAggregations` {ObjectType} derived from the parent {ObjectType} or
351
+ # {InterfaceType}. This is used by clients to perform sub-aggregations on list fields indexed using the `nested` mapping type.
352
+ # * Multiple {EnumValue}s (both `*_ASC` and `*_DESC`) are generated on the `*SortOrder` {EnumType} derived from the parent indexed
353
+ # {ObjectType}. This is used by clients to sort by a field.
354
+ #
355
+ # This method registers a customization callback which is applied to every element that is generated for this field.
356
+ #
357
+ # @yield [Field, EnumValue] the schema element
358
+ # @return [void]
359
+ # @see #customize_aggregated_values_field
360
+ # @see #customize_filter_field
361
+ # @see #customize_grouped_by_field
362
+ # @see #customize_sort_order_enum_values
363
+ # @see #customize_sub_aggregations_field
364
+ #
365
+ # @example
366
+ # ElasticGraph.define_schema do |schema|
367
+ # schema.object_type "Transaction" do |t|
368
+ # t.field "id", "ID"
369
+ #
370
+ # t.field "amount", "Int" do |f|
371
+ # f.on_each_generated_schema_element do |element|
372
+ # # Adds a `@deprecated` directive to every GraphQL schema element generated for `amount`:
373
+ # #
374
+ # # - The `Transaction.amount` field.
375
+ # # - The `TransactionFilterInput.amount` field.
376
+ # # - The `TransactionAggregationGroupedBy.amount` field.
377
+ # # - The `TransactionAggregatedValues.amount` field.
378
+ # # - The `TransactionSortOrder.amount_ASC` and`TransactionSortOrder.amount_DESC` enum values.
379
+ # element.directive "deprecated"
380
+ # end
381
+ # end
382
+ #
383
+ # t.index "transactions"
384
+ # end
385
+ # end
386
+ def on_each_generated_schema_element(&customization_block)
387
+ customization_block.call(self)
388
+ customize_filter_field(&customization_block)
389
+ customize_aggregated_values_field(&customization_block)
390
+ customize_grouped_by_field(&customization_block)
391
+ customize_sub_aggregations_field(&customization_block)
392
+ customize_sort_order_enum_values(&customization_block)
393
+ end
394
+
395
+ # (see Mixins::HasTypeInfo#json_schema)
396
+ def json_schema(nullable: nil, **options)
397
+ if options.key?(:type)
398
+ raise SchemaError, "Cannot override JSON schema type of field `#{name}` with `#{options.fetch(:type)}`"
399
+ end
400
+
401
+ case nullable
402
+ when true
403
+ raise SchemaError, "`nullable: true` is not allowed on a field--just declare the GraphQL field as being nullable (no `!` suffix) instead."
404
+ when false
405
+ self.non_nullable_in_json_schema = true
406
+ end
407
+
408
+ super(**options)
409
+ end
410
+
411
+ # Configures ElasticGraph to source a field’s value from a related object. This can be used to denormalize data at ingestion time to
412
+ # support filtering, grouping, sorting, or aggregating data on a field from a related object.
413
+ #
414
+ # @param relationship [String] name of a relationship defined with {TypeWithSubfields#relates_to_one} using an inbound foreign key
415
+ # which contains the the field you wish to source values from
416
+ # @param field_path [String] dot-separated path to the field on the related type containing values that should be copied to this
417
+ # field
418
+ # @return [void]
419
+ #
420
+ # @example Source `City.currency` from `Country.currency`
421
+ # ElasticGraph.define_schema do |schema|
422
+ # schema.object_type "Country" do |t|
423
+ # t.field "id", "ID"
424
+ # t.field "name", "String"
425
+ # t.field "currency", "String"
426
+ # t.relates_to_one "capitalCity", "City", via: "capitalCityId", dir: :out
427
+ # t.index "countries"
428
+ # end
429
+ #
430
+ # schema.object_type "City" do |t|
431
+ # t.field "id", "ID"
432
+ # t.field "name", "String"
433
+ # t.relates_to_one "capitalOf", "Country", via: "capitalCityId", dir: :in
434
+ #
435
+ # t.field "currency", "String" do |f|
436
+ # f.sourced_from "capitalOf", "currency"
437
+ # end
438
+ #
439
+ # t.index "cities"
440
+ # end
441
+ # end
442
+ def sourced_from(relationship, field_path)
443
+ self.source = schema_def_state.factory.new_field_source(
444
+ relationship_name: relationship,
445
+ field_path: field_path
446
+ )
447
+ end
448
+
449
+ # @private
450
+ def runtime_script(script)
451
+ self.runtime_field_script = script
452
+ end
453
+
454
+ # Registers an old name that this field used to have in a prior version of the schema.
455
+ #
456
+ # @note In situations where this API applies, ElasticGraph will give you an error message indicating that you need to use this API
457
+ # or {TypeWithSubfields#deleted_field}. Likewise, when ElasticGraph no longer needs to know about this, it'll give you a warning
458
+ # indicating the call to this method can be removed.
459
+ #
460
+ # @param old_name [String] old name this field used to have in a prior version of the schema
461
+ # @return [void]
462
+ #
463
+ # @example Indicate that `Widget.description` used to be called `Widget.notes`.
464
+ # ElasticGraph.define_schema do |schema|
465
+ # schema.object_type "Widget" do |t|
466
+ # t.field "description", "String" do |f|
467
+ # f.renamed_from "notes"
468
+ # end
469
+ # end
470
+ # end
471
+ def renamed_from(old_name)
472
+ schema_def_state.register_renamed_field(
473
+ parent_type.name,
474
+ from: old_name,
475
+ to: name,
476
+ defined_at: caller_locations(1, 1).first, # : ::Thread::Backtrace::Location
477
+ defined_via: %(field.renamed_from "#{old_name}")
478
+ )
479
+ end
480
+
481
+ # @return [String] the name of this field in the datastore index
482
+ def name_in_index
483
+ runtime_metadata_graphql_field.name_in_index
484
+ end
485
+
486
+ # @private
487
+ def to_sdl(type_structure_only: false, default_value_sdl: nil, &arg_selector)
488
+ if type_structure_only
489
+ "#{name}#{args_sdl(joiner: ", ", &arg_selector)}: #{type.name}"
490
+ else
491
+ args_sdl = args_sdl(joiner: "\n ", after_opening_paren: "\n ", &arg_selector)
492
+ "#{formatted_documentation}#{name}#{args_sdl}: #{type.name}#{default_value_sdl} #{directives_sdl}".strip
493
+ end
494
+ end
495
+
496
+ # Indicates if this field is sortable. Sortable fields will have corresponding `_ASC` and `_DESC` values generated in the
497
+ # sort order {EnumType} of the parent indexed type.
498
+ #
499
+ # By default, the sortability is inferred by the field type and mapping. For example, list fields are not sortable,
500
+ # and fields mapped as `text` are not sortable either. Fields are sortable in most other cases.
501
+ #
502
+ # The `sortable: true` option can be used to force a field to be sortable.
503
+ #
504
+ # @return [Boolean] true if this field is sortable
505
+ def sortable?
506
+ return sortable unless sortable.nil?
507
+
508
+ # List fields are not sortable by default. We'd need to provide the datastore a sort mode option:
509
+ # https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_sort_mode_option
510
+ return false if type.list?
511
+
512
+ # Boolean fields are not sortable by default.
513
+ # - Boolean: sorting all falses before all trues (or whatever) is not generally interesting.
514
+ return false if type.unwrap_non_null.boolean?
515
+
516
+ # Elasticsearch/OpenSearch do not support sorting text fields:
517
+ # > Text fields are not used for sorting...
518
+ # (from https://www.elastic.co/guide/en/elasticsearch/reference/current/the datastore.html#text)
519
+ return false if text?
520
+
521
+ # If the type uses custom mapping type we don't know how if the datastore can sort by it, so we assume it's not sortable.
522
+ return false if type.as_object_type&.has_custom_mapping_type?
523
+
524
+ # Default every other field to being sortable.
525
+ true
526
+ end
527
+
528
+ # Indicates if this field is filterable. Filterable fields will be available in the GraphQL schema under the `filter` argument.
529
+ #
530
+ # Most fields are filterable, except when:
531
+ #
532
+ # - It's a relation. Relation fields require us to load the related data from another index and can't be filtered on.
533
+ # - The field is an object type that isn't itself filterable (e.g. due to having no filterable fields or whatever).
534
+ # - Explicitly disabled with `filterable: false`.
535
+ #
536
+ # @return [Boolean]
537
+ def filterable?
538
+ # Object types that use custom index mappings (as `GeoLocation` does) aren't filterable
539
+ # by default since we can't guess what datastore filtering capabilities they have. We've implemented
540
+ # filtering support for `GeoLocation` fields, though, so we need to explicitly make it fliterable here.
541
+ # TODO: clean this up using an interface instead of checking for `GeoLocation`.
542
+ return true if type.fully_unwrapped.name == "GeoLocation"
543
+
544
+ return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:filterable?)
545
+ return true if filterable.nil?
546
+ filterable
547
+ end
548
+
549
+ # Indicates if this field is groupable. Groupable fields will be available under `groupedBy` for an aggregations query.
550
+ #
551
+ # Groupability is inferred based on the field type and mapping type, or you can use the `groupable: true` option to force it.
552
+ #
553
+ # @return [Boolean]
554
+ def groupable?
555
+ # If the groupability of the field was specified explicitly when the field was defined, use the specified value.
556
+ return groupable unless groupable.nil?
557
+
558
+ # We don't want the `id` field of an indexed type to be available to group by, because it's the unique primary key
559
+ # and the groupings would each contain one document. It's simpler and more efficient to just query the raw documents
560
+ # instead.
561
+ return false if parent_type.indexed? && name == "id"
562
+
563
+ return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:groupable?)
564
+
565
+ # We don't support grouping an entire list of values, but we do support grouping on individual values in a list.
566
+ # However, we only do so when a `singular_name` has been provided (so that we know what to call the grouped_by field).
567
+ # The semantics are a little odd (since one document can be duplicated in multiple grouping buckets) so we're ok
568
+ # with not offering it by default--the user has to opt-in by telling us what to call the field in its singular form.
569
+ return list_field_groupable_by_single_values? if type.list? && type.fully_unwrapped.leaf?
570
+
571
+ # Nested fields will be supported through specific nested aggregation support, and do not
572
+ # work as expected when grouping on the root document type.
573
+ return false if nested?
574
+
575
+ # Text fields cannot be efficiently grouped on, so make them non-groupable by default.
576
+ return false if text?
577
+
578
+ # In all other cases, default to being groupable.
579
+ true
580
+ end
581
+
582
+ # Indicates if this field is aggregatable. Aggregatable fields will be available under `aggregatedValues` for an aggregations query.
583
+ #
584
+ # Aggregatability is inferred based on the field type and mapping type, or you can use the `aggregatable: true` option to force it.
585
+ #
586
+ # @return [Boolean]
587
+ def aggregatable?
588
+ return aggregatable unless aggregatable.nil?
589
+ return false if relationship
590
+
591
+ # We don't yet support aggregating over subfields of a `nested` field.
592
+ # TODO: add support for aggregating over subfields of `nested` fields.
593
+ return false if nested?
594
+
595
+ # Text fields are not efficiently aggregatable (and you'll often get errors from the datastore if you attempt to aggregate them).
596
+ return false if text?
597
+
598
+ type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:aggregatable?) || index_leaf?
599
+ end
600
+
601
+ # Indicates if this field can be used as the basis for a sub-aggregation. Sub-aggregatable fields will be available under
602
+ # `subAggregations` for an aggregations query.
603
+ #
604
+ # Only nested fields, and object fields which have nested fields, can be sub-aggregated.
605
+ #
606
+ # @return [Boolean]
607
+ def sub_aggregatable?
608
+ return false if relationship
609
+
610
+ nested? || type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:sub_aggregatable?)
611
+ end
612
+
613
+ # Defines an argument on the field.
614
+ #
615
+ # @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
616
+ # this API, and it has no way to interpret arbitrary arguments defined on a field. However, it can be useful for extensions that
617
+ # extend the {ElasticGraph::GraphQL} query engine. For example, {ElasticGraph::Apollo} uses this API to satisfy the [Apollo
618
+ # federation subgraph spec](https://www.apollographql.com/docs/federation/federation-spec/).
619
+ #
620
+ # @param name [String] name of the argument
621
+ # @param value_type [String] type of the argument in GraphQL SDL syntax
622
+ # @yield [Argument] for further customization
623
+ #
624
+ # @example Define an argument on a field
625
+ # ElasticGraph.define_schema do |schema|
626
+ # schema.object_type "Product" do |t|
627
+ # t.field "name", "String" do |f|
628
+ # f.argument "language", "String"
629
+ # end
630
+ # end
631
+ # end
632
+ def argument(name, value_type, &block)
633
+ args[name] = schema_def_state.factory.new_argument(
634
+ self,
635
+ name,
636
+ schema_def_state.type_ref(value_type),
637
+ &block
638
+ )
639
+ end
640
+
641
+ # The index mapping type in effect for this field. This could come from either the field definition or from the type definition.
642
+ #
643
+ # @return [String]
644
+ def mapping_type
645
+ backing_indexing_field&.mapping_type || (resolve_mapping || {})["type"]
646
+ end
647
+
648
+ # @private
649
+ def list_field_groupable_by_single_values?
650
+ (type.list? || backing_indexing_field&.type&.list?) && !singular_name.nil?
651
+ end
652
+
653
+ # @private
654
+ def define_aggregated_values_field(parent_type)
655
+ return unless aggregatable?
656
+
657
+ unwrapped_type_for_derived_types = type_for_derived_types.fully_unwrapped
658
+ aggregated_values_type =
659
+ if index_leaf?
660
+ unwrapped_type_for_derived_types.resolved.aggregated_values_type
661
+ else
662
+ unwrapped_type_for_derived_types.as_aggregated_values
663
+ end
664
+
665
+ parent_type.field name, aggregated_values_type.name, name_in_index: name_in_index, graphql_only: true do |f|
666
+ f.documentation derived_documentation("Computed aggregate values for the `#{name}` field")
667
+ aggregated_values_customizations.each { |block| block.call(f) }
668
+ end
669
+ end
670
+
671
+ # @private
672
+ def define_grouped_by_field(parent_type)
673
+ return unless (field_name = grouped_by_field_name)
674
+
675
+ parent_type.field field_name, grouped_by_field_type_name, name_in_index: name_in_index, graphql_only: true do |f|
676
+ add_grouped_by_field_documentation(f)
677
+
678
+ define_legacy_timestamp_grouping_arguments_if_needed(f) if legacy_grouping_schema
679
+
680
+ grouped_by_customizations.each { |block| block.call(f) }
681
+ end
682
+ end
683
+
684
+ # @private
685
+ def grouped_by_field_type_name
686
+ unwrapped_type = type_for_derived_types.fully_unwrapped
687
+ if unwrapped_type.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
688
+ unwrapped_type.with_reverted_override.as_grouped_by.name
689
+ elsif unwrapped_type.leaf?
690
+ unwrapped_type.name
691
+ else
692
+ unwrapped_type.as_grouped_by.name
693
+ end
694
+ end
695
+
696
+ # @private
697
+ def add_grouped_by_field_documentation(field)
698
+ text = if list_field_groupable_by_single_values?
699
+ derived_documentation(
700
+ "The individual value from `#{name}` for this group",
701
+ list_field_grouped_by_doc_note("`#{name}`")
702
+ )
703
+ elsif type.list? && type.fully_unwrapped.object?
704
+ derived_documentation(
705
+ "The `#{name}` field value for this group",
706
+ list_field_grouped_by_doc_note("the selected subfields of `#{name}`")
707
+ )
708
+ elsif type_for_derived_types.fully_unwrapped.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
709
+ derived_documentation("Offers the different grouping options for the `#{name}` value within this group")
710
+ else
711
+ derived_documentation("The `#{name}` field value for this group")
712
+ end
713
+
714
+ field.documentation text
715
+ end
716
+
717
+ # @private
718
+ def grouped_by_field_name
719
+ return nil unless groupable?
720
+ list_field_groupable_by_single_values? ? singular_name : name
721
+ end
722
+
723
+ # @private
724
+ def define_sub_aggregations_field(parent_type:, type:)
725
+ parent_type.field name, type, name_in_index: name_in_index, graphql_only: true do |f|
726
+ f.documentation derived_documentation("Used to perform a sub-aggregation of `#{name}`")
727
+ sub_aggregations_customizations.each { |c| c.call(f) }
728
+
729
+ yield f if block_given?
730
+ end
731
+ end
732
+
733
+ # @private
734
+ def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list?)
735
+ type_prefix = text? ? "Text" : type_for_derived_types.fully_unwrapped.name
736
+ filter_type = schema_def_state
737
+ .type_ref(type_prefix)
738
+ .as_static_derived_type(filter_field_category(for_single_value))
739
+ .name
740
+
741
+ params = to_h
742
+ .slice(*@@initialize_param_names)
743
+ .merge(type: filter_type, parent_type: parent_type, name_in_index: name_in_index, type_for_derived_types: nil)
744
+
745
+ schema_def_state.factory.new_field(**params).tap do |f|
746
+ f.documentation derived_documentation(
747
+ "Used to filter on the `#{name}` field",
748
+ "Will be ignored if `null` or an empty object is passed"
749
+ )
750
+
751
+ filter_customizations.each { |c| c.call(f) }
752
+ end
753
+ end
754
+
755
+ # @private
756
+ def define_relay_pagination_arguments!
757
+ argument schema_def_state.schema_elements.first.to_sym, "Int" do |a|
758
+ a.documentation <<~EOS
759
+ Used in conjunction with the `after` argument to forward-paginate through the `#{name}`.
760
+ When provided, limits the number of returned results to the first `n` after the provided
761
+ `after` cursor (or from the start of the `#{name}`, if no `after` cursor is provided).
762
+
763
+ See the [Relay GraphQL Cursor Connections
764
+ Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
765
+ EOS
766
+ end
767
+
768
+ argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a|
769
+ a.documentation <<~EOS
770
+ Used to forward-paginate through the `#{name}`. When provided, the next page after the
771
+ provided cursor will be returned.
772
+
773
+ See the [Relay GraphQL Cursor Connections
774
+ Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
775
+ EOS
776
+ end
777
+
778
+ argument schema_def_state.schema_elements.last.to_sym, "Int" do |a|
779
+ a.documentation <<~EOS
780
+ Used in conjunction with the `before` argument to backward-paginate through the `#{name}`.
781
+ When provided, limits the number of returned results to the last `n` before the provided
782
+ `before` cursor (or from the end of the `#{name}`, if no `before` cursor is provided).
783
+
784
+ See the [Relay GraphQL Cursor Connections
785
+ Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
786
+ EOS
787
+ end
788
+
789
+ argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a|
790
+ a.documentation <<~EOS
791
+ Used to backward-paginate through the `#{name}`. When provided, the previous page before the
792
+ provided cursor will be returned.
793
+
794
+ See the [Relay GraphQL Cursor Connections
795
+ Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
796
+ EOS
797
+ end
798
+ end
799
+
800
+ # Converts this field to an `Indexing::FieldReference`, which contains all the attributes involved
801
+ # in building an `Indexing::Field`. Notably, we cannot actually convert to an `Indexing::Field` at
802
+ # the point this method is called, because the referenced field type may not have been defined
803
+ # yet. We don't need an actual `Indexing::Field` until the very end of the schema definition process,
804
+ # when we are dumping the artifacts. However, we need this at field definition time so that we
805
+ # can correctly detect duplicate indexing field issues when a field is defined. (This is used
806
+ # in `TypeWithSubfields#field`).
807
+ #
808
+ # @private
809
+ def to_indexing_field_reference
810
+ return nil if graphql_only
811
+
812
+ Indexing::FieldReference.new(
813
+ name: name,
814
+ name_in_index: name_in_index,
815
+ type: non_nullable_in_json_schema ? type.wrap_non_null : type,
816
+ mapping_options: mapping_options,
817
+ json_schema_options: json_schema_options,
818
+ accuracy_confidence: accuracy_confidence,
819
+ source: source,
820
+ runtime_field_script: runtime_field_script
821
+ )
822
+ end
823
+
824
+ # Converts this field to its `IndexingField` form.
825
+ #
826
+ # @private
827
+ def to_indexing_field
828
+ to_indexing_field_reference&.resolve
829
+ end
830
+
831
+ # @private
832
+ def resolve_mapping
833
+ to_indexing_field&.mapping
834
+ end
835
+
836
+ # Returns the string paths to the list fields that we need to index counts for.
837
+ # We do this to support the ability to filter on the size of a list.
838
+ #
839
+ # @private
840
+ def paths_to_lists_for_count_indexing(has_list_ancestor: false)
841
+ self_path = (has_list_ancestor || type.list?) ? [name_in_index] : []
842
+
843
+ nested_paths =
844
+ # Nested fields get indexed as separate hidden documents:
845
+ # https://www.elastic.co/guide/en/elasticsearch/reference/8.8/nested.html
846
+ #
847
+ # Given that, the counts of any `nested` list subfields will go in a `__counts` field on the
848
+ # separate hidden document.
849
+ if !nested? && (object_type = type.fully_unwrapped.as_object_type)
850
+ object_type.indexing_fields_by_name_in_index.values.flat_map do |sub_field|
851
+ sub_field.paths_to_lists_for_count_indexing(has_list_ancestor: has_list_ancestor || type.list?).map do |sub_path|
852
+ "#{name_in_index}#{LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR}#{sub_path}"
853
+ end
854
+ end
855
+ else
856
+ []
857
+ end
858
+
859
+ self_path + nested_paths
860
+ end
861
+
862
+ # Indicates if this field is a leaf value in the index. Note that GraphQL leaf values
863
+ # are always leaf values in the index but the inverse is not always true. For example,
864
+ # a `GeoLocation` field is not a leaf in GraphQL (because `GeoLocation` is an object
865
+ # type with subfields) but in the index we use a single `geo_point` mapping type, which
866
+ # is a single unit, so we consider it an index leaf.
867
+ #
868
+ # @private
869
+ def index_leaf?
870
+ type_for_derived_types.fully_unwrapped.leaf? || DATASTORE_PROPERTYLESS_OBJECT_TYPES.include?(mapping_type)
871
+ end
872
+
873
+ # @private
874
+ ACCURACY_SCORES = {
875
+ # :high is assigned to `Field`s that are generated directly from GraphQL fields or :extra_fields.
876
+ # For these, we know everything available to us in the schema about them.
877
+ high: 3,
878
+
879
+ # :medium is assigned to `Field`s that are inferred from the id fields required by a relation.
880
+ # We make logical guesses about the `indexing_field_type` but if the field is also manually defined,
881
+ # it could be slightly different (e.g. additional json schema validations), so we have medium
882
+ # confidence of these.
883
+ medium: 2,
884
+
885
+ # :low is assigned to the ElastcField inferred for the foreign key of an inbound relation. The
886
+ # nullability/cardinality of the foreign key field cannot be known from the relation metadata,
887
+ # so we just guess what seems safest (`[:nullable]`). If the field is defined another way
888
+ # we should prefer it, so we give these fields :low confidence.
889
+ low: 1
890
+ }
891
+
892
+ # Given two fields, picks the one that is most accurate. If they have the same accuracy
893
+ # confidence, yields to a block to force it to deal with the discrepancy, unless the fields
894
+ # are exactly equal (in which case we can return either).
895
+ #
896
+ # @private
897
+ def self.pick_most_accurate_from(field1, field2, to_comparable: ->(it) { it })
898
+ return field1 if to_comparable.call(field1) == to_comparable.call(field2)
899
+ yield if field1.accuracy_confidence == field2.accuracy_confidence
900
+ # Array#max_by can return nil (when called on an empty array), but our steep type is non-nil.
901
+ # Since it's not smart enough to realize the non-empty-array-usage of `max_by` won't return nil,
902
+ # we have to cast it to untyped here.
903
+ _ = [field1, field2].max_by { |f| ACCURACY_SCORES.fetch(f.accuracy_confidence) }
904
+ end
905
+
906
+ # Indicates if the field uses the `nested` mapping type.
907
+ #
908
+ # @private
909
+ def nested?
910
+ mapping_type == "nested"
911
+ end
912
+
913
+ private
914
+
915
+ def args_sdl(joiner:, after_opening_paren: "", &arg_selector)
916
+ selected_args = args.values.select(&arg_selector)
917
+ args_sdl = selected_args.map(&:to_sdl).flat_map { |s| s.split("\n") }.join(joiner)
918
+ return nil if args_sdl.empty?
919
+ "(#{after_opening_paren}#{args_sdl})"
920
+ end
921
+
922
+ # Indicates if the field uses the `text` mapping type.
923
+ def text?
924
+ mapping_type == "text"
925
+ end
926
+
927
+ def define_legacy_timestamp_grouping_arguments_if_needed(grouping_field)
928
+ case type.fully_unwrapped.name
929
+ when "Date"
930
+ grouping_field.argument schema_def_state.schema_elements.granularity, "DateGroupingGranularity!" do |a|
931
+ a.documentation "Determines the grouping granularity for this field."
932
+ end
933
+
934
+ grouping_field.argument schema_def_state.schema_elements.offset_days, "Int" do |a|
935
+ a.documentation <<~EOS
936
+ Number of days (positive or negative) to shift the `Date` boundaries of each date grouping bucket.
937
+
938
+ For example, when grouping by `YEAR`, this can be used to align the buckets with fiscal or school years instead of calendar years.
939
+ EOS
940
+ end
941
+ when "DateTime"
942
+ grouping_field.argument schema_def_state.schema_elements.granularity, "DateTimeGroupingGranularity!" do |a|
943
+ a.documentation "Determines the grouping granularity for this field."
944
+ end
945
+
946
+ grouping_field.argument schema_def_state.schema_elements.time_zone, "TimeZone" do |a|
947
+ a.documentation "The time zone to use when determining which grouping a `DateTime` value falls in."
948
+ a.default "UTC"
949
+ end
950
+
951
+ grouping_field.argument schema_def_state.schema_elements.offset, "DateTimeGroupingOffsetInput" do |a|
952
+ a.documentation <<~EOS
953
+ Amount of offset (positive or negative) to shift the `DateTime` boundaries of each grouping bucket.
954
+
955
+ For example, when grouping by `WEEK`, you can shift by 24 hours to change what day-of-week weeks are considered to start on.
956
+ EOS
957
+ end
958
+ end
959
+ end
960
+
961
+ def list_field_grouped_by_doc_note(individual_value_selection_description)
962
+ <<~EOS.strip
963
+ Note: `#{name}` is a collection field, but selecting this field will group on individual values of #{individual_value_selection_description}.
964
+ That means that a document may be grouped into multiple aggregation groupings (i.e. when its `#{name}`
965
+ field has multiple values) leading to some data duplication in the response. However, if a value shows
966
+ up in `#{name}` multiple times for a single document, that document will only be included in the group
967
+ once
968
+ EOS
969
+ end
970
+
971
+ # Determines the suffix of the filter field derived for this field. The suffix used determines
972
+ # the filtering capabilities (e.g. filtering on a single value vs a list of values with `any_satisfy`).
973
+ def filter_field_category(for_single_value)
974
+ return :filter_input if for_single_value
975
+
976
+ # For an index leaf field, there are no further nesting paths to traverse. We want to directly
977
+ # use a `ListFilterInput` type (e.g. `IntListFilterInput`) to offer `any_satisfy` filtering at this level.
978
+ return :list_filter_input if index_leaf?
979
+
980
+ # If it's a list-of-objects field we require the user to tell us what mapping type they want to
981
+ # use, which determines the suffix (and is handled below). Otherwise, we want to use `FieldsListFilterInput`.
982
+ # We are within a list filtering context (as indicated by `for_single_value` being false) without
983
+ # being at an index leaf field, so we must use `FieldsListFilterInput` as there are further nesting paths
984
+ # on the document and we want to provide `any_satisfy` at the leaf fields.
985
+ return :fields_list_filter_input unless type_for_derived_types.list?
986
+
987
+ case mapping_type
988
+ when "nested" then :list_filter_input
989
+ when "object" then :fields_list_filter_input
990
+ else
991
+ raise SchemaError, <<~EOS
992
+ `#{parent_type.name}.#{name}` is a list-of-objects field, but the mapping type has not been explicitly specified. Elasticsearch and OpenSearch
993
+ offer two ways to index list-of-objects fields. It cannot be changed on an existing field without dropping the index and recreating it (losing
994
+ any existing indexed data!), and there are nuanced tradeoffs involved here, so ElasticGraph provides no default mapping in this situation.
995
+
996
+ If you're currently prototyping and don't want to spend time weighing this tradeoff, we recommend you do this:
997
+
998
+ ```
999
+ t.field "#{name}", "#{type.name}" do |f|
1000
+ # Here we are opting for flexibility (nested) over pure performance (object).
1001
+ # TODO: evaluate if we want to stick with `nested` before going to production.
1002
+ f.mapping type: "nested"
1003
+ end
1004
+ ```
1005
+
1006
+ Read on for details of the tradeoff involved here.
1007
+
1008
+ -----------------------------------------------------------------------------------------------------------------------------
1009
+
1010
+ Here are the options:
1011
+
1012
+ 1) `f.mapping type: "object"` will cause each field path to be indexed as a separate "flattened" list.
1013
+
1014
+ For example, given a `Film` document like this:
1015
+
1016
+ ```
1017
+ {
1018
+ "name": "The Empire Strikes Back",
1019
+ "characters": [
1020
+ {"first": "Luke", "last": "Skywalker"},
1021
+ {"first": "Han", "last": "Solo"}
1022
+ ]
1023
+ }
1024
+ ```
1025
+
1026
+ ...the data will look like this in the inverted Lucene index:
1027
+
1028
+ ```
1029
+ {
1030
+ "name": "The Empire Strikes Back",
1031
+ "characters.first": ["Luke", "Han"],
1032
+ "characters.last": ["Skywalker", "Solo"]
1033
+ }
1034
+ ```
1035
+
1036
+ This is highly efficient, but there is no way to search on multiple fields of a character and be sure that the matching values came from the same character.
1037
+ ElasticGraph models this in the filtering API it offers for this case:
1038
+
1039
+ ```
1040
+ query {
1041
+ films(filter: {
1042
+ characters: {
1043
+ first: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}}
1044
+ last: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}}
1045
+ }
1046
+ }) {
1047
+ # ...
1048
+ }
1049
+ }
1050
+ ```
1051
+
1052
+ As suggested by this filtering API, this will match any film that has a character with a first name of "Luke" and a character
1053
+ with the last name of "Skywalker", but this could be satisfied by two separate characters.
1054
+
1055
+ 2) `f.mapping type: "nested"` will cause each _object_ in the list to be indexed as a separate hidden document, preserving the independence of each.
1056
+
1057
+ Given a `Film` document like "The Empire Strikes Back" from above, the `nested` type will index separate hidden documents for each character. This
1058
+ allows ElasticGraph to offer this filtering API instead:
1059
+
1060
+ ```
1061
+ query {
1062
+ films(filter: {
1063
+ characters: {#{schema_def_state.schema_elements.any_satisfy}: {
1064
+ first: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}
1065
+ last: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}
1066
+ }}
1067
+ }) {
1068
+ # ...
1069
+ }
1070
+ }
1071
+ ```
1072
+
1073
+ As suggested by this filtering API, this will only match films that have a character named "Luke Skywalker". However, the Elasticsearch docs[^1][^2] warn
1074
+ that the `nested` mapping type can lead to performance problems, and index sorting cannot be configured[^3] when the `nested` type is used.
1075
+
1076
+ [^1]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/nested.html
1077
+ [^2]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/joining-queries.html
1078
+ [^3]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/index-modules-index-sorting.html
1079
+ EOS
1080
+ end
1081
+ end
1082
+ end
1083
+ end
1084
+ end
1085
+ end