elasticgraph-schema_definition 0.18.0.0

Sign up to get free protection for your applications and to get access to all the features.
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