Class: ElasticGraph::SchemaDefinition::SchemaElements::Field

Inherits:
Struct
  • Object
show all
Includes:
Mixins::HasDirectives, Mixins::HasDocumentation, Mixins::HasTypeInfo, Mixins::VerifiesGraphQLName
Defined in:
elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb

Overview

Represents a GraphQL field.

Examples:

Define a GraphQL field

ElasticGraph.define_schema do |schema|
  schema.object_type "Widget" do |t|
    t.field "id", "ID" do |f|
      # `f` in this block is a Field object
    end
  end
end

Direct Known Subclasses

Relationship

Constant Summary

Constants included from Mixins::HasTypeInfo

Mixins::HasTypeInfo::CUSTOMIZABLE_DATASTORE_PARAMS

Instance Attribute Summary collapse

Attributes included from Mixins::HasDocumentation

#doc_comment

Instance Method Summary collapse

Methods included from Mixins::HasTypeInfo

#json_schema_options, #mapping, #mapping_options

Methods included from Mixins::HasDirectives

#directive, #directives, #directives_sdl

Methods included from Mixins::HasDocumentation

#append_to_documentation, #derived_documentation, #documentation, #formatted_documentation

Methods included from Mixins::VerifiesGraphQLName

verify_name!

Instance Attribute Details

#graphql_onlyBoolean (readonly)

Returns true if this field exists only in the GraphQL schema and is not indexed.

Returns:

  • (Boolean)

    true if this field exists only in the GraphQL schema and is not indexed



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 89

class Field < Struct.new(
  :name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
  :filter_customizations, :grouped_by_customizations, :sub_aggregations_customizations,
  :aggregated_values_customizations, :sort_order_enum_value_customizations,
  :args, :sortable, :filterable, :aggregatable, :groupable, :graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
  :computation_detail, :non_nullable_in_json_schema, :backing_indexing_field, :as_input,
  :legacy_grouping_schema, :name_in_index
)
  include Mixins::HasDocumentation
  include Mixins::HasDirectives
  include Mixins::HasTypeInfo
  include Mixins::HasReadableToSAndInspect.new { |f| "#{f.parent_type.name}.#{f.name}: #{f.type}" }

  # @private
  def initialize(
    name:, type:, parent_type:, schema_def_state:,
    accuracy_confidence: :high, name_in_index: name,
    runtime_metadata_graphql_field: SchemaArtifacts::RuntimeMetadata::GraphQLField::EMPTY,
    type_for_derived_types: nil, graphql_only: nil, singular: nil,
    sortable: nil, filterable: nil, aggregatable: nil, groupable: nil,
    backing_indexing_field: nil, as_input: false, legacy_grouping_schema: false
  )
    type_ref = schema_def_state.type_ref(type)
    super(
      name: name,
      original_type: type_ref,
      parent_type: parent_type,
      original_type_for_derived_types: type_for_derived_types ? schema_def_state.type_ref(type_for_derived_types) : type_ref,
      schema_def_state: schema_def_state,
      accuracy_confidence: accuracy_confidence,
      filter_customizations: [],
      grouped_by_customizations: [],
      sub_aggregations_customizations: [],
      aggregated_values_customizations: [],
      sort_order_enum_value_customizations: [],
      args: {},
      sortable: sortable,
      filterable: filterable,
      aggregatable: aggregatable,
      groupable: groupable,
      graphql_only: graphql_only,
      source: nil,
      runtime_field_script: nil,
      # Note: we named the keyword argument `singular` (with no `_name` suffix) for consistency with
      # other schema definition APIs, which also use `singular:` instead of `singular_name:`. We include
      # the `_name` suffix on the attribute for clarity.
      singular_name: singular,
      name_in_index: name_in_index,
      non_nullable_in_json_schema: false,
      backing_indexing_field: backing_indexing_field,
      as_input: as_input,
      legacy_grouping_schema: legacy_grouping_schema
    )

    if name != name_in_index && name_in_index&.include?(".") && !graphql_only
      raise Errors::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."
    end

    schema_def_state.register_user_defined_field(self)
    yield self if block_given?
  end

  # @private
  @@initialize_param_names = instance_method(:initialize).parameters.map(&:last).to_set

  # must come after we capture the initialize params.
  prepend Mixins::VerifiesGraphQLName

  # @return [TypeReference] the type of this field
  def type
    # Here we lazily convert the `original_type` to an input type as needed. This must be lazy because
    # the logic of `as_input` depends on detecting whether the type is an enum type, which it may not
    # be able to do right away--we assume not if we can't tell, and retry every time this method is called.
    original_type.to_final_form(as_input: as_input)
  end

  # @return [TypeReference] the type of the corresponding field on derived types (usually this is the same as {#type}).
  #
  # @private
  def type_for_derived_types
    original_type_for_derived_types.to_final_form(as_input: as_input)
  end

  # @note For each field defined in your schema that is filterable, a corresponding filtering field will be created on the
  #   `*FilterInput` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding filtering field that will be generated for this
  # field.
  #
  # @yield [Field] derived filtering field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignFilterInput.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_filter_field do |ff|
  #           ff.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_filter_field(&customization_block)
    filter_customizations << customization_block
  end

  # @note For each field defined in your schema that is aggregatable, a corresponding `aggregatedValues` field will be created on the
  #   `*AggregatedValues` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `aggregatedValues` field that will be generated for
  # this field.
  #
  # @yield [Field] derived aggregated values field
  # @return [void]
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignAggregatedValues.adImpressions` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "adImpressions", "Int" do |f|
  #         f.customize_aggregated_values_field do |avf|
  #           avf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_aggregated_values_field(&customization_block)
    aggregated_values_customizations << customization_block
  end

  # @note For each field defined in your schema that is groupable, a corresponding `groupedBy` field will be created on the
  #   `*AggregationGroupedBy` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `groupedBy` field that will be generated for this
  # field.
  #
  # @yield [Field] derived grouped by field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignGroupedBy.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_grouped_by_field do |gbf|
  #           gbf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_grouped_by_field(&customization_block)
    grouped_by_customizations << customization_block
  end

  # @note For each field defined in your schema that is sub-aggregatable (e.g. list fields indexed using the `nested` mapping type),
  # a corresponding field will be created on the `*AggregationSubAggregations` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `subAggregations` field that will be generated for
  # this field.
  #
  # @yield [Field] derived sub-aggregations field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #on_each_generated_schema_element
  #
  # @example Mark `TransactionAggregationSubAggregations.fees` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "fees", "[Money!]!" do |f|
  #         f.mapping type: "nested"
  #
  #         f.customize_sub_aggregations_field do |saf|
  #           # Adds a `@deprecated` directive to the `PaymentAggregationSubAggregations.fees`
  #           # field without also adding it to the `Payment.fees` field.
  #           saf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #
  #     schema.object_type "Money" do |t|
  #       t.field "amount", "Int"
  #       t.field "currency", "String"
  #     end
  #   end
  def customize_sub_aggregations_field(&customization_block)
    sub_aggregations_customizations << customization_block
  end

  # @note for each sortable field, enum values will be generated on the derived sort order enum type allowing you to
  #   sort by the field `ASC` or `DESC`.
  #
  # Registers a customization callback that will be applied to the corresponding enum values that will be generated for this field
  # on the derived `SortOrder` enum type.
  #
  # @yield [SortOrderEnumValue] derived sort order enum value
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignSortOrder.organizationId_(ASC|DESC)` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_sort_order_enum_values do |soev|
  #           soev.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_sort_order_enum_values(&customization_block)
    sort_order_enum_value_customizations << customization_block
  end

  # When you define a {Field} on an {ObjectType} or {InterfaceType}, ElasticGraph generates up to 6 different GraphQL schema elements
  # for it:
  #
  # * A {Field} is generated on the parent {ObjectType} or {InterfaceType} (that is, this field itself). This is used by clients to
  #   ask for values for the field in a response.
  # * A {Field} may be generated on the `*FilterInput` {InputType} derived from the parent {ObjectType} or {InterfaceType}. This is
  #   used by clients to specify how the query should filter.
  # * A {Field} may be generated on the `*AggregationGroupedBy` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to specify how aggregations should be grouped.
  # * A {Field} may be generated on the `*AggregatedValues` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to apply aggregation functions (e.g. `sum`, `max`, `min`, etc) to a set of field values for a group.
  # * A {Field} may be generated on the `*AggregationSubAggregations` {ObjectType} derived from the parent {ObjectType} or
  #   {InterfaceType}. This is used by clients to perform sub-aggregations on list fields indexed using the `nested` mapping type.
  # * Multiple {EnumValue}s (both `*_ASC` and `*_DESC`) are generated on the `*SortOrder` {EnumType} derived from the parent indexed
  #   {ObjectType}. This is used by clients to sort by a field.
  #
  # This method registers a customization callback which is applied to every element that is generated for this field.
  #
  # @yield [Field, EnumValue] the schema element
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  #
  # @example
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "amount", "Int" do |f|
  #         f.on_each_generated_schema_element do |element|
  #           # Adds a `@deprecated` directive to every GraphQL schema element generated for `amount`:
  #           #
  #           # - The `Transaction.amount` field.
  #           # - The `TransactionFilterInput.amount` field.
  #           # - The `TransactionAggregationGroupedBy.amount` field.
  #           # - The `TransactionAggregatedValues.amount` field.
  #           # - The `TransactionSortOrder.amount_ASC` and`TransactionSortOrder.amount_DESC` enum values.
  #           element.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #   end
  def on_each_generated_schema_element(&customization_block)
    customization_block.call(self)
    customize_filter_field(&customization_block)
    customize_aggregated_values_field(&customization_block)
    customize_grouped_by_field(&customization_block)
    customize_sub_aggregations_field(&customization_block)
    customize_sort_order_enum_values(&customization_block)
  end

  # (see Mixins::HasTypeInfo#json_schema)
  def json_schema(nullable: nil, **options)
    if options.key?(:type)
      raise Errors::SchemaError, "Cannot override JSON schema type of field `#{name}` with `#{options.fetch(:type)}`"
    end

    case nullable
    when true
      raise Errors::SchemaError, "`nullable: true` is not allowed on a field--just declare the GraphQL field as being nullable (no `!` suffix) instead."
    when false
      self.non_nullable_in_json_schema = true
    end

    super(**options)
  end

  # Configures ElasticGraph to source a field’s value from a related object. This can be used to denormalize data at ingestion time to
  # support filtering, grouping, sorting, or aggregating data on a field from a related object.
  #
  # @param relationship [String] name of a relationship defined with {TypeWithSubfields#relates_to_one} using an inbound foreign key
  #   which contains the the field you wish to source values from
  # @param field_path [String] dot-separated path to the field on the related type containing values that should be copied to this
  #   field
  # @return [void]
  #
  # @example Source `City.currency` from `Country.currency`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Country" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.field "currency", "String"
  #       t.relates_to_one "capitalCity", "City", via: "capitalCityId", dir: :out
  #       t.index "countries"
  #     end
  #
  #     schema.object_type "City" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.relates_to_one "capitalOf", "Country", via: "capitalCityId", dir: :in
  #
  #       t.field "currency", "String" do |f|
  #         f.sourced_from "capitalOf", "currency"
  #       end
  #
  #       t.index "cities"
  #     end
  #   end
  def sourced_from(relationship, field_path)
    self.source = schema_def_state.factory.new_field_source(
      relationship_name: relationship,
      field_path: field_path
    )
  end

  # @private
  def runtime_script(script)
    self.runtime_field_script = script
  end

  # Registers an old name that this field used to have in a prior version of the schema.
  #
  # @note In situations where this API applies, ElasticGraph will give you an error message indicating that you need to use this API
  #   or {TypeWithSubfields#deleted_field}. Likewise, when ElasticGraph no longer needs to know about this, it'll give you a warning
  #   indicating the call to this method can be removed.
  #
  # @param old_name [String] old name this field used to have in a prior version of the schema
  # @return [void]
  #
  # @example Indicate that `Widget.description` used to be called `Widget.notes`.
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Widget" do |t|
  #       t.field "description", "String" do |f|
  #         f.renamed_from "notes"
  #       end
  #     end
  #   end
  def renamed_from(old_name)
    schema_def_state.register_renamed_field(
      parent_type.name,
      from: old_name,
      to: name,
      defined_at: caller_locations(1, 1).first, # : ::Thread::Backtrace::Location
      defined_via: %(field.renamed_from "#{old_name}")
    )
  end

  # @private
  def to_sdl(type_structure_only: false, default_value_sdl: nil, &arg_selector)
    if type_structure_only
      "#{name}#{args_sdl(joiner: ", ", &arg_selector)}: #{type.name}"
    else
      args_sdl = args_sdl(joiner: "\n  ", after_opening_paren: "\n  ", &arg_selector)
      "#{formatted_documentation}#{name}#{args_sdl}: #{type.name}#{default_value_sdl} #{directives_sdl}".strip
    end
  end

  # Indicates if this field is sortable. Sortable fields will have corresponding `_ASC` and `_DESC` values generated in the
  # sort order {EnumType} of the parent indexed type.
  #
  # By default, the sortability is inferred by the field type and mapping. For example, list fields are not sortable,
  # and fields mapped as `text` are not sortable either. Fields are sortable in most other cases.
  #
  # The `sortable: true` option can be used to force a field to be sortable.
  #
  # @return [Boolean] true if this field is sortable
  def sortable?
    return sortable unless sortable.nil?

    # List fields are not sortable by default. We'd need to provide the datastore a sort mode option:
    # https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_sort_mode_option
    return false if type.list?

    # Boolean fields are not sortable by default.
    #   - Boolean: sorting all falses before all trues (or whatever) is not generally interesting.
    return false if type.unwrap_non_null.boolean?

    # Elasticsearch/OpenSearch do not support sorting text fields:
    # > Text fields are not used for sorting...
    # (from https://www.elastic.co/guide/en/elasticsearch/reference/current/the datastore.html#text)
    return false if text?

    # 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.
    return false if type.as_object_type&.has_custom_mapping_type?

    # Default every other field to being sortable.
    true
  end

  # Indicates if this field is filterable. Filterable fields will be available in the GraphQL schema under the `filter` argument.
  #
  # Most fields are filterable, except when:
  #
  # - It's a relation. Relation fields require us to load the related data from another index and can't be filtered on.
  # - The field is an object type that isn't itself filterable (e.g. due to having no filterable fields or whatever).
  # - Explicitly disabled with `filterable: false`.
  #
  # @return [Boolean]
  def filterable?
    # Object types that use custom index mappings (as `GeoLocation` does) aren't filterable
    # by default since we can't guess what datastore filtering capabilities they have. We've implemented
    # filtering support for `GeoLocation` fields, though, so we need to explicitly make it fliterable here.
    # TODO: clean this up using an interface instead of checking for `GeoLocation`.
    return true if type.fully_unwrapped.name == "GeoLocation"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:filterable?)
    return true if filterable.nil?
    filterable
  end

  # Indicates if this field is groupable. Groupable fields will be available under `groupedBy` for an aggregations query.
  #
  # Groupability is inferred based on the field type and mapping type, or you can use the `groupable: true` option to force it.
  #
  # @return [Boolean]
  def groupable?
    # If the groupability of the field was specified explicitly when the field was defined, use the specified value.
    return groupable unless groupable.nil?

    # We don't want the `id` field of an indexed type to be available to group by, because it's the unique primary key
    # and the groupings would each contain one document. It's simpler and more efficient to just query the raw documents
    # instead.
    return false if parent_type.indexed? && name == "id"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:groupable?)

    # We don't support grouping an entire list of values, but we do support grouping on individual values in a list.
    # However, we only do so when a `singular_name` has been provided (so that we know what to call the grouped_by field).
    # The semantics are a little odd (since one document can be duplicated in multiple grouping buckets) so we're ok
    # with not offering it by default--the user has to opt-in by telling us what to call the field in its singular form.
    return list_field_groupable_by_single_values? if type.list? && type.fully_unwrapped.leaf?

    # Nested fields will be supported through specific nested aggregation support, and do not
    # work as expected when grouping on the root document type.
    return false if nested?

    # Text fields cannot be efficiently grouped on, so make them non-groupable by default.
    return false if text?

    # In all other cases, default to being groupable.
    true
  end

  # Indicates if this field is aggregatable. Aggregatable fields will be available under `aggregatedValues` for an aggregations query.
  #
  # Aggregatability is inferred based on the field type and mapping type, or you can use the `aggregatable: true` option to force it.
  #
  # @return [Boolean]
  def aggregatable?
    return aggregatable unless aggregatable.nil?
    return false if relationship

    # We don't yet support aggregating over subfields of a `nested` field.
    # TODO: add support for aggregating over subfields of `nested` fields.
    return false if nested?

    # Text fields are not efficiently aggregatable (and you'll often get errors from the datastore if you attempt to aggregate them).
    return false if text?

    type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:aggregatable?) || index_leaf?
  end

  # Indicates if this field can be used as the basis for a sub-aggregation. Sub-aggregatable fields will be available under
  # `subAggregations` for an aggregations query.
  #
  # Only nested fields, and object fields which have nested fields, can be sub-aggregated.
  #
  # @return [Boolean]
  def sub_aggregatable?
    return false if relationship

    nested? || type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:sub_aggregatable?)
  end

  # Defines an argument on the field.
  #
  # @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
  #   this API, and it has no way to interpret arbitrary arguments defined on a field. However, it can be useful for extensions that
  #   extend the {ElasticGraph::GraphQL} query engine. For example, {ElasticGraph::Apollo} uses this API to satisfy the [Apollo
  #   federation subgraph spec](https://www.apollographql.com/docs/federation/federation-spec/).
  #
  # @param name [String] name of the argument
  # @param value_type [String] type of the argument in GraphQL SDL syntax
  # @yield [Argument] for further customization
  #
  # @example Define an argument on a field
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Product" do |t|
  #       t.field "name", "String" do |f|
  #         f.argument "language", "String"
  #       end
  #     end
  #   end
  def argument(name, value_type, &block)
    args[name] = schema_def_state.factory.new_argument(
      self,
      name,
      schema_def_state.type_ref(value_type),
      &block
    )
  end

  # The index mapping type in effect for this field. This could come from either the field definition or from the type definition.
  #
  # @return [String]
  def mapping_type
    backing_indexing_field&.mapping_type || (resolve_mapping || {})["type"]
  end

  # @private
  def list_field_groupable_by_single_values?
    (type.list? || backing_indexing_field&.type&.list?) && !singular_name.nil?
  end

  # @private
  def define_aggregated_values_field(parent_type)
    return unless aggregatable?

    unwrapped_type_for_derived_types = type_for_derived_types.fully_unwrapped
    aggregated_values_type =
      if index_leaf?
        unwrapped_type_for_derived_types.resolved.aggregated_values_type
      else
        unwrapped_type_for_derived_types.as_aggregated_values
      end

    parent_type.field name, aggregated_values_type.name, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Computed aggregate values for the `#{name}` field")
      aggregated_values_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def define_grouped_by_field(parent_type)
    return unless (field_name = grouped_by_field_name)

    parent_type.field field_name, grouped_by_field_type_name, name_in_index: name_in_index, graphql_only: true do |f|
      add_grouped_by_field_documentation(f)

      define_legacy_timestamp_grouping_arguments_if_needed(f) if legacy_grouping_schema

      grouped_by_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def grouped_by_field_type_name
    unwrapped_type = type_for_derived_types.fully_unwrapped
    if unwrapped_type.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      unwrapped_type.with_reverted_override.as_grouped_by.name
    elsif unwrapped_type.leaf?
      unwrapped_type.name
    else
      unwrapped_type.as_grouped_by.name
    end
  end

  # @private
  def add_grouped_by_field_documentation(field)
    text = if list_field_groupable_by_single_values?
      derived_documentation(
        "The individual value from `#{name}` for this group",
        list_field_grouped_by_doc_note("`#{name}`")
      )
    elsif type.list? && type.fully_unwrapped.object?
      derived_documentation(
        "The `#{name}` field value for this group",
        list_field_grouped_by_doc_note("the selected subfields of `#{name}`")
      )
    elsif type_for_derived_types.fully_unwrapped.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      derived_documentation("Offers the different grouping options for the `#{name}` value within this group")
    else
      derived_documentation("The `#{name}` field value for this group")
    end

    field.documentation text
  end

  # @private
  def grouped_by_field_name
    return nil unless groupable?
    list_field_groupable_by_single_values? ? singular_name : name
  end

  # @private
  def define_sub_aggregations_field(parent_type:, type:)
    parent_type.field name, type, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Used to perform a sub-aggregation of `#{name}`")
      sub_aggregations_customizations.each { |c| c.call(f) }

      yield f if block_given?
    end
  end

  # @private
  def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list?)
    type_prefix = text? ? "Text" : type_for_derived_types.fully_unwrapped.name
    filter_type = schema_def_state
      .type_ref(type_prefix)
      .as_static_derived_type(filter_field_category(for_single_value))
      .name

    params = to_h
      .slice(*@@initialize_param_names)
      .merge(type: filter_type, parent_type: parent_type, name_in_index: name_in_index, type_for_derived_types: nil)

    schema_def_state.factory.new_field(**params).tap do |f|
      f.documentation derived_documentation(
        "Used to filter on the `#{name}` field",
        "Will be ignored if `null` or an empty object is passed"
      )

      filter_customizations.each { |c| c.call(f) }
    end
  end

  # @private
  def define_relay_pagination_arguments!
    argument schema_def_state.schema_elements.first.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `after` argument to forward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the first `n` after the provided
        `after` cursor (or from the start of the `#{name}`, if no `after` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to forward-paginate through the `#{name}`. When provided, the next page after the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.last.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `before` argument to backward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the last `n` before the provided
        `before` cursor (or from the end of the `#{name}`, if no `before` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to backward-paginate through the `#{name}`. When provided, the previous page before the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end
  end

  # Converts this field to an `Indexing::FieldReference`, which contains all the attributes involved
  # in building an `Indexing::Field`. Notably, we cannot actually convert to an `Indexing::Field` at
  # the point this method is called, because the referenced field type may not have been defined
  # yet. We don't need an actual `Indexing::Field` until the very end of the schema definition process,
  # when we are dumping the artifacts. However, we need this at field definition time so that we
  # can correctly detect duplicate indexing field issues when a field is defined. (This is used
  # in `TypeWithSubfields#field`).
  #
  # @private
  def to_indexing_field_reference
    return nil if graphql_only

    Indexing::FieldReference.new(
      name: name,
      name_in_index: name_in_index,
      type: non_nullable_in_json_schema ? type.wrap_non_null : type,
      mapping_options: mapping_options,
      json_schema_options: json_schema_options,
      accuracy_confidence: accuracy_confidence,
      source: source,
      runtime_field_script: runtime_field_script
    )
  end

  # Converts this field to its `IndexingField` form.
  #
  # @private
  def to_indexing_field
    to_indexing_field_reference&.resolve
  end

  # @private
  def resolve_mapping
    to_indexing_field&.mapping
  end

  # Returns the string paths to the list fields that we need to index counts for.
  # We do this to support the ability to filter on the size of a list.
  #
  # @private
  def paths_to_lists_for_count_indexing(has_list_ancestor: false)
    self_path = (has_list_ancestor || type.list?) ? [name_in_index] : []

    nested_paths =
      # Nested fields get indexed as separate hidden documents:
      # https://www.elastic.co/guide/en/elasticsearch/reference/8.8/nested.html
      #
      # Given that, the counts of any `nested` list subfields will go in a `__counts` field on the
      # separate hidden document.
      if !nested? && (object_type = type.fully_unwrapped.as_object_type)
        object_type.indexing_fields_by_name_in_index.values.flat_map do |sub_field|
          sub_field.paths_to_lists_for_count_indexing(has_list_ancestor: has_list_ancestor || type.list?).map do |sub_path|
            "#{name_in_index}#{LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR}#{sub_path}"
          end
        end
      else
        []
      end

    self_path + nested_paths
  end

  # Indicates if this field is a leaf value in the index.  Note that GraphQL leaf values
  # are always leaf values in the index but the inverse is not always true. For example,
  # a `GeoLocation` field is not a leaf in GraphQL (because `GeoLocation` is an object
  # type with subfields) but in the index we use a single `geo_point` mapping type, which
  # is a single unit, so we consider it an index leaf.
  #
  # @private
  def index_leaf?
    type_for_derived_types.fully_unwrapped.leaf? || DATASTORE_PROPERTYLESS_OBJECT_TYPES.include?(mapping_type)
  end

  # @private
  ACCURACY_SCORES = {
    # :high is assigned to `Field`s that are generated directly from GraphQL fields or :extra_fields.
    # For these, we know everything available to us in the schema about them.
    high: 3,

    # :medium is assigned to `Field`s that are inferred from the id fields required by a relation.
    # We make logical guesses about the `indexing_field_type` but if the field is also manually defined,
    # it could be slightly different (e.g. additional json schema validations), so we have medium
    # confidence of these.
    medium: 2,

    # :low is assigned to the ElastcField inferred for the foreign key of an inbound relation. The
    # nullability/cardinality of the foreign key field cannot be known from the relation metadata,
    # so we just guess what seems safest (`[:nullable]`). If the field is defined another way
    # we should prefer it, so we give these fields :low confidence.
    low: 1
  }

  # Given two fields, picks the one that is most accurate. If they have the same accuracy
  # confidence, yields to a block to force it to deal with the discrepancy, unless the fields
  # are exactly equal (in which case we can return either).
  #
  # @private
  def self.pick_most_accurate_from(field1, field2, to_comparable: ->(it) { it })
    return field1 if to_comparable.call(field1) == to_comparable.call(field2)
    yield if field1.accuracy_confidence == field2.accuracy_confidence
    # Array#max_by can return nil (when called on an empty array), but our steep type is non-nil.
    # Since it's not smart enough to realize the non-empty-array-usage of `max_by` won't return nil,
    # we have to cast it to untyped here.
    _ = [field1, field2].max_by { |f| ACCURACY_SCORES.fetch(f.accuracy_confidence) }
  end

  # Indicates if the field uses the `nested` mapping type.
  #
  # @private
  def nested?
    mapping_type == "nested"
  end

  # Records the `ComputationDetail` that should be on the `runtime_metadata_graphql_field`.
  #
  # @private
  def (empty_bucket_value:, function:)
    self.computation_detail = SchemaArtifacts::RuntimeMetadata::ComputationDetail.new(
      empty_bucket_value: empty_bucket_value,
      function: function
    )
  end

  # Lazily creates and returns a GraphQLField using the field's {#name_in_index}, {#computation_detail},
  # and {#relationship}.
  #
  # @private
  def 
    SchemaArtifacts::RuntimeMetadata::GraphQLField.new(
      name_in_index: name_in_index,
      computation_detail: computation_detail,
      relation: relationship&.
    )
  end

  private

  def args_sdl(joiner:, after_opening_paren: "", &arg_selector)
    selected_args = args.values.select(&arg_selector)
    args_sdl = selected_args.map(&:to_sdl).flat_map { |s| s.split("\n") }.join(joiner)
    return nil if args_sdl.empty?
    "(#{after_opening_paren}#{args_sdl})"
  end

  # Indicates if the field uses the `text` mapping type.
  def text?
    mapping_type == "text"
  end

  def define_legacy_timestamp_grouping_arguments_if_needed(grouping_field)
    case type.fully_unwrapped.name
    when "Date"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.offset_days, "Int" do |a|
        a.documentation <<~EOS
          Number of days (positive or negative) to shift the `Date` boundaries of each date grouping bucket.

          For example, when grouping by `YEAR`, this can be used to align the buckets with fiscal or school years instead of calendar years.
        EOS
      end
    when "DateTime"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateTimeGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.time_zone, "TimeZone" do |a|
        a.documentation "The time zone to use when determining which grouping a `DateTime` value falls in."
        a.default "UTC"
      end

      grouping_field.argument schema_def_state.schema_elements.offset, "DateTimeGroupingOffsetInput" do |a|
        a.documentation <<~EOS
          Amount of offset (positive or negative) to shift the `DateTime` boundaries of each grouping bucket.

          For example, when grouping by `WEEK`, you can shift by 24 hours to change what day-of-week weeks are considered to start on.
        EOS
      end
    end
  end

  def list_field_grouped_by_doc_note(individual_value_selection_description)
    <<~EOS.strip
      Note: `#{name}` is a collection field, but selecting this field will group on individual values of #{individual_value_selection_description}.
      That means that a document may be grouped into multiple aggregation groupings (i.e. when its `#{name}`
      field has multiple values) leading to some data duplication in the response. However, if a value shows
      up in `#{name}` multiple times for a single document, that document will only be included in the group
      once
    EOS
  end

  # Determines the suffix of the filter field derived for this field. The suffix used determines
  # the filtering capabilities (e.g. filtering on a single value vs a list of values with `any_satisfy`).
  def filter_field_category(for_single_value)
    return :filter_input if for_single_value

    # For an index leaf field, there are no further nesting paths to traverse. We want to directly
    # use a `ListFilterInput` type (e.g. `IntListFilterInput`) to offer `any_satisfy` filtering at this level.
    return :list_filter_input if index_leaf?

    # If it's a list-of-objects field we require the user to tell us what mapping type they want to
    # use, which determines the suffix (and is handled below). Otherwise, we want to use `FieldsListFilterInput`.
    # We are within a list filtering context (as indicated by `for_single_value` being false) without
    # being at an index leaf field, so we must use `FieldsListFilterInput` as there are further nesting paths
    # on the document and we want to provide `any_satisfy` at the leaf fields.
    return :fields_list_filter_input unless type_for_derived_types.list?

    case mapping_type
    when "nested" then :list_filter_input
    when "object" then :fields_list_filter_input
    else
      raise Errors::SchemaError, <<~EOS
        `#{parent_type.name}.#{name}` is a list-of-objects field, but the mapping type has not been explicitly specified. Elasticsearch and OpenSearch
        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
        any existing indexed data!), and there are nuanced tradeoffs involved here, so ElasticGraph provides no default mapping in this situation.

        If you're currently prototyping and don't want to spend time weighing this tradeoff, we recommend you do this:

        ```
        t.field "#{name}", "#{type.name}" do |f|
          # Here we are opting for flexibility (nested) over pure performance (object).
          # TODO: evaluate if we want to stick with `nested` before going to production.
          f.mapping type: "nested"
        end
        ```

        Read on for details of the tradeoff involved here.

        -----------------------------------------------------------------------------------------------------------------------------

        Here are the options:

        1) `f.mapping type: "object"` will cause each field path to be indexed as a separate "flattened" list.

        For example, given a `Film` document like this:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters": [
            {"first": "Luke", "last": "Skywalker"},
            {"first": "Han", "last": "Solo"}
          ]
        }
        ```

        ...the data will look like this in the inverted Lucene index:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters.first": ["Luke", "Han"],
          "characters.last": ["Skywalker", "Solo"]
        }
        ```

        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.
        ElasticGraph models this in the filtering API it offers for this case:

        ```
        query {
          films(filter: {
            characters: {
              first: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}}
              last: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}}
            }
          }) {
            # ...
          }
        }
        ```

        As suggested by this filtering API, this will match any film that has a character with a first name of "Luke" and a character
        with the last name of "Skywalker", but this could be satisfied by two separate characters.

        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.

        Given a `Film` document like "The Empire Strikes Back" from above, the `nested` type will index separate hidden documents for each character. This
        allows ElasticGraph to offer this filtering API instead:

        ```
        query {
          films(filter: {
            characters: {#{schema_def_state.schema_elements.any_satisfy}: {
              first: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}
              last: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}
            }}
          }) {
            # ...
          }
        }
        ```

        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
        that the `nested` mapping type can lead to performance problems, and index sorting cannot be configured[^3] when the `nested` type is used.

        [^1]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/nested.html
        [^2]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/joining-queries.html
        [^3]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/index-modules-index-sorting.html
      EOS
    end
  end
end

#nameString (readonly)

Returns name of the field.

Returns:

  • (String)

    name of the field



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 89

class Field < Struct.new(
  :name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
  :filter_customizations, :grouped_by_customizations, :sub_aggregations_customizations,
  :aggregated_values_customizations, :sort_order_enum_value_customizations,
  :args, :sortable, :filterable, :aggregatable, :groupable, :graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
  :computation_detail, :non_nullable_in_json_schema, :backing_indexing_field, :as_input,
  :legacy_grouping_schema, :name_in_index
)
  include Mixins::HasDocumentation
  include Mixins::HasDirectives
  include Mixins::HasTypeInfo
  include Mixins::HasReadableToSAndInspect.new { |f| "#{f.parent_type.name}.#{f.name}: #{f.type}" }

  # @private
  def initialize(
    name:, type:, parent_type:, schema_def_state:,
    accuracy_confidence: :high, name_in_index: name,
    runtime_metadata_graphql_field: SchemaArtifacts::RuntimeMetadata::GraphQLField::EMPTY,
    type_for_derived_types: nil, graphql_only: nil, singular: nil,
    sortable: nil, filterable: nil, aggregatable: nil, groupable: nil,
    backing_indexing_field: nil, as_input: false, legacy_grouping_schema: false
  )
    type_ref = schema_def_state.type_ref(type)
    super(
      name: name,
      original_type: type_ref,
      parent_type: parent_type,
      original_type_for_derived_types: type_for_derived_types ? schema_def_state.type_ref(type_for_derived_types) : type_ref,
      schema_def_state: schema_def_state,
      accuracy_confidence: accuracy_confidence,
      filter_customizations: [],
      grouped_by_customizations: [],
      sub_aggregations_customizations: [],
      aggregated_values_customizations: [],
      sort_order_enum_value_customizations: [],
      args: {},
      sortable: sortable,
      filterable: filterable,
      aggregatable: aggregatable,
      groupable: groupable,
      graphql_only: graphql_only,
      source: nil,
      runtime_field_script: nil,
      # Note: we named the keyword argument `singular` (with no `_name` suffix) for consistency with
      # other schema definition APIs, which also use `singular:` instead of `singular_name:`. We include
      # the `_name` suffix on the attribute for clarity.
      singular_name: singular,
      name_in_index: name_in_index,
      non_nullable_in_json_schema: false,
      backing_indexing_field: backing_indexing_field,
      as_input: as_input,
      legacy_grouping_schema: legacy_grouping_schema
    )

    if name != name_in_index && name_in_index&.include?(".") && !graphql_only
      raise Errors::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."
    end

    schema_def_state.register_user_defined_field(self)
    yield self if block_given?
  end

  # @private
  @@initialize_param_names = instance_method(:initialize).parameters.map(&:last).to_set

  # must come after we capture the initialize params.
  prepend Mixins::VerifiesGraphQLName

  # @return [TypeReference] the type of this field
  def type
    # Here we lazily convert the `original_type` to an input type as needed. This must be lazy because
    # the logic of `as_input` depends on detecting whether the type is an enum type, which it may not
    # be able to do right away--we assume not if we can't tell, and retry every time this method is called.
    original_type.to_final_form(as_input: as_input)
  end

  # @return [TypeReference] the type of the corresponding field on derived types (usually this is the same as {#type}).
  #
  # @private
  def type_for_derived_types
    original_type_for_derived_types.to_final_form(as_input: as_input)
  end

  # @note For each field defined in your schema that is filterable, a corresponding filtering field will be created on the
  #   `*FilterInput` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding filtering field that will be generated for this
  # field.
  #
  # @yield [Field] derived filtering field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignFilterInput.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_filter_field do |ff|
  #           ff.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_filter_field(&customization_block)
    filter_customizations << customization_block
  end

  # @note For each field defined in your schema that is aggregatable, a corresponding `aggregatedValues` field will be created on the
  #   `*AggregatedValues` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `aggregatedValues` field that will be generated for
  # this field.
  #
  # @yield [Field] derived aggregated values field
  # @return [void]
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignAggregatedValues.adImpressions` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "adImpressions", "Int" do |f|
  #         f.customize_aggregated_values_field do |avf|
  #           avf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_aggregated_values_field(&customization_block)
    aggregated_values_customizations << customization_block
  end

  # @note For each field defined in your schema that is groupable, a corresponding `groupedBy` field will be created on the
  #   `*AggregationGroupedBy` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `groupedBy` field that will be generated for this
  # field.
  #
  # @yield [Field] derived grouped by field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignGroupedBy.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_grouped_by_field do |gbf|
  #           gbf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_grouped_by_field(&customization_block)
    grouped_by_customizations << customization_block
  end

  # @note For each field defined in your schema that is sub-aggregatable (e.g. list fields indexed using the `nested` mapping type),
  # a corresponding field will be created on the `*AggregationSubAggregations` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `subAggregations` field that will be generated for
  # this field.
  #
  # @yield [Field] derived sub-aggregations field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #on_each_generated_schema_element
  #
  # @example Mark `TransactionAggregationSubAggregations.fees` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "fees", "[Money!]!" do |f|
  #         f.mapping type: "nested"
  #
  #         f.customize_sub_aggregations_field do |saf|
  #           # Adds a `@deprecated` directive to the `PaymentAggregationSubAggregations.fees`
  #           # field without also adding it to the `Payment.fees` field.
  #           saf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #
  #     schema.object_type "Money" do |t|
  #       t.field "amount", "Int"
  #       t.field "currency", "String"
  #     end
  #   end
  def customize_sub_aggregations_field(&customization_block)
    sub_aggregations_customizations << customization_block
  end

  # @note for each sortable field, enum values will be generated on the derived sort order enum type allowing you to
  #   sort by the field `ASC` or `DESC`.
  #
  # Registers a customization callback that will be applied to the corresponding enum values that will be generated for this field
  # on the derived `SortOrder` enum type.
  #
  # @yield [SortOrderEnumValue] derived sort order enum value
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignSortOrder.organizationId_(ASC|DESC)` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_sort_order_enum_values do |soev|
  #           soev.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_sort_order_enum_values(&customization_block)
    sort_order_enum_value_customizations << customization_block
  end

  # When you define a {Field} on an {ObjectType} or {InterfaceType}, ElasticGraph generates up to 6 different GraphQL schema elements
  # for it:
  #
  # * A {Field} is generated on the parent {ObjectType} or {InterfaceType} (that is, this field itself). This is used by clients to
  #   ask for values for the field in a response.
  # * A {Field} may be generated on the `*FilterInput` {InputType} derived from the parent {ObjectType} or {InterfaceType}. This is
  #   used by clients to specify how the query should filter.
  # * A {Field} may be generated on the `*AggregationGroupedBy` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to specify how aggregations should be grouped.
  # * A {Field} may be generated on the `*AggregatedValues` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to apply aggregation functions (e.g. `sum`, `max`, `min`, etc) to a set of field values for a group.
  # * A {Field} may be generated on the `*AggregationSubAggregations` {ObjectType} derived from the parent {ObjectType} or
  #   {InterfaceType}. This is used by clients to perform sub-aggregations on list fields indexed using the `nested` mapping type.
  # * Multiple {EnumValue}s (both `*_ASC` and `*_DESC`) are generated on the `*SortOrder` {EnumType} derived from the parent indexed
  #   {ObjectType}. This is used by clients to sort by a field.
  #
  # This method registers a customization callback which is applied to every element that is generated for this field.
  #
  # @yield [Field, EnumValue] the schema element
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  #
  # @example
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "amount", "Int" do |f|
  #         f.on_each_generated_schema_element do |element|
  #           # Adds a `@deprecated` directive to every GraphQL schema element generated for `amount`:
  #           #
  #           # - The `Transaction.amount` field.
  #           # - The `TransactionFilterInput.amount` field.
  #           # - The `TransactionAggregationGroupedBy.amount` field.
  #           # - The `TransactionAggregatedValues.amount` field.
  #           # - The `TransactionSortOrder.amount_ASC` and`TransactionSortOrder.amount_DESC` enum values.
  #           element.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #   end
  def on_each_generated_schema_element(&customization_block)
    customization_block.call(self)
    customize_filter_field(&customization_block)
    customize_aggregated_values_field(&customization_block)
    customize_grouped_by_field(&customization_block)
    customize_sub_aggregations_field(&customization_block)
    customize_sort_order_enum_values(&customization_block)
  end

  # (see Mixins::HasTypeInfo#json_schema)
  def json_schema(nullable: nil, **options)
    if options.key?(:type)
      raise Errors::SchemaError, "Cannot override JSON schema type of field `#{name}` with `#{options.fetch(:type)}`"
    end

    case nullable
    when true
      raise Errors::SchemaError, "`nullable: true` is not allowed on a field--just declare the GraphQL field as being nullable (no `!` suffix) instead."
    when false
      self.non_nullable_in_json_schema = true
    end

    super(**options)
  end

  # Configures ElasticGraph to source a field’s value from a related object. This can be used to denormalize data at ingestion time to
  # support filtering, grouping, sorting, or aggregating data on a field from a related object.
  #
  # @param relationship [String] name of a relationship defined with {TypeWithSubfields#relates_to_one} using an inbound foreign key
  #   which contains the the field you wish to source values from
  # @param field_path [String] dot-separated path to the field on the related type containing values that should be copied to this
  #   field
  # @return [void]
  #
  # @example Source `City.currency` from `Country.currency`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Country" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.field "currency", "String"
  #       t.relates_to_one "capitalCity", "City", via: "capitalCityId", dir: :out
  #       t.index "countries"
  #     end
  #
  #     schema.object_type "City" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.relates_to_one "capitalOf", "Country", via: "capitalCityId", dir: :in
  #
  #       t.field "currency", "String" do |f|
  #         f.sourced_from "capitalOf", "currency"
  #       end
  #
  #       t.index "cities"
  #     end
  #   end
  def sourced_from(relationship, field_path)
    self.source = schema_def_state.factory.new_field_source(
      relationship_name: relationship,
      field_path: field_path
    )
  end

  # @private
  def runtime_script(script)
    self.runtime_field_script = script
  end

  # Registers an old name that this field used to have in a prior version of the schema.
  #
  # @note In situations where this API applies, ElasticGraph will give you an error message indicating that you need to use this API
  #   or {TypeWithSubfields#deleted_field}. Likewise, when ElasticGraph no longer needs to know about this, it'll give you a warning
  #   indicating the call to this method can be removed.
  #
  # @param old_name [String] old name this field used to have in a prior version of the schema
  # @return [void]
  #
  # @example Indicate that `Widget.description` used to be called `Widget.notes`.
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Widget" do |t|
  #       t.field "description", "String" do |f|
  #         f.renamed_from "notes"
  #       end
  #     end
  #   end
  def renamed_from(old_name)
    schema_def_state.register_renamed_field(
      parent_type.name,
      from: old_name,
      to: name,
      defined_at: caller_locations(1, 1).first, # : ::Thread::Backtrace::Location
      defined_via: %(field.renamed_from "#{old_name}")
    )
  end

  # @private
  def to_sdl(type_structure_only: false, default_value_sdl: nil, &arg_selector)
    if type_structure_only
      "#{name}#{args_sdl(joiner: ", ", &arg_selector)}: #{type.name}"
    else
      args_sdl = args_sdl(joiner: "\n  ", after_opening_paren: "\n  ", &arg_selector)
      "#{formatted_documentation}#{name}#{args_sdl}: #{type.name}#{default_value_sdl} #{directives_sdl}".strip
    end
  end

  # Indicates if this field is sortable. Sortable fields will have corresponding `_ASC` and `_DESC` values generated in the
  # sort order {EnumType} of the parent indexed type.
  #
  # By default, the sortability is inferred by the field type and mapping. For example, list fields are not sortable,
  # and fields mapped as `text` are not sortable either. Fields are sortable in most other cases.
  #
  # The `sortable: true` option can be used to force a field to be sortable.
  #
  # @return [Boolean] true if this field is sortable
  def sortable?
    return sortable unless sortable.nil?

    # List fields are not sortable by default. We'd need to provide the datastore a sort mode option:
    # https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_sort_mode_option
    return false if type.list?

    # Boolean fields are not sortable by default.
    #   - Boolean: sorting all falses before all trues (or whatever) is not generally interesting.
    return false if type.unwrap_non_null.boolean?

    # Elasticsearch/OpenSearch do not support sorting text fields:
    # > Text fields are not used for sorting...
    # (from https://www.elastic.co/guide/en/elasticsearch/reference/current/the datastore.html#text)
    return false if text?

    # 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.
    return false if type.as_object_type&.has_custom_mapping_type?

    # Default every other field to being sortable.
    true
  end

  # Indicates if this field is filterable. Filterable fields will be available in the GraphQL schema under the `filter` argument.
  #
  # Most fields are filterable, except when:
  #
  # - It's a relation. Relation fields require us to load the related data from another index and can't be filtered on.
  # - The field is an object type that isn't itself filterable (e.g. due to having no filterable fields or whatever).
  # - Explicitly disabled with `filterable: false`.
  #
  # @return [Boolean]
  def filterable?
    # Object types that use custom index mappings (as `GeoLocation` does) aren't filterable
    # by default since we can't guess what datastore filtering capabilities they have. We've implemented
    # filtering support for `GeoLocation` fields, though, so we need to explicitly make it fliterable here.
    # TODO: clean this up using an interface instead of checking for `GeoLocation`.
    return true if type.fully_unwrapped.name == "GeoLocation"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:filterable?)
    return true if filterable.nil?
    filterable
  end

  # Indicates if this field is groupable. Groupable fields will be available under `groupedBy` for an aggregations query.
  #
  # Groupability is inferred based on the field type and mapping type, or you can use the `groupable: true` option to force it.
  #
  # @return [Boolean]
  def groupable?
    # If the groupability of the field was specified explicitly when the field was defined, use the specified value.
    return groupable unless groupable.nil?

    # We don't want the `id` field of an indexed type to be available to group by, because it's the unique primary key
    # and the groupings would each contain one document. It's simpler and more efficient to just query the raw documents
    # instead.
    return false if parent_type.indexed? && name == "id"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:groupable?)

    # We don't support grouping an entire list of values, but we do support grouping on individual values in a list.
    # However, we only do so when a `singular_name` has been provided (so that we know what to call the grouped_by field).
    # The semantics are a little odd (since one document can be duplicated in multiple grouping buckets) so we're ok
    # with not offering it by default--the user has to opt-in by telling us what to call the field in its singular form.
    return list_field_groupable_by_single_values? if type.list? && type.fully_unwrapped.leaf?

    # Nested fields will be supported through specific nested aggregation support, and do not
    # work as expected when grouping on the root document type.
    return false if nested?

    # Text fields cannot be efficiently grouped on, so make them non-groupable by default.
    return false if text?

    # In all other cases, default to being groupable.
    true
  end

  # Indicates if this field is aggregatable. Aggregatable fields will be available under `aggregatedValues` for an aggregations query.
  #
  # Aggregatability is inferred based on the field type and mapping type, or you can use the `aggregatable: true` option to force it.
  #
  # @return [Boolean]
  def aggregatable?
    return aggregatable unless aggregatable.nil?
    return false if relationship

    # We don't yet support aggregating over subfields of a `nested` field.
    # TODO: add support for aggregating over subfields of `nested` fields.
    return false if nested?

    # Text fields are not efficiently aggregatable (and you'll often get errors from the datastore if you attempt to aggregate them).
    return false if text?

    type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:aggregatable?) || index_leaf?
  end

  # Indicates if this field can be used as the basis for a sub-aggregation. Sub-aggregatable fields will be available under
  # `subAggregations` for an aggregations query.
  #
  # Only nested fields, and object fields which have nested fields, can be sub-aggregated.
  #
  # @return [Boolean]
  def sub_aggregatable?
    return false if relationship

    nested? || type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:sub_aggregatable?)
  end

  # Defines an argument on the field.
  #
  # @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
  #   this API, and it has no way to interpret arbitrary arguments defined on a field. However, it can be useful for extensions that
  #   extend the {ElasticGraph::GraphQL} query engine. For example, {ElasticGraph::Apollo} uses this API to satisfy the [Apollo
  #   federation subgraph spec](https://www.apollographql.com/docs/federation/federation-spec/).
  #
  # @param name [String] name of the argument
  # @param value_type [String] type of the argument in GraphQL SDL syntax
  # @yield [Argument] for further customization
  #
  # @example Define an argument on a field
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Product" do |t|
  #       t.field "name", "String" do |f|
  #         f.argument "language", "String"
  #       end
  #     end
  #   end
  def argument(name, value_type, &block)
    args[name] = schema_def_state.factory.new_argument(
      self,
      name,
      schema_def_state.type_ref(value_type),
      &block
    )
  end

  # The index mapping type in effect for this field. This could come from either the field definition or from the type definition.
  #
  # @return [String]
  def mapping_type
    backing_indexing_field&.mapping_type || (resolve_mapping || {})["type"]
  end

  # @private
  def list_field_groupable_by_single_values?
    (type.list? || backing_indexing_field&.type&.list?) && !singular_name.nil?
  end

  # @private
  def define_aggregated_values_field(parent_type)
    return unless aggregatable?

    unwrapped_type_for_derived_types = type_for_derived_types.fully_unwrapped
    aggregated_values_type =
      if index_leaf?
        unwrapped_type_for_derived_types.resolved.aggregated_values_type
      else
        unwrapped_type_for_derived_types.as_aggregated_values
      end

    parent_type.field name, aggregated_values_type.name, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Computed aggregate values for the `#{name}` field")
      aggregated_values_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def define_grouped_by_field(parent_type)
    return unless (field_name = grouped_by_field_name)

    parent_type.field field_name, grouped_by_field_type_name, name_in_index: name_in_index, graphql_only: true do |f|
      add_grouped_by_field_documentation(f)

      define_legacy_timestamp_grouping_arguments_if_needed(f) if legacy_grouping_schema

      grouped_by_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def grouped_by_field_type_name
    unwrapped_type = type_for_derived_types.fully_unwrapped
    if unwrapped_type.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      unwrapped_type.with_reverted_override.as_grouped_by.name
    elsif unwrapped_type.leaf?
      unwrapped_type.name
    else
      unwrapped_type.as_grouped_by.name
    end
  end

  # @private
  def add_grouped_by_field_documentation(field)
    text = if list_field_groupable_by_single_values?
      derived_documentation(
        "The individual value from `#{name}` for this group",
        list_field_grouped_by_doc_note("`#{name}`")
      )
    elsif type.list? && type.fully_unwrapped.object?
      derived_documentation(
        "The `#{name}` field value for this group",
        list_field_grouped_by_doc_note("the selected subfields of `#{name}`")
      )
    elsif type_for_derived_types.fully_unwrapped.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      derived_documentation("Offers the different grouping options for the `#{name}` value within this group")
    else
      derived_documentation("The `#{name}` field value for this group")
    end

    field.documentation text
  end

  # @private
  def grouped_by_field_name
    return nil unless groupable?
    list_field_groupable_by_single_values? ? singular_name : name
  end

  # @private
  def define_sub_aggregations_field(parent_type:, type:)
    parent_type.field name, type, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Used to perform a sub-aggregation of `#{name}`")
      sub_aggregations_customizations.each { |c| c.call(f) }

      yield f if block_given?
    end
  end

  # @private
  def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list?)
    type_prefix = text? ? "Text" : type_for_derived_types.fully_unwrapped.name
    filter_type = schema_def_state
      .type_ref(type_prefix)
      .as_static_derived_type(filter_field_category(for_single_value))
      .name

    params = to_h
      .slice(*@@initialize_param_names)
      .merge(type: filter_type, parent_type: parent_type, name_in_index: name_in_index, type_for_derived_types: nil)

    schema_def_state.factory.new_field(**params).tap do |f|
      f.documentation derived_documentation(
        "Used to filter on the `#{name}` field",
        "Will be ignored if `null` or an empty object is passed"
      )

      filter_customizations.each { |c| c.call(f) }
    end
  end

  # @private
  def define_relay_pagination_arguments!
    argument schema_def_state.schema_elements.first.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `after` argument to forward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the first `n` after the provided
        `after` cursor (or from the start of the `#{name}`, if no `after` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to forward-paginate through the `#{name}`. When provided, the next page after the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.last.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `before` argument to backward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the last `n` before the provided
        `before` cursor (or from the end of the `#{name}`, if no `before` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to backward-paginate through the `#{name}`. When provided, the previous page before the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end
  end

  # Converts this field to an `Indexing::FieldReference`, which contains all the attributes involved
  # in building an `Indexing::Field`. Notably, we cannot actually convert to an `Indexing::Field` at
  # the point this method is called, because the referenced field type may not have been defined
  # yet. We don't need an actual `Indexing::Field` until the very end of the schema definition process,
  # when we are dumping the artifacts. However, we need this at field definition time so that we
  # can correctly detect duplicate indexing field issues when a field is defined. (This is used
  # in `TypeWithSubfields#field`).
  #
  # @private
  def to_indexing_field_reference
    return nil if graphql_only

    Indexing::FieldReference.new(
      name: name,
      name_in_index: name_in_index,
      type: non_nullable_in_json_schema ? type.wrap_non_null : type,
      mapping_options: mapping_options,
      json_schema_options: json_schema_options,
      accuracy_confidence: accuracy_confidence,
      source: source,
      runtime_field_script: runtime_field_script
    )
  end

  # Converts this field to its `IndexingField` form.
  #
  # @private
  def to_indexing_field
    to_indexing_field_reference&.resolve
  end

  # @private
  def resolve_mapping
    to_indexing_field&.mapping
  end

  # Returns the string paths to the list fields that we need to index counts for.
  # We do this to support the ability to filter on the size of a list.
  #
  # @private
  def paths_to_lists_for_count_indexing(has_list_ancestor: false)
    self_path = (has_list_ancestor || type.list?) ? [name_in_index] : []

    nested_paths =
      # Nested fields get indexed as separate hidden documents:
      # https://www.elastic.co/guide/en/elasticsearch/reference/8.8/nested.html
      #
      # Given that, the counts of any `nested` list subfields will go in a `__counts` field on the
      # separate hidden document.
      if !nested? && (object_type = type.fully_unwrapped.as_object_type)
        object_type.indexing_fields_by_name_in_index.values.flat_map do |sub_field|
          sub_field.paths_to_lists_for_count_indexing(has_list_ancestor: has_list_ancestor || type.list?).map do |sub_path|
            "#{name_in_index}#{LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR}#{sub_path}"
          end
        end
      else
        []
      end

    self_path + nested_paths
  end

  # Indicates if this field is a leaf value in the index.  Note that GraphQL leaf values
  # are always leaf values in the index but the inverse is not always true. For example,
  # a `GeoLocation` field is not a leaf in GraphQL (because `GeoLocation` is an object
  # type with subfields) but in the index we use a single `geo_point` mapping type, which
  # is a single unit, so we consider it an index leaf.
  #
  # @private
  def index_leaf?
    type_for_derived_types.fully_unwrapped.leaf? || DATASTORE_PROPERTYLESS_OBJECT_TYPES.include?(mapping_type)
  end

  # @private
  ACCURACY_SCORES = {
    # :high is assigned to `Field`s that are generated directly from GraphQL fields or :extra_fields.
    # For these, we know everything available to us in the schema about them.
    high: 3,

    # :medium is assigned to `Field`s that are inferred from the id fields required by a relation.
    # We make logical guesses about the `indexing_field_type` but if the field is also manually defined,
    # it could be slightly different (e.g. additional json schema validations), so we have medium
    # confidence of these.
    medium: 2,

    # :low is assigned to the ElastcField inferred for the foreign key of an inbound relation. The
    # nullability/cardinality of the foreign key field cannot be known from the relation metadata,
    # so we just guess what seems safest (`[:nullable]`). If the field is defined another way
    # we should prefer it, so we give these fields :low confidence.
    low: 1
  }

  # Given two fields, picks the one that is most accurate. If they have the same accuracy
  # confidence, yields to a block to force it to deal with the discrepancy, unless the fields
  # are exactly equal (in which case we can return either).
  #
  # @private
  def self.pick_most_accurate_from(field1, field2, to_comparable: ->(it) { it })
    return field1 if to_comparable.call(field1) == to_comparable.call(field2)
    yield if field1.accuracy_confidence == field2.accuracy_confidence
    # Array#max_by can return nil (when called on an empty array), but our steep type is non-nil.
    # Since it's not smart enough to realize the non-empty-array-usage of `max_by` won't return nil,
    # we have to cast it to untyped here.
    _ = [field1, field2].max_by { |f| ACCURACY_SCORES.fetch(f.accuracy_confidence) }
  end

  # Indicates if the field uses the `nested` mapping type.
  #
  # @private
  def nested?
    mapping_type == "nested"
  end

  # Records the `ComputationDetail` that should be on the `runtime_metadata_graphql_field`.
  #
  # @private
  def (empty_bucket_value:, function:)
    self.computation_detail = SchemaArtifacts::RuntimeMetadata::ComputationDetail.new(
      empty_bucket_value: empty_bucket_value,
      function: function
    )
  end

  # Lazily creates and returns a GraphQLField using the field's {#name_in_index}, {#computation_detail},
  # and {#relationship}.
  #
  # @private
  def 
    SchemaArtifacts::RuntimeMetadata::GraphQLField.new(
      name_in_index: name_in_index,
      computation_detail: computation_detail,
      relation: relationship&.
    )
  end

  private

  def args_sdl(joiner:, after_opening_paren: "", &arg_selector)
    selected_args = args.values.select(&arg_selector)
    args_sdl = selected_args.map(&:to_sdl).flat_map { |s| s.split("\n") }.join(joiner)
    return nil if args_sdl.empty?
    "(#{after_opening_paren}#{args_sdl})"
  end

  # Indicates if the field uses the `text` mapping type.
  def text?
    mapping_type == "text"
  end

  def define_legacy_timestamp_grouping_arguments_if_needed(grouping_field)
    case type.fully_unwrapped.name
    when "Date"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.offset_days, "Int" do |a|
        a.documentation <<~EOS
          Number of days (positive or negative) to shift the `Date` boundaries of each date grouping bucket.

          For example, when grouping by `YEAR`, this can be used to align the buckets with fiscal or school years instead of calendar years.
        EOS
      end
    when "DateTime"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateTimeGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.time_zone, "TimeZone" do |a|
        a.documentation "The time zone to use when determining which grouping a `DateTime` value falls in."
        a.default "UTC"
      end

      grouping_field.argument schema_def_state.schema_elements.offset, "DateTimeGroupingOffsetInput" do |a|
        a.documentation <<~EOS
          Amount of offset (positive or negative) to shift the `DateTime` boundaries of each grouping bucket.

          For example, when grouping by `WEEK`, you can shift by 24 hours to change what day-of-week weeks are considered to start on.
        EOS
      end
    end
  end

  def list_field_grouped_by_doc_note(individual_value_selection_description)
    <<~EOS.strip
      Note: `#{name}` is a collection field, but selecting this field will group on individual values of #{individual_value_selection_description}.
      That means that a document may be grouped into multiple aggregation groupings (i.e. when its `#{name}`
      field has multiple values) leading to some data duplication in the response. However, if a value shows
      up in `#{name}` multiple times for a single document, that document will only be included in the group
      once
    EOS
  end

  # Determines the suffix of the filter field derived for this field. The suffix used determines
  # the filtering capabilities (e.g. filtering on a single value vs a list of values with `any_satisfy`).
  def filter_field_category(for_single_value)
    return :filter_input if for_single_value

    # For an index leaf field, there are no further nesting paths to traverse. We want to directly
    # use a `ListFilterInput` type (e.g. `IntListFilterInput`) to offer `any_satisfy` filtering at this level.
    return :list_filter_input if index_leaf?

    # If it's a list-of-objects field we require the user to tell us what mapping type they want to
    # use, which determines the suffix (and is handled below). Otherwise, we want to use `FieldsListFilterInput`.
    # We are within a list filtering context (as indicated by `for_single_value` being false) without
    # being at an index leaf field, so we must use `FieldsListFilterInput` as there are further nesting paths
    # on the document and we want to provide `any_satisfy` at the leaf fields.
    return :fields_list_filter_input unless type_for_derived_types.list?

    case mapping_type
    when "nested" then :list_filter_input
    when "object" then :fields_list_filter_input
    else
      raise Errors::SchemaError, <<~EOS
        `#{parent_type.name}.#{name}` is a list-of-objects field, but the mapping type has not been explicitly specified. Elasticsearch and OpenSearch
        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
        any existing indexed data!), and there are nuanced tradeoffs involved here, so ElasticGraph provides no default mapping in this situation.

        If you're currently prototyping and don't want to spend time weighing this tradeoff, we recommend you do this:

        ```
        t.field "#{name}", "#{type.name}" do |f|
          # Here we are opting for flexibility (nested) over pure performance (object).
          # TODO: evaluate if we want to stick with `nested` before going to production.
          f.mapping type: "nested"
        end
        ```

        Read on for details of the tradeoff involved here.

        -----------------------------------------------------------------------------------------------------------------------------

        Here are the options:

        1) `f.mapping type: "object"` will cause each field path to be indexed as a separate "flattened" list.

        For example, given a `Film` document like this:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters": [
            {"first": "Luke", "last": "Skywalker"},
            {"first": "Han", "last": "Solo"}
          ]
        }
        ```

        ...the data will look like this in the inverted Lucene index:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters.first": ["Luke", "Han"],
          "characters.last": ["Skywalker", "Solo"]
        }
        ```

        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.
        ElasticGraph models this in the filtering API it offers for this case:

        ```
        query {
          films(filter: {
            characters: {
              first: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}}
              last: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}}
            }
          }) {
            # ...
          }
        }
        ```

        As suggested by this filtering API, this will match any film that has a character with a first name of "Luke" and a character
        with the last name of "Skywalker", but this could be satisfied by two separate characters.

        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.

        Given a `Film` document like "The Empire Strikes Back" from above, the `nested` type will index separate hidden documents for each character. This
        allows ElasticGraph to offer this filtering API instead:

        ```
        query {
          films(filter: {
            characters: {#{schema_def_state.schema_elements.any_satisfy}: {
              first: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}
              last: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}
            }}
          }) {
            # ...
          }
        }
        ```

        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
        that the `nested` mapping type can lead to performance problems, and index sorting cannot be configured[^3] when the `nested` type is used.

        [^1]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/nested.html
        [^2]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/joining-queries.html
        [^3]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/index-modules-index-sorting.html
      EOS
    end
  end
end

#name_in_indexString (readonly)

Returns the name of this field in the datastore index.

Returns:

  • (String)

    the name of this field in the datastore index



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 89

class Field < Struct.new(
  :name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
  :filter_customizations, :grouped_by_customizations, :sub_aggregations_customizations,
  :aggregated_values_customizations, :sort_order_enum_value_customizations,
  :args, :sortable, :filterable, :aggregatable, :groupable, :graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
  :computation_detail, :non_nullable_in_json_schema, :backing_indexing_field, :as_input,
  :legacy_grouping_schema, :name_in_index
)
  include Mixins::HasDocumentation
  include Mixins::HasDirectives
  include Mixins::HasTypeInfo
  include Mixins::HasReadableToSAndInspect.new { |f| "#{f.parent_type.name}.#{f.name}: #{f.type}" }

  # @private
  def initialize(
    name:, type:, parent_type:, schema_def_state:,
    accuracy_confidence: :high, name_in_index: name,
    runtime_metadata_graphql_field: SchemaArtifacts::RuntimeMetadata::GraphQLField::EMPTY,
    type_for_derived_types: nil, graphql_only: nil, singular: nil,
    sortable: nil, filterable: nil, aggregatable: nil, groupable: nil,
    backing_indexing_field: nil, as_input: false, legacy_grouping_schema: false
  )
    type_ref = schema_def_state.type_ref(type)
    super(
      name: name,
      original_type: type_ref,
      parent_type: parent_type,
      original_type_for_derived_types: type_for_derived_types ? schema_def_state.type_ref(type_for_derived_types) : type_ref,
      schema_def_state: schema_def_state,
      accuracy_confidence: accuracy_confidence,
      filter_customizations: [],
      grouped_by_customizations: [],
      sub_aggregations_customizations: [],
      aggregated_values_customizations: [],
      sort_order_enum_value_customizations: [],
      args: {},
      sortable: sortable,
      filterable: filterable,
      aggregatable: aggregatable,
      groupable: groupable,
      graphql_only: graphql_only,
      source: nil,
      runtime_field_script: nil,
      # Note: we named the keyword argument `singular` (with no `_name` suffix) for consistency with
      # other schema definition APIs, which also use `singular:` instead of `singular_name:`. We include
      # the `_name` suffix on the attribute for clarity.
      singular_name: singular,
      name_in_index: name_in_index,
      non_nullable_in_json_schema: false,
      backing_indexing_field: backing_indexing_field,
      as_input: as_input,
      legacy_grouping_schema: legacy_grouping_schema
    )

    if name != name_in_index && name_in_index&.include?(".") && !graphql_only
      raise Errors::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."
    end

    schema_def_state.register_user_defined_field(self)
    yield self if block_given?
  end

  # @private
  @@initialize_param_names = instance_method(:initialize).parameters.map(&:last).to_set

  # must come after we capture the initialize params.
  prepend Mixins::VerifiesGraphQLName

  # @return [TypeReference] the type of this field
  def type
    # Here we lazily convert the `original_type` to an input type as needed. This must be lazy because
    # the logic of `as_input` depends on detecting whether the type is an enum type, which it may not
    # be able to do right away--we assume not if we can't tell, and retry every time this method is called.
    original_type.to_final_form(as_input: as_input)
  end

  # @return [TypeReference] the type of the corresponding field on derived types (usually this is the same as {#type}).
  #
  # @private
  def type_for_derived_types
    original_type_for_derived_types.to_final_form(as_input: as_input)
  end

  # @note For each field defined in your schema that is filterable, a corresponding filtering field will be created on the
  #   `*FilterInput` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding filtering field that will be generated for this
  # field.
  #
  # @yield [Field] derived filtering field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignFilterInput.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_filter_field do |ff|
  #           ff.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_filter_field(&customization_block)
    filter_customizations << customization_block
  end

  # @note For each field defined in your schema that is aggregatable, a corresponding `aggregatedValues` field will be created on the
  #   `*AggregatedValues` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `aggregatedValues` field that will be generated for
  # this field.
  #
  # @yield [Field] derived aggregated values field
  # @return [void]
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignAggregatedValues.adImpressions` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "adImpressions", "Int" do |f|
  #         f.customize_aggregated_values_field do |avf|
  #           avf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_aggregated_values_field(&customization_block)
    aggregated_values_customizations << customization_block
  end

  # @note For each field defined in your schema that is groupable, a corresponding `groupedBy` field will be created on the
  #   `*AggregationGroupedBy` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `groupedBy` field that will be generated for this
  # field.
  #
  # @yield [Field] derived grouped by field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignGroupedBy.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_grouped_by_field do |gbf|
  #           gbf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_grouped_by_field(&customization_block)
    grouped_by_customizations << customization_block
  end

  # @note For each field defined in your schema that is sub-aggregatable (e.g. list fields indexed using the `nested` mapping type),
  # a corresponding field will be created on the `*AggregationSubAggregations` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `subAggregations` field that will be generated for
  # this field.
  #
  # @yield [Field] derived sub-aggregations field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #on_each_generated_schema_element
  #
  # @example Mark `TransactionAggregationSubAggregations.fees` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "fees", "[Money!]!" do |f|
  #         f.mapping type: "nested"
  #
  #         f.customize_sub_aggregations_field do |saf|
  #           # Adds a `@deprecated` directive to the `PaymentAggregationSubAggregations.fees`
  #           # field without also adding it to the `Payment.fees` field.
  #           saf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #
  #     schema.object_type "Money" do |t|
  #       t.field "amount", "Int"
  #       t.field "currency", "String"
  #     end
  #   end
  def customize_sub_aggregations_field(&customization_block)
    sub_aggregations_customizations << customization_block
  end

  # @note for each sortable field, enum values will be generated on the derived sort order enum type allowing you to
  #   sort by the field `ASC` or `DESC`.
  #
  # Registers a customization callback that will be applied to the corresponding enum values that will be generated for this field
  # on the derived `SortOrder` enum type.
  #
  # @yield [SortOrderEnumValue] derived sort order enum value
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignSortOrder.organizationId_(ASC|DESC)` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_sort_order_enum_values do |soev|
  #           soev.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_sort_order_enum_values(&customization_block)
    sort_order_enum_value_customizations << customization_block
  end

  # When you define a {Field} on an {ObjectType} or {InterfaceType}, ElasticGraph generates up to 6 different GraphQL schema elements
  # for it:
  #
  # * A {Field} is generated on the parent {ObjectType} or {InterfaceType} (that is, this field itself). This is used by clients to
  #   ask for values for the field in a response.
  # * A {Field} may be generated on the `*FilterInput` {InputType} derived from the parent {ObjectType} or {InterfaceType}. This is
  #   used by clients to specify how the query should filter.
  # * A {Field} may be generated on the `*AggregationGroupedBy` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to specify how aggregations should be grouped.
  # * A {Field} may be generated on the `*AggregatedValues` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to apply aggregation functions (e.g. `sum`, `max`, `min`, etc) to a set of field values for a group.
  # * A {Field} may be generated on the `*AggregationSubAggregations` {ObjectType} derived from the parent {ObjectType} or
  #   {InterfaceType}. This is used by clients to perform sub-aggregations on list fields indexed using the `nested` mapping type.
  # * Multiple {EnumValue}s (both `*_ASC` and `*_DESC`) are generated on the `*SortOrder` {EnumType} derived from the parent indexed
  #   {ObjectType}. This is used by clients to sort by a field.
  #
  # This method registers a customization callback which is applied to every element that is generated for this field.
  #
  # @yield [Field, EnumValue] the schema element
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  #
  # @example
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "amount", "Int" do |f|
  #         f.on_each_generated_schema_element do |element|
  #           # Adds a `@deprecated` directive to every GraphQL schema element generated for `amount`:
  #           #
  #           # - The `Transaction.amount` field.
  #           # - The `TransactionFilterInput.amount` field.
  #           # - The `TransactionAggregationGroupedBy.amount` field.
  #           # - The `TransactionAggregatedValues.amount` field.
  #           # - The `TransactionSortOrder.amount_ASC` and`TransactionSortOrder.amount_DESC` enum values.
  #           element.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #   end
  def on_each_generated_schema_element(&customization_block)
    customization_block.call(self)
    customize_filter_field(&customization_block)
    customize_aggregated_values_field(&customization_block)
    customize_grouped_by_field(&customization_block)
    customize_sub_aggregations_field(&customization_block)
    customize_sort_order_enum_values(&customization_block)
  end

  # (see Mixins::HasTypeInfo#json_schema)
  def json_schema(nullable: nil, **options)
    if options.key?(:type)
      raise Errors::SchemaError, "Cannot override JSON schema type of field `#{name}` with `#{options.fetch(:type)}`"
    end

    case nullable
    when true
      raise Errors::SchemaError, "`nullable: true` is not allowed on a field--just declare the GraphQL field as being nullable (no `!` suffix) instead."
    when false
      self.non_nullable_in_json_schema = true
    end

    super(**options)
  end

  # Configures ElasticGraph to source a field’s value from a related object. This can be used to denormalize data at ingestion time to
  # support filtering, grouping, sorting, or aggregating data on a field from a related object.
  #
  # @param relationship [String] name of a relationship defined with {TypeWithSubfields#relates_to_one} using an inbound foreign key
  #   which contains the the field you wish to source values from
  # @param field_path [String] dot-separated path to the field on the related type containing values that should be copied to this
  #   field
  # @return [void]
  #
  # @example Source `City.currency` from `Country.currency`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Country" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.field "currency", "String"
  #       t.relates_to_one "capitalCity", "City", via: "capitalCityId", dir: :out
  #       t.index "countries"
  #     end
  #
  #     schema.object_type "City" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.relates_to_one "capitalOf", "Country", via: "capitalCityId", dir: :in
  #
  #       t.field "currency", "String" do |f|
  #         f.sourced_from "capitalOf", "currency"
  #       end
  #
  #       t.index "cities"
  #     end
  #   end
  def sourced_from(relationship, field_path)
    self.source = schema_def_state.factory.new_field_source(
      relationship_name: relationship,
      field_path: field_path
    )
  end

  # @private
  def runtime_script(script)
    self.runtime_field_script = script
  end

  # Registers an old name that this field used to have in a prior version of the schema.
  #
  # @note In situations where this API applies, ElasticGraph will give you an error message indicating that you need to use this API
  #   or {TypeWithSubfields#deleted_field}. Likewise, when ElasticGraph no longer needs to know about this, it'll give you a warning
  #   indicating the call to this method can be removed.
  #
  # @param old_name [String] old name this field used to have in a prior version of the schema
  # @return [void]
  #
  # @example Indicate that `Widget.description` used to be called `Widget.notes`.
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Widget" do |t|
  #       t.field "description", "String" do |f|
  #         f.renamed_from "notes"
  #       end
  #     end
  #   end
  def renamed_from(old_name)
    schema_def_state.register_renamed_field(
      parent_type.name,
      from: old_name,
      to: name,
      defined_at: caller_locations(1, 1).first, # : ::Thread::Backtrace::Location
      defined_via: %(field.renamed_from "#{old_name}")
    )
  end

  # @private
  def to_sdl(type_structure_only: false, default_value_sdl: nil, &arg_selector)
    if type_structure_only
      "#{name}#{args_sdl(joiner: ", ", &arg_selector)}: #{type.name}"
    else
      args_sdl = args_sdl(joiner: "\n  ", after_opening_paren: "\n  ", &arg_selector)
      "#{formatted_documentation}#{name}#{args_sdl}: #{type.name}#{default_value_sdl} #{directives_sdl}".strip
    end
  end

  # Indicates if this field is sortable. Sortable fields will have corresponding `_ASC` and `_DESC` values generated in the
  # sort order {EnumType} of the parent indexed type.
  #
  # By default, the sortability is inferred by the field type and mapping. For example, list fields are not sortable,
  # and fields mapped as `text` are not sortable either. Fields are sortable in most other cases.
  #
  # The `sortable: true` option can be used to force a field to be sortable.
  #
  # @return [Boolean] true if this field is sortable
  def sortable?
    return sortable unless sortable.nil?

    # List fields are not sortable by default. We'd need to provide the datastore a sort mode option:
    # https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_sort_mode_option
    return false if type.list?

    # Boolean fields are not sortable by default.
    #   - Boolean: sorting all falses before all trues (or whatever) is not generally interesting.
    return false if type.unwrap_non_null.boolean?

    # Elasticsearch/OpenSearch do not support sorting text fields:
    # > Text fields are not used for sorting...
    # (from https://www.elastic.co/guide/en/elasticsearch/reference/current/the datastore.html#text)
    return false if text?

    # 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.
    return false if type.as_object_type&.has_custom_mapping_type?

    # Default every other field to being sortable.
    true
  end

  # Indicates if this field is filterable. Filterable fields will be available in the GraphQL schema under the `filter` argument.
  #
  # Most fields are filterable, except when:
  #
  # - It's a relation. Relation fields require us to load the related data from another index and can't be filtered on.
  # - The field is an object type that isn't itself filterable (e.g. due to having no filterable fields or whatever).
  # - Explicitly disabled with `filterable: false`.
  #
  # @return [Boolean]
  def filterable?
    # Object types that use custom index mappings (as `GeoLocation` does) aren't filterable
    # by default since we can't guess what datastore filtering capabilities they have. We've implemented
    # filtering support for `GeoLocation` fields, though, so we need to explicitly make it fliterable here.
    # TODO: clean this up using an interface instead of checking for `GeoLocation`.
    return true if type.fully_unwrapped.name == "GeoLocation"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:filterable?)
    return true if filterable.nil?
    filterable
  end

  # Indicates if this field is groupable. Groupable fields will be available under `groupedBy` for an aggregations query.
  #
  # Groupability is inferred based on the field type and mapping type, or you can use the `groupable: true` option to force it.
  #
  # @return [Boolean]
  def groupable?
    # If the groupability of the field was specified explicitly when the field was defined, use the specified value.
    return groupable unless groupable.nil?

    # We don't want the `id` field of an indexed type to be available to group by, because it's the unique primary key
    # and the groupings would each contain one document. It's simpler and more efficient to just query the raw documents
    # instead.
    return false if parent_type.indexed? && name == "id"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:groupable?)

    # We don't support grouping an entire list of values, but we do support grouping on individual values in a list.
    # However, we only do so when a `singular_name` has been provided (so that we know what to call the grouped_by field).
    # The semantics are a little odd (since one document can be duplicated in multiple grouping buckets) so we're ok
    # with not offering it by default--the user has to opt-in by telling us what to call the field in its singular form.
    return list_field_groupable_by_single_values? if type.list? && type.fully_unwrapped.leaf?

    # Nested fields will be supported through specific nested aggregation support, and do not
    # work as expected when grouping on the root document type.
    return false if nested?

    # Text fields cannot be efficiently grouped on, so make them non-groupable by default.
    return false if text?

    # In all other cases, default to being groupable.
    true
  end

  # Indicates if this field is aggregatable. Aggregatable fields will be available under `aggregatedValues` for an aggregations query.
  #
  # Aggregatability is inferred based on the field type and mapping type, or you can use the `aggregatable: true` option to force it.
  #
  # @return [Boolean]
  def aggregatable?
    return aggregatable unless aggregatable.nil?
    return false if relationship

    # We don't yet support aggregating over subfields of a `nested` field.
    # TODO: add support for aggregating over subfields of `nested` fields.
    return false if nested?

    # Text fields are not efficiently aggregatable (and you'll often get errors from the datastore if you attempt to aggregate them).
    return false if text?

    type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:aggregatable?) || index_leaf?
  end

  # Indicates if this field can be used as the basis for a sub-aggregation. Sub-aggregatable fields will be available under
  # `subAggregations` for an aggregations query.
  #
  # Only nested fields, and object fields which have nested fields, can be sub-aggregated.
  #
  # @return [Boolean]
  def sub_aggregatable?
    return false if relationship

    nested? || type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:sub_aggregatable?)
  end

  # Defines an argument on the field.
  #
  # @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
  #   this API, and it has no way to interpret arbitrary arguments defined on a field. However, it can be useful for extensions that
  #   extend the {ElasticGraph::GraphQL} query engine. For example, {ElasticGraph::Apollo} uses this API to satisfy the [Apollo
  #   federation subgraph spec](https://www.apollographql.com/docs/federation/federation-spec/).
  #
  # @param name [String] name of the argument
  # @param value_type [String] type of the argument in GraphQL SDL syntax
  # @yield [Argument] for further customization
  #
  # @example Define an argument on a field
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Product" do |t|
  #       t.field "name", "String" do |f|
  #         f.argument "language", "String"
  #       end
  #     end
  #   end
  def argument(name, value_type, &block)
    args[name] = schema_def_state.factory.new_argument(
      self,
      name,
      schema_def_state.type_ref(value_type),
      &block
    )
  end

  # The index mapping type in effect for this field. This could come from either the field definition or from the type definition.
  #
  # @return [String]
  def mapping_type
    backing_indexing_field&.mapping_type || (resolve_mapping || {})["type"]
  end

  # @private
  def list_field_groupable_by_single_values?
    (type.list? || backing_indexing_field&.type&.list?) && !singular_name.nil?
  end

  # @private
  def define_aggregated_values_field(parent_type)
    return unless aggregatable?

    unwrapped_type_for_derived_types = type_for_derived_types.fully_unwrapped
    aggregated_values_type =
      if index_leaf?
        unwrapped_type_for_derived_types.resolved.aggregated_values_type
      else
        unwrapped_type_for_derived_types.as_aggregated_values
      end

    parent_type.field name, aggregated_values_type.name, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Computed aggregate values for the `#{name}` field")
      aggregated_values_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def define_grouped_by_field(parent_type)
    return unless (field_name = grouped_by_field_name)

    parent_type.field field_name, grouped_by_field_type_name, name_in_index: name_in_index, graphql_only: true do |f|
      add_grouped_by_field_documentation(f)

      define_legacy_timestamp_grouping_arguments_if_needed(f) if legacy_grouping_schema

      grouped_by_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def grouped_by_field_type_name
    unwrapped_type = type_for_derived_types.fully_unwrapped
    if unwrapped_type.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      unwrapped_type.with_reverted_override.as_grouped_by.name
    elsif unwrapped_type.leaf?
      unwrapped_type.name
    else
      unwrapped_type.as_grouped_by.name
    end
  end

  # @private
  def add_grouped_by_field_documentation(field)
    text = if list_field_groupable_by_single_values?
      derived_documentation(
        "The individual value from `#{name}` for this group",
        list_field_grouped_by_doc_note("`#{name}`")
      )
    elsif type.list? && type.fully_unwrapped.object?
      derived_documentation(
        "The `#{name}` field value for this group",
        list_field_grouped_by_doc_note("the selected subfields of `#{name}`")
      )
    elsif type_for_derived_types.fully_unwrapped.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      derived_documentation("Offers the different grouping options for the `#{name}` value within this group")
    else
      derived_documentation("The `#{name}` field value for this group")
    end

    field.documentation text
  end

  # @private
  def grouped_by_field_name
    return nil unless groupable?
    list_field_groupable_by_single_values? ? singular_name : name
  end

  # @private
  def define_sub_aggregations_field(parent_type:, type:)
    parent_type.field name, type, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Used to perform a sub-aggregation of `#{name}`")
      sub_aggregations_customizations.each { |c| c.call(f) }

      yield f if block_given?
    end
  end

  # @private
  def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list?)
    type_prefix = text? ? "Text" : type_for_derived_types.fully_unwrapped.name
    filter_type = schema_def_state
      .type_ref(type_prefix)
      .as_static_derived_type(filter_field_category(for_single_value))
      .name

    params = to_h
      .slice(*@@initialize_param_names)
      .merge(type: filter_type, parent_type: parent_type, name_in_index: name_in_index, type_for_derived_types: nil)

    schema_def_state.factory.new_field(**params).tap do |f|
      f.documentation derived_documentation(
        "Used to filter on the `#{name}` field",
        "Will be ignored if `null` or an empty object is passed"
      )

      filter_customizations.each { |c| c.call(f) }
    end
  end

  # @private
  def define_relay_pagination_arguments!
    argument schema_def_state.schema_elements.first.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `after` argument to forward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the first `n` after the provided
        `after` cursor (or from the start of the `#{name}`, if no `after` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to forward-paginate through the `#{name}`. When provided, the next page after the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.last.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `before` argument to backward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the last `n` before the provided
        `before` cursor (or from the end of the `#{name}`, if no `before` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to backward-paginate through the `#{name}`. When provided, the previous page before the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end
  end

  # Converts this field to an `Indexing::FieldReference`, which contains all the attributes involved
  # in building an `Indexing::Field`. Notably, we cannot actually convert to an `Indexing::Field` at
  # the point this method is called, because the referenced field type may not have been defined
  # yet. We don't need an actual `Indexing::Field` until the very end of the schema definition process,
  # when we are dumping the artifacts. However, we need this at field definition time so that we
  # can correctly detect duplicate indexing field issues when a field is defined. (This is used
  # in `TypeWithSubfields#field`).
  #
  # @private
  def to_indexing_field_reference
    return nil if graphql_only

    Indexing::FieldReference.new(
      name: name,
      name_in_index: name_in_index,
      type: non_nullable_in_json_schema ? type.wrap_non_null : type,
      mapping_options: mapping_options,
      json_schema_options: json_schema_options,
      accuracy_confidence: accuracy_confidence,
      source: source,
      runtime_field_script: runtime_field_script
    )
  end

  # Converts this field to its `IndexingField` form.
  #
  # @private
  def to_indexing_field
    to_indexing_field_reference&.resolve
  end

  # @private
  def resolve_mapping
    to_indexing_field&.mapping
  end

  # Returns the string paths to the list fields that we need to index counts for.
  # We do this to support the ability to filter on the size of a list.
  #
  # @private
  def paths_to_lists_for_count_indexing(has_list_ancestor: false)
    self_path = (has_list_ancestor || type.list?) ? [name_in_index] : []

    nested_paths =
      # Nested fields get indexed as separate hidden documents:
      # https://www.elastic.co/guide/en/elasticsearch/reference/8.8/nested.html
      #
      # Given that, the counts of any `nested` list subfields will go in a `__counts` field on the
      # separate hidden document.
      if !nested? && (object_type = type.fully_unwrapped.as_object_type)
        object_type.indexing_fields_by_name_in_index.values.flat_map do |sub_field|
          sub_field.paths_to_lists_for_count_indexing(has_list_ancestor: has_list_ancestor || type.list?).map do |sub_path|
            "#{name_in_index}#{LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR}#{sub_path}"
          end
        end
      else
        []
      end

    self_path + nested_paths
  end

  # Indicates if this field is a leaf value in the index.  Note that GraphQL leaf values
  # are always leaf values in the index but the inverse is not always true. For example,
  # a `GeoLocation` field is not a leaf in GraphQL (because `GeoLocation` is an object
  # type with subfields) but in the index we use a single `geo_point` mapping type, which
  # is a single unit, so we consider it an index leaf.
  #
  # @private
  def index_leaf?
    type_for_derived_types.fully_unwrapped.leaf? || DATASTORE_PROPERTYLESS_OBJECT_TYPES.include?(mapping_type)
  end

  # @private
  ACCURACY_SCORES = {
    # :high is assigned to `Field`s that are generated directly from GraphQL fields or :extra_fields.
    # For these, we know everything available to us in the schema about them.
    high: 3,

    # :medium is assigned to `Field`s that are inferred from the id fields required by a relation.
    # We make logical guesses about the `indexing_field_type` but if the field is also manually defined,
    # it could be slightly different (e.g. additional json schema validations), so we have medium
    # confidence of these.
    medium: 2,

    # :low is assigned to the ElastcField inferred for the foreign key of an inbound relation. The
    # nullability/cardinality of the foreign key field cannot be known from the relation metadata,
    # so we just guess what seems safest (`[:nullable]`). If the field is defined another way
    # we should prefer it, so we give these fields :low confidence.
    low: 1
  }

  # Given two fields, picks the one that is most accurate. If they have the same accuracy
  # confidence, yields to a block to force it to deal with the discrepancy, unless the fields
  # are exactly equal (in which case we can return either).
  #
  # @private
  def self.pick_most_accurate_from(field1, field2, to_comparable: ->(it) { it })
    return field1 if to_comparable.call(field1) == to_comparable.call(field2)
    yield if field1.accuracy_confidence == field2.accuracy_confidence
    # Array#max_by can return nil (when called on an empty array), but our steep type is non-nil.
    # Since it's not smart enough to realize the non-empty-array-usage of `max_by` won't return nil,
    # we have to cast it to untyped here.
    _ = [field1, field2].max_by { |f| ACCURACY_SCORES.fetch(f.accuracy_confidence) }
  end

  # Indicates if the field uses the `nested` mapping type.
  #
  # @private
  def nested?
    mapping_type == "nested"
  end

  # Records the `ComputationDetail` that should be on the `runtime_metadata_graphql_field`.
  #
  # @private
  def (empty_bucket_value:, function:)
    self.computation_detail = SchemaArtifacts::RuntimeMetadata::ComputationDetail.new(
      empty_bucket_value: empty_bucket_value,
      function: function
    )
  end

  # Lazily creates and returns a GraphQLField using the field's {#name_in_index}, {#computation_detail},
  # and {#relationship}.
  #
  # @private
  def 
    SchemaArtifacts::RuntimeMetadata::GraphQLField.new(
      name_in_index: name_in_index,
      computation_detail: computation_detail,
      relation: relationship&.
    )
  end

  private

  def args_sdl(joiner:, after_opening_paren: "", &arg_selector)
    selected_args = args.values.select(&arg_selector)
    args_sdl = selected_args.map(&:to_sdl).flat_map { |s| s.split("\n") }.join(joiner)
    return nil if args_sdl.empty?
    "(#{after_opening_paren}#{args_sdl})"
  end

  # Indicates if the field uses the `text` mapping type.
  def text?
    mapping_type == "text"
  end

  def define_legacy_timestamp_grouping_arguments_if_needed(grouping_field)
    case type.fully_unwrapped.name
    when "Date"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.offset_days, "Int" do |a|
        a.documentation <<~EOS
          Number of days (positive or negative) to shift the `Date` boundaries of each date grouping bucket.

          For example, when grouping by `YEAR`, this can be used to align the buckets with fiscal or school years instead of calendar years.
        EOS
      end
    when "DateTime"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateTimeGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.time_zone, "TimeZone" do |a|
        a.documentation "The time zone to use when determining which grouping a `DateTime` value falls in."
        a.default "UTC"
      end

      grouping_field.argument schema_def_state.schema_elements.offset, "DateTimeGroupingOffsetInput" do |a|
        a.documentation <<~EOS
          Amount of offset (positive or negative) to shift the `DateTime` boundaries of each grouping bucket.

          For example, when grouping by `WEEK`, you can shift by 24 hours to change what day-of-week weeks are considered to start on.
        EOS
      end
    end
  end

  def list_field_grouped_by_doc_note(individual_value_selection_description)
    <<~EOS.strip
      Note: `#{name}` is a collection field, but selecting this field will group on individual values of #{individual_value_selection_description}.
      That means that a document may be grouped into multiple aggregation groupings (i.e. when its `#{name}`
      field has multiple values) leading to some data duplication in the response. However, if a value shows
      up in `#{name}` multiple times for a single document, that document will only be included in the group
      once
    EOS
  end

  # Determines the suffix of the filter field derived for this field. The suffix used determines
  # the filtering capabilities (e.g. filtering on a single value vs a list of values with `any_satisfy`).
  def filter_field_category(for_single_value)
    return :filter_input if for_single_value

    # For an index leaf field, there are no further nesting paths to traverse. We want to directly
    # use a `ListFilterInput` type (e.g. `IntListFilterInput`) to offer `any_satisfy` filtering at this level.
    return :list_filter_input if index_leaf?

    # If it's a list-of-objects field we require the user to tell us what mapping type they want to
    # use, which determines the suffix (and is handled below). Otherwise, we want to use `FieldsListFilterInput`.
    # We are within a list filtering context (as indicated by `for_single_value` being false) without
    # being at an index leaf field, so we must use `FieldsListFilterInput` as there are further nesting paths
    # on the document and we want to provide `any_satisfy` at the leaf fields.
    return :fields_list_filter_input unless type_for_derived_types.list?

    case mapping_type
    when "nested" then :list_filter_input
    when "object" then :fields_list_filter_input
    else
      raise Errors::SchemaError, <<~EOS
        `#{parent_type.name}.#{name}` is a list-of-objects field, but the mapping type has not been explicitly specified. Elasticsearch and OpenSearch
        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
        any existing indexed data!), and there are nuanced tradeoffs involved here, so ElasticGraph provides no default mapping in this situation.

        If you're currently prototyping and don't want to spend time weighing this tradeoff, we recommend you do this:

        ```
        t.field "#{name}", "#{type.name}" do |f|
          # Here we are opting for flexibility (nested) over pure performance (object).
          # TODO: evaluate if we want to stick with `nested` before going to production.
          f.mapping type: "nested"
        end
        ```

        Read on for details of the tradeoff involved here.

        -----------------------------------------------------------------------------------------------------------------------------

        Here are the options:

        1) `f.mapping type: "object"` will cause each field path to be indexed as a separate "flattened" list.

        For example, given a `Film` document like this:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters": [
            {"first": "Luke", "last": "Skywalker"},
            {"first": "Han", "last": "Solo"}
          ]
        }
        ```

        ...the data will look like this in the inverted Lucene index:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters.first": ["Luke", "Han"],
          "characters.last": ["Skywalker", "Solo"]
        }
        ```

        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.
        ElasticGraph models this in the filtering API it offers for this case:

        ```
        query {
          films(filter: {
            characters: {
              first: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}}
              last: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}}
            }
          }) {
            # ...
          }
        }
        ```

        As suggested by this filtering API, this will match any film that has a character with a first name of "Luke" and a character
        with the last name of "Skywalker", but this could be satisfied by two separate characters.

        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.

        Given a `Film` document like "The Empire Strikes Back" from above, the `nested` type will index separate hidden documents for each character. This
        allows ElasticGraph to offer this filtering API instead:

        ```
        query {
          films(filter: {
            characters: {#{schema_def_state.schema_elements.any_satisfy}: {
              first: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}
              last: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}
            }}
          }) {
            # ...
          }
        }
        ```

        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
        that the `nested` mapping type can lead to performance problems, and index sorting cannot be configured[^3] when the `nested` type is used.

        [^1]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/nested.html
        [^2]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/joining-queries.html
        [^3]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/index-modules-index-sorting.html
      EOS
    end
  end
end

#schema_def_stateState (readonly)

Returns schema definition state.

Returns:

  • (State)

    schema definition state



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 89

class Field < Struct.new(
  :name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
  :filter_customizations, :grouped_by_customizations, :sub_aggregations_customizations,
  :aggregated_values_customizations, :sort_order_enum_value_customizations,
  :args, :sortable, :filterable, :aggregatable, :groupable, :graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
  :computation_detail, :non_nullable_in_json_schema, :backing_indexing_field, :as_input,
  :legacy_grouping_schema, :name_in_index
)
  include Mixins::HasDocumentation
  include Mixins::HasDirectives
  include Mixins::HasTypeInfo
  include Mixins::HasReadableToSAndInspect.new { |f| "#{f.parent_type.name}.#{f.name}: #{f.type}" }

  # @private
  def initialize(
    name:, type:, parent_type:, schema_def_state:,
    accuracy_confidence: :high, name_in_index: name,
    runtime_metadata_graphql_field: SchemaArtifacts::RuntimeMetadata::GraphQLField::EMPTY,
    type_for_derived_types: nil, graphql_only: nil, singular: nil,
    sortable: nil, filterable: nil, aggregatable: nil, groupable: nil,
    backing_indexing_field: nil, as_input: false, legacy_grouping_schema: false
  )
    type_ref = schema_def_state.type_ref(type)
    super(
      name: name,
      original_type: type_ref,
      parent_type: parent_type,
      original_type_for_derived_types: type_for_derived_types ? schema_def_state.type_ref(type_for_derived_types) : type_ref,
      schema_def_state: schema_def_state,
      accuracy_confidence: accuracy_confidence,
      filter_customizations: [],
      grouped_by_customizations: [],
      sub_aggregations_customizations: [],
      aggregated_values_customizations: [],
      sort_order_enum_value_customizations: [],
      args: {},
      sortable: sortable,
      filterable: filterable,
      aggregatable: aggregatable,
      groupable: groupable,
      graphql_only: graphql_only,
      source: nil,
      runtime_field_script: nil,
      # Note: we named the keyword argument `singular` (with no `_name` suffix) for consistency with
      # other schema definition APIs, which also use `singular:` instead of `singular_name:`. We include
      # the `_name` suffix on the attribute for clarity.
      singular_name: singular,
      name_in_index: name_in_index,
      non_nullable_in_json_schema: false,
      backing_indexing_field: backing_indexing_field,
      as_input: as_input,
      legacy_grouping_schema: legacy_grouping_schema
    )

    if name != name_in_index && name_in_index&.include?(".") && !graphql_only
      raise Errors::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."
    end

    schema_def_state.register_user_defined_field(self)
    yield self if block_given?
  end

  # @private
  @@initialize_param_names = instance_method(:initialize).parameters.map(&:last).to_set

  # must come after we capture the initialize params.
  prepend Mixins::VerifiesGraphQLName

  # @return [TypeReference] the type of this field
  def type
    # Here we lazily convert the `original_type` to an input type as needed. This must be lazy because
    # the logic of `as_input` depends on detecting whether the type is an enum type, which it may not
    # be able to do right away--we assume not if we can't tell, and retry every time this method is called.
    original_type.to_final_form(as_input: as_input)
  end

  # @return [TypeReference] the type of the corresponding field on derived types (usually this is the same as {#type}).
  #
  # @private
  def type_for_derived_types
    original_type_for_derived_types.to_final_form(as_input: as_input)
  end

  # @note For each field defined in your schema that is filterable, a corresponding filtering field will be created on the
  #   `*FilterInput` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding filtering field that will be generated for this
  # field.
  #
  # @yield [Field] derived filtering field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignFilterInput.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_filter_field do |ff|
  #           ff.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_filter_field(&customization_block)
    filter_customizations << customization_block
  end

  # @note For each field defined in your schema that is aggregatable, a corresponding `aggregatedValues` field will be created on the
  #   `*AggregatedValues` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `aggregatedValues` field that will be generated for
  # this field.
  #
  # @yield [Field] derived aggregated values field
  # @return [void]
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignAggregatedValues.adImpressions` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "adImpressions", "Int" do |f|
  #         f.customize_aggregated_values_field do |avf|
  #           avf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_aggregated_values_field(&customization_block)
    aggregated_values_customizations << customization_block
  end

  # @note For each field defined in your schema that is groupable, a corresponding `groupedBy` field will be created on the
  #   `*AggregationGroupedBy` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `groupedBy` field that will be generated for this
  # field.
  #
  # @yield [Field] derived grouped by field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignGroupedBy.organizationId` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_grouped_by_field do |gbf|
  #           gbf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_grouped_by_field(&customization_block)
    grouped_by_customizations << customization_block
  end

  # @note For each field defined in your schema that is sub-aggregatable (e.g. list fields indexed using the `nested` mapping type),
  # a corresponding field will be created on the `*AggregationSubAggregations` type derived from the parent object type.
  #
  # Registers a customization callback that will be applied to the corresponding `subAggregations` field that will be generated for
  # this field.
  #
  # @yield [Field] derived sub-aggregations field
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #on_each_generated_schema_element
  #
  # @example Mark `TransactionAggregationSubAggregations.fees` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "fees", "[Money!]!" do |f|
  #         f.mapping type: "nested"
  #
  #         f.customize_sub_aggregations_field do |saf|
  #           # Adds a `@deprecated` directive to the `PaymentAggregationSubAggregations.fees`
  #           # field without also adding it to the `Payment.fees` field.
  #           saf.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #
  #     schema.object_type "Money" do |t|
  #       t.field "amount", "Int"
  #       t.field "currency", "String"
  #     end
  #   end
  def customize_sub_aggregations_field(&customization_block)
    sub_aggregations_customizations << customization_block
  end

  # @note for each sortable field, enum values will be generated on the derived sort order enum type allowing you to
  #   sort by the field `ASC` or `DESC`.
  #
  # Registers a customization callback that will be applied to the corresponding enum values that will be generated for this field
  # on the derived `SortOrder` enum type.
  #
  # @yield [SortOrderEnumValue] derived sort order enum value
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sub_aggregations_field
  # @see #on_each_generated_schema_element
  #
  # @example Mark `CampaignSortOrder.organizationId_(ASC|DESC)` with `@deprecated`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Campaign" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "organizationId", "ID" do |f|
  #         f.customize_sort_order_enum_values do |soev|
  #           soev.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "campaigns"
  #     end
  #   end
  def customize_sort_order_enum_values(&customization_block)
    sort_order_enum_value_customizations << customization_block
  end

  # When you define a {Field} on an {ObjectType} or {InterfaceType}, ElasticGraph generates up to 6 different GraphQL schema elements
  # for it:
  #
  # * A {Field} is generated on the parent {ObjectType} or {InterfaceType} (that is, this field itself). This is used by clients to
  #   ask for values for the field in a response.
  # * A {Field} may be generated on the `*FilterInput` {InputType} derived from the parent {ObjectType} or {InterfaceType}. This is
  #   used by clients to specify how the query should filter.
  # * A {Field} may be generated on the `*AggregationGroupedBy` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to specify how aggregations should be grouped.
  # * A {Field} may be generated on the `*AggregatedValues` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}.
  #   This is used by clients to apply aggregation functions (e.g. `sum`, `max`, `min`, etc) to a set of field values for a group.
  # * A {Field} may be generated on the `*AggregationSubAggregations` {ObjectType} derived from the parent {ObjectType} or
  #   {InterfaceType}. This is used by clients to perform sub-aggregations on list fields indexed using the `nested` mapping type.
  # * Multiple {EnumValue}s (both `*_ASC` and `*_DESC`) are generated on the `*SortOrder` {EnumType} derived from the parent indexed
  #   {ObjectType}. This is used by clients to sort by a field.
  #
  # This method registers a customization callback which is applied to every element that is generated for this field.
  #
  # @yield [Field, EnumValue] the schema element
  # @return [void]
  # @see #customize_aggregated_values_field
  # @see #customize_filter_field
  # @see #customize_grouped_by_field
  # @see #customize_sort_order_enum_values
  # @see #customize_sub_aggregations_field
  #
  # @example
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Transaction" do |t|
  #       t.field "id", "ID"
  #
  #       t.field "amount", "Int" do |f|
  #         f.on_each_generated_schema_element do |element|
  #           # Adds a `@deprecated` directive to every GraphQL schema element generated for `amount`:
  #           #
  #           # - The `Transaction.amount` field.
  #           # - The `TransactionFilterInput.amount` field.
  #           # - The `TransactionAggregationGroupedBy.amount` field.
  #           # - The `TransactionAggregatedValues.amount` field.
  #           # - The `TransactionSortOrder.amount_ASC` and`TransactionSortOrder.amount_DESC` enum values.
  #           element.directive "deprecated"
  #         end
  #       end
  #
  #       t.index "transactions"
  #     end
  #   end
  def on_each_generated_schema_element(&customization_block)
    customization_block.call(self)
    customize_filter_field(&customization_block)
    customize_aggregated_values_field(&customization_block)
    customize_grouped_by_field(&customization_block)
    customize_sub_aggregations_field(&customization_block)
    customize_sort_order_enum_values(&customization_block)
  end

  # (see Mixins::HasTypeInfo#json_schema)
  def json_schema(nullable: nil, **options)
    if options.key?(:type)
      raise Errors::SchemaError, "Cannot override JSON schema type of field `#{name}` with `#{options.fetch(:type)}`"
    end

    case nullable
    when true
      raise Errors::SchemaError, "`nullable: true` is not allowed on a field--just declare the GraphQL field as being nullable (no `!` suffix) instead."
    when false
      self.non_nullable_in_json_schema = true
    end

    super(**options)
  end

  # Configures ElasticGraph to source a field’s value from a related object. This can be used to denormalize data at ingestion time to
  # support filtering, grouping, sorting, or aggregating data on a field from a related object.
  #
  # @param relationship [String] name of a relationship defined with {TypeWithSubfields#relates_to_one} using an inbound foreign key
  #   which contains the the field you wish to source values from
  # @param field_path [String] dot-separated path to the field on the related type containing values that should be copied to this
  #   field
  # @return [void]
  #
  # @example Source `City.currency` from `Country.currency`
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Country" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.field "currency", "String"
  #       t.relates_to_one "capitalCity", "City", via: "capitalCityId", dir: :out
  #       t.index "countries"
  #     end
  #
  #     schema.object_type "City" do |t|
  #       t.field "id", "ID"
  #       t.field "name", "String"
  #       t.relates_to_one "capitalOf", "Country", via: "capitalCityId", dir: :in
  #
  #       t.field "currency", "String" do |f|
  #         f.sourced_from "capitalOf", "currency"
  #       end
  #
  #       t.index "cities"
  #     end
  #   end
  def sourced_from(relationship, field_path)
    self.source = schema_def_state.factory.new_field_source(
      relationship_name: relationship,
      field_path: field_path
    )
  end

  # @private
  def runtime_script(script)
    self.runtime_field_script = script
  end

  # Registers an old name that this field used to have in a prior version of the schema.
  #
  # @note In situations where this API applies, ElasticGraph will give you an error message indicating that you need to use this API
  #   or {TypeWithSubfields#deleted_field}. Likewise, when ElasticGraph no longer needs to know about this, it'll give you a warning
  #   indicating the call to this method can be removed.
  #
  # @param old_name [String] old name this field used to have in a prior version of the schema
  # @return [void]
  #
  # @example Indicate that `Widget.description` used to be called `Widget.notes`.
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Widget" do |t|
  #       t.field "description", "String" do |f|
  #         f.renamed_from "notes"
  #       end
  #     end
  #   end
  def renamed_from(old_name)
    schema_def_state.register_renamed_field(
      parent_type.name,
      from: old_name,
      to: name,
      defined_at: caller_locations(1, 1).first, # : ::Thread::Backtrace::Location
      defined_via: %(field.renamed_from "#{old_name}")
    )
  end

  # @private
  def to_sdl(type_structure_only: false, default_value_sdl: nil, &arg_selector)
    if type_structure_only
      "#{name}#{args_sdl(joiner: ", ", &arg_selector)}: #{type.name}"
    else
      args_sdl = args_sdl(joiner: "\n  ", after_opening_paren: "\n  ", &arg_selector)
      "#{formatted_documentation}#{name}#{args_sdl}: #{type.name}#{default_value_sdl} #{directives_sdl}".strip
    end
  end

  # Indicates if this field is sortable. Sortable fields will have corresponding `_ASC` and `_DESC` values generated in the
  # sort order {EnumType} of the parent indexed type.
  #
  # By default, the sortability is inferred by the field type and mapping. For example, list fields are not sortable,
  # and fields mapped as `text` are not sortable either. Fields are sortable in most other cases.
  #
  # The `sortable: true` option can be used to force a field to be sortable.
  #
  # @return [Boolean] true if this field is sortable
  def sortable?
    return sortable unless sortable.nil?

    # List fields are not sortable by default. We'd need to provide the datastore a sort mode option:
    # https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_sort_mode_option
    return false if type.list?

    # Boolean fields are not sortable by default.
    #   - Boolean: sorting all falses before all trues (or whatever) is not generally interesting.
    return false if type.unwrap_non_null.boolean?

    # Elasticsearch/OpenSearch do not support sorting text fields:
    # > Text fields are not used for sorting...
    # (from https://www.elastic.co/guide/en/elasticsearch/reference/current/the datastore.html#text)
    return false if text?

    # 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.
    return false if type.as_object_type&.has_custom_mapping_type?

    # Default every other field to being sortable.
    true
  end

  # Indicates if this field is filterable. Filterable fields will be available in the GraphQL schema under the `filter` argument.
  #
  # Most fields are filterable, except when:
  #
  # - It's a relation. Relation fields require us to load the related data from another index and can't be filtered on.
  # - The field is an object type that isn't itself filterable (e.g. due to having no filterable fields or whatever).
  # - Explicitly disabled with `filterable: false`.
  #
  # @return [Boolean]
  def filterable?
    # Object types that use custom index mappings (as `GeoLocation` does) aren't filterable
    # by default since we can't guess what datastore filtering capabilities they have. We've implemented
    # filtering support for `GeoLocation` fields, though, so we need to explicitly make it fliterable here.
    # TODO: clean this up using an interface instead of checking for `GeoLocation`.
    return true if type.fully_unwrapped.name == "GeoLocation"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:filterable?)
    return true if filterable.nil?
    filterable
  end

  # Indicates if this field is groupable. Groupable fields will be available under `groupedBy` for an aggregations query.
  #
  # Groupability is inferred based on the field type and mapping type, or you can use the `groupable: true` option to force it.
  #
  # @return [Boolean]
  def groupable?
    # If the groupability of the field was specified explicitly when the field was defined, use the specified value.
    return groupable unless groupable.nil?

    # We don't want the `id` field of an indexed type to be available to group by, because it's the unique primary key
    # and the groupings would each contain one document. It's simpler and more efficient to just query the raw documents
    # instead.
    return false if parent_type.indexed? && name == "id"

    return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:groupable?)

    # We don't support grouping an entire list of values, but we do support grouping on individual values in a list.
    # However, we only do so when a `singular_name` has been provided (so that we know what to call the grouped_by field).
    # The semantics are a little odd (since one document can be duplicated in multiple grouping buckets) so we're ok
    # with not offering it by default--the user has to opt-in by telling us what to call the field in its singular form.
    return list_field_groupable_by_single_values? if type.list? && type.fully_unwrapped.leaf?

    # Nested fields will be supported through specific nested aggregation support, and do not
    # work as expected when grouping on the root document type.
    return false if nested?

    # Text fields cannot be efficiently grouped on, so make them non-groupable by default.
    return false if text?

    # In all other cases, default to being groupable.
    true
  end

  # Indicates if this field is aggregatable. Aggregatable fields will be available under `aggregatedValues` for an aggregations query.
  #
  # Aggregatability is inferred based on the field type and mapping type, or you can use the `aggregatable: true` option to force it.
  #
  # @return [Boolean]
  def aggregatable?
    return aggregatable unless aggregatable.nil?
    return false if relationship

    # We don't yet support aggregating over subfields of a `nested` field.
    # TODO: add support for aggregating over subfields of `nested` fields.
    return false if nested?

    # Text fields are not efficiently aggregatable (and you'll often get errors from the datastore if you attempt to aggregate them).
    return false if text?

    type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:aggregatable?) || index_leaf?
  end

  # Indicates if this field can be used as the basis for a sub-aggregation. Sub-aggregatable fields will be available under
  # `subAggregations` for an aggregations query.
  #
  # Only nested fields, and object fields which have nested fields, can be sub-aggregated.
  #
  # @return [Boolean]
  def sub_aggregatable?
    return false if relationship

    nested? || type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:sub_aggregatable?)
  end

  # Defines an argument on the field.
  #
  # @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
  #   this API, and it has no way to interpret arbitrary arguments defined on a field. However, it can be useful for extensions that
  #   extend the {ElasticGraph::GraphQL} query engine. For example, {ElasticGraph::Apollo} uses this API to satisfy the [Apollo
  #   federation subgraph spec](https://www.apollographql.com/docs/federation/federation-spec/).
  #
  # @param name [String] name of the argument
  # @param value_type [String] type of the argument in GraphQL SDL syntax
  # @yield [Argument] for further customization
  #
  # @example Define an argument on a field
  #   ElasticGraph.define_schema do |schema|
  #     schema.object_type "Product" do |t|
  #       t.field "name", "String" do |f|
  #         f.argument "language", "String"
  #       end
  #     end
  #   end
  def argument(name, value_type, &block)
    args[name] = schema_def_state.factory.new_argument(
      self,
      name,
      schema_def_state.type_ref(value_type),
      &block
    )
  end

  # The index mapping type in effect for this field. This could come from either the field definition or from the type definition.
  #
  # @return [String]
  def mapping_type
    backing_indexing_field&.mapping_type || (resolve_mapping || {})["type"]
  end

  # @private
  def list_field_groupable_by_single_values?
    (type.list? || backing_indexing_field&.type&.list?) && !singular_name.nil?
  end

  # @private
  def define_aggregated_values_field(parent_type)
    return unless aggregatable?

    unwrapped_type_for_derived_types = type_for_derived_types.fully_unwrapped
    aggregated_values_type =
      if index_leaf?
        unwrapped_type_for_derived_types.resolved.aggregated_values_type
      else
        unwrapped_type_for_derived_types.as_aggregated_values
      end

    parent_type.field name, aggregated_values_type.name, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Computed aggregate values for the `#{name}` field")
      aggregated_values_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def define_grouped_by_field(parent_type)
    return unless (field_name = grouped_by_field_name)

    parent_type.field field_name, grouped_by_field_type_name, name_in_index: name_in_index, graphql_only: true do |f|
      add_grouped_by_field_documentation(f)

      define_legacy_timestamp_grouping_arguments_if_needed(f) if legacy_grouping_schema

      grouped_by_customizations.each { |block| block.call(f) }
    end
  end

  # @private
  def grouped_by_field_type_name
    unwrapped_type = type_for_derived_types.fully_unwrapped
    if unwrapped_type.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      unwrapped_type.with_reverted_override.as_grouped_by.name
    elsif unwrapped_type.leaf?
      unwrapped_type.name
    else
      unwrapped_type.as_grouped_by.name
    end
  end

  # @private
  def add_grouped_by_field_documentation(field)
    text = if list_field_groupable_by_single_values?
      derived_documentation(
        "The individual value from `#{name}` for this group",
        list_field_grouped_by_doc_note("`#{name}`")
      )
    elsif type.list? && type.fully_unwrapped.object?
      derived_documentation(
        "The `#{name}` field value for this group",
        list_field_grouped_by_doc_note("the selected subfields of `#{name}`")
      )
    elsif type_for_derived_types.fully_unwrapped.scalar_type_needing_grouped_by_object? && !legacy_grouping_schema
      derived_documentation("Offers the different grouping options for the `#{name}` value within this group")
    else
      derived_documentation("The `#{name}` field value for this group")
    end

    field.documentation text
  end

  # @private
  def grouped_by_field_name
    return nil unless groupable?
    list_field_groupable_by_single_values? ? singular_name : name
  end

  # @private
  def define_sub_aggregations_field(parent_type:, type:)
    parent_type.field name, type, name_in_index: name_in_index, graphql_only: true do |f|
      f.documentation derived_documentation("Used to perform a sub-aggregation of `#{name}`")
      sub_aggregations_customizations.each { |c| c.call(f) }

      yield f if block_given?
    end
  end

  # @private
  def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list?)
    type_prefix = text? ? "Text" : type_for_derived_types.fully_unwrapped.name
    filter_type = schema_def_state
      .type_ref(type_prefix)
      .as_static_derived_type(filter_field_category(for_single_value))
      .name

    params = to_h
      .slice(*@@initialize_param_names)
      .merge(type: filter_type, parent_type: parent_type, name_in_index: name_in_index, type_for_derived_types: nil)

    schema_def_state.factory.new_field(**params).tap do |f|
      f.documentation derived_documentation(
        "Used to filter on the `#{name}` field",
        "Will be ignored if `null` or an empty object is passed"
      )

      filter_customizations.each { |c| c.call(f) }
    end
  end

  # @private
  def define_relay_pagination_arguments!
    argument schema_def_state.schema_elements.first.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `after` argument to forward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the first `n` after the provided
        `after` cursor (or from the start of the `#{name}`, if no `after` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to forward-paginate through the `#{name}`. When provided, the next page after the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.last.to_sym, "Int" do |a|
      a.documentation <<~EOS
        Used in conjunction with the `before` argument to backward-paginate through the `#{name}`.
        When provided, limits the number of returned results to the last `n` before the provided
        `before` cursor (or from the end of the `#{name}`, if no `before` cursor is provided).

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end

    argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a|
      a.documentation <<~EOS
        Used to backward-paginate through the `#{name}`. When provided, the previous page before the
        provided cursor will be returned.

        See the [Relay GraphQL Cursor Connections
        Specification](https://relay.dev/graphql/connections.htm#sec-Arguments) for more info.
      EOS
    end
  end

  # Converts this field to an `Indexing::FieldReference`, which contains all the attributes involved
  # in building an `Indexing::Field`. Notably, we cannot actually convert to an `Indexing::Field` at
  # the point this method is called, because the referenced field type may not have been defined
  # yet. We don't need an actual `Indexing::Field` until the very end of the schema definition process,
  # when we are dumping the artifacts. However, we need this at field definition time so that we
  # can correctly detect duplicate indexing field issues when a field is defined. (This is used
  # in `TypeWithSubfields#field`).
  #
  # @private
  def to_indexing_field_reference
    return nil if graphql_only

    Indexing::FieldReference.new(
      name: name,
      name_in_index: name_in_index,
      type: non_nullable_in_json_schema ? type.wrap_non_null : type,
      mapping_options: mapping_options,
      json_schema_options: json_schema_options,
      accuracy_confidence: accuracy_confidence,
      source: source,
      runtime_field_script: runtime_field_script
    )
  end

  # Converts this field to its `IndexingField` form.
  #
  # @private
  def to_indexing_field
    to_indexing_field_reference&.resolve
  end

  # @private
  def resolve_mapping
    to_indexing_field&.mapping
  end

  # Returns the string paths to the list fields that we need to index counts for.
  # We do this to support the ability to filter on the size of a list.
  #
  # @private
  def paths_to_lists_for_count_indexing(has_list_ancestor: false)
    self_path = (has_list_ancestor || type.list?) ? [name_in_index] : []

    nested_paths =
      # Nested fields get indexed as separate hidden documents:
      # https://www.elastic.co/guide/en/elasticsearch/reference/8.8/nested.html
      #
      # Given that, the counts of any `nested` list subfields will go in a `__counts` field on the
      # separate hidden document.
      if !nested? && (object_type = type.fully_unwrapped.as_object_type)
        object_type.indexing_fields_by_name_in_index.values.flat_map do |sub_field|
          sub_field.paths_to_lists_for_count_indexing(has_list_ancestor: has_list_ancestor || type.list?).map do |sub_path|
            "#{name_in_index}#{LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR}#{sub_path}"
          end
        end
      else
        []
      end

    self_path + nested_paths
  end

  # Indicates if this field is a leaf value in the index.  Note that GraphQL leaf values
  # are always leaf values in the index but the inverse is not always true. For example,
  # a `GeoLocation` field is not a leaf in GraphQL (because `GeoLocation` is an object
  # type with subfields) but in the index we use a single `geo_point` mapping type, which
  # is a single unit, so we consider it an index leaf.
  #
  # @private
  def index_leaf?
    type_for_derived_types.fully_unwrapped.leaf? || DATASTORE_PROPERTYLESS_OBJECT_TYPES.include?(mapping_type)
  end

  # @private
  ACCURACY_SCORES = {
    # :high is assigned to `Field`s that are generated directly from GraphQL fields or :extra_fields.
    # For these, we know everything available to us in the schema about them.
    high: 3,

    # :medium is assigned to `Field`s that are inferred from the id fields required by a relation.
    # We make logical guesses about the `indexing_field_type` but if the field is also manually defined,
    # it could be slightly different (e.g. additional json schema validations), so we have medium
    # confidence of these.
    medium: 2,

    # :low is assigned to the ElastcField inferred for the foreign key of an inbound relation. The
    # nullability/cardinality of the foreign key field cannot be known from the relation metadata,
    # so we just guess what seems safest (`[:nullable]`). If the field is defined another way
    # we should prefer it, so we give these fields :low confidence.
    low: 1
  }

  # Given two fields, picks the one that is most accurate. If they have the same accuracy
  # confidence, yields to a block to force it to deal with the discrepancy, unless the fields
  # are exactly equal (in which case we can return either).
  #
  # @private
  def self.pick_most_accurate_from(field1, field2, to_comparable: ->(it) { it })
    return field1 if to_comparable.call(field1) == to_comparable.call(field2)
    yield if field1.accuracy_confidence == field2.accuracy_confidence
    # Array#max_by can return nil (when called on an empty array), but our steep type is non-nil.
    # Since it's not smart enough to realize the non-empty-array-usage of `max_by` won't return nil,
    # we have to cast it to untyped here.
    _ = [field1, field2].max_by { |f| ACCURACY_SCORES.fetch(f.accuracy_confidence) }
  end

  # Indicates if the field uses the `nested` mapping type.
  #
  # @private
  def nested?
    mapping_type == "nested"
  end

  # Records the `ComputationDetail` that should be on the `runtime_metadata_graphql_field`.
  #
  # @private
  def (empty_bucket_value:, function:)
    self.computation_detail = SchemaArtifacts::RuntimeMetadata::ComputationDetail.new(
      empty_bucket_value: empty_bucket_value,
      function: function
    )
  end

  # Lazily creates and returns a GraphQLField using the field's {#name_in_index}, {#computation_detail},
  # and {#relationship}.
  #
  # @private
  def 
    SchemaArtifacts::RuntimeMetadata::GraphQLField.new(
      name_in_index: name_in_index,
      computation_detail: computation_detail,
      relation: relationship&.
    )
  end

  private

  def args_sdl(joiner:, after_opening_paren: "", &arg_selector)
    selected_args = args.values.select(&arg_selector)
    args_sdl = selected_args.map(&:to_sdl).flat_map { |s| s.split("\n") }.join(joiner)
    return nil if args_sdl.empty?
    "(#{after_opening_paren}#{args_sdl})"
  end

  # Indicates if the field uses the `text` mapping type.
  def text?
    mapping_type == "text"
  end

  def define_legacy_timestamp_grouping_arguments_if_needed(grouping_field)
    case type.fully_unwrapped.name
    when "Date"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.offset_days, "Int" do |a|
        a.documentation <<~EOS
          Number of days (positive or negative) to shift the `Date` boundaries of each date grouping bucket.

          For example, when grouping by `YEAR`, this can be used to align the buckets with fiscal or school years instead of calendar years.
        EOS
      end
    when "DateTime"
      grouping_field.argument schema_def_state.schema_elements.granularity, "DateTimeGroupingGranularity!" do |a|
        a.documentation "Determines the grouping granularity for this field."
      end

      grouping_field.argument schema_def_state.schema_elements.time_zone, "TimeZone" do |a|
        a.documentation "The time zone to use when determining which grouping a `DateTime` value falls in."
        a.default "UTC"
      end

      grouping_field.argument schema_def_state.schema_elements.offset, "DateTimeGroupingOffsetInput" do |a|
        a.documentation <<~EOS
          Amount of offset (positive or negative) to shift the `DateTime` boundaries of each grouping bucket.

          For example, when grouping by `WEEK`, you can shift by 24 hours to change what day-of-week weeks are considered to start on.
        EOS
      end
    end
  end

  def list_field_grouped_by_doc_note(individual_value_selection_description)
    <<~EOS.strip
      Note: `#{name}` is a collection field, but selecting this field will group on individual values of #{individual_value_selection_description}.
      That means that a document may be grouped into multiple aggregation groupings (i.e. when its `#{name}`
      field has multiple values) leading to some data duplication in the response. However, if a value shows
      up in `#{name}` multiple times for a single document, that document will only be included in the group
      once
    EOS
  end

  # Determines the suffix of the filter field derived for this field. The suffix used determines
  # the filtering capabilities (e.g. filtering on a single value vs a list of values with `any_satisfy`).
  def filter_field_category(for_single_value)
    return :filter_input if for_single_value

    # For an index leaf field, there are no further nesting paths to traverse. We want to directly
    # use a `ListFilterInput` type (e.g. `IntListFilterInput`) to offer `any_satisfy` filtering at this level.
    return :list_filter_input if index_leaf?

    # If it's a list-of-objects field we require the user to tell us what mapping type they want to
    # use, which determines the suffix (and is handled below). Otherwise, we want to use `FieldsListFilterInput`.
    # We are within a list filtering context (as indicated by `for_single_value` being false) without
    # being at an index leaf field, so we must use `FieldsListFilterInput` as there are further nesting paths
    # on the document and we want to provide `any_satisfy` at the leaf fields.
    return :fields_list_filter_input unless type_for_derived_types.list?

    case mapping_type
    when "nested" then :list_filter_input
    when "object" then :fields_list_filter_input
    else
      raise Errors::SchemaError, <<~EOS
        `#{parent_type.name}.#{name}` is a list-of-objects field, but the mapping type has not been explicitly specified. Elasticsearch and OpenSearch
        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
        any existing indexed data!), and there are nuanced tradeoffs involved here, so ElasticGraph provides no default mapping in this situation.

        If you're currently prototyping and don't want to spend time weighing this tradeoff, we recommend you do this:

        ```
        t.field "#{name}", "#{type.name}" do |f|
          # Here we are opting for flexibility (nested) over pure performance (object).
          # TODO: evaluate if we want to stick with `nested` before going to production.
          f.mapping type: "nested"
        end
        ```

        Read on for details of the tradeoff involved here.

        -----------------------------------------------------------------------------------------------------------------------------

        Here are the options:

        1) `f.mapping type: "object"` will cause each field path to be indexed as a separate "flattened" list.

        For example, given a `Film` document like this:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters": [
            {"first": "Luke", "last": "Skywalker"},
            {"first": "Han", "last": "Solo"}
          ]
        }
        ```

        ...the data will look like this in the inverted Lucene index:

        ```
        {
          "name": "The Empire Strikes Back",
          "characters.first": ["Luke", "Han"],
          "characters.last": ["Skywalker", "Solo"]
        }
        ```

        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.
        ElasticGraph models this in the filtering API it offers for this case:

        ```
        query {
          films(filter: {
            characters: {
              first: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}}
              last: {#{schema_def_state.schema_elements.any_satisfy}: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}}
            }
          }) {
            # ...
          }
        }
        ```

        As suggested by this filtering API, this will match any film that has a character with a first name of "Luke" and a character
        with the last name of "Skywalker", but this could be satisfied by two separate characters.

        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.

        Given a `Film` document like "The Empire Strikes Back" from above, the `nested` type will index separate hidden documents for each character. This
        allows ElasticGraph to offer this filtering API instead:

        ```
        query {
          films(filter: {
            characters: {#{schema_def_state.schema_elements.any_satisfy}: {
              first: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Luke"]}
              last: {#{schema_def_state.schema_elements.equal_to_any_of}: ["Skywalker"]}
            }}
          }) {
            # ...
          }
        }
        ```

        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
        that the `nested` mapping type can lead to performance problems, and index sorting cannot be configured[^3] when the `nested` type is used.

        [^1]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/nested.html
        [^2]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/joining-queries.html
        [^3]: https://www.elastic.co/guide/en/elasticsearch/reference/8.10/index-modules-index-sorting.html
      EOS
    end
  end
end

Instance Method Details

#aggregatable?Boolean

Indicates if this field is aggregatable. Aggregatable fields will be available under aggregatedValues for an aggregations query.

Aggregatability is inferred based on the field type and mapping type, or you can use the aggregatable: true option to force it.

Returns:

  • (Boolean)


584
585
586
587
588
589
590
591
592
593
594
595
596
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 584

def aggregatable?
  return aggregatable unless aggregatable.nil?
  return false if relationship

  # We don't yet support aggregating over subfields of a `nested` field.
  # TODO: add support for aggregating over subfields of `nested` fields.
  return false if nested?

  # Text fields are not efficiently aggregatable (and you'll often get errors from the datastore if you attempt to aggregate them).
  return false if text?

  type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:aggregatable?) || index_leaf?
end

#argument(name, value_type) {|Argument| ... } ⇒ Object

Note:

ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use this API, and it has no way to interpret arbitrary arguments defined on a field. However, it can be useful for extensions that extend the GraphQL query engine. For example, Apollo uses this API to satisfy the Apollo federation subgraph spec.

Defines an argument on the field.

Examples:

Define an argument on a field

ElasticGraph.define_schema do |schema|
  schema.object_type "Product" do |t|
    t.field "name", "String" do |f|
      f.argument "language", "String"
    end
  end
end

Parameters:

  • name (String)

    name of the argument

  • value_type (String)

    type of the argument in GraphQL SDL syntax

Yields:

  • (Argument)

    for further customization



629
630
631
632
633
634
635
636
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 629

def argument(name, value_type, &block)
  args[name] = schema_def_state.factory.new_argument(
    self,
    name,
    schema_def_state.type_ref(value_type),
    &block
  )
end

#customize_aggregated_values_field {|Field| ... } ⇒ void

Note:

For each field defined in your schema that is aggregatable, a corresponding aggregatedValues field will be created on the *AggregatedValues type derived from the parent object type.

This method returns an undefined value.

Registers a customization callback that will be applied to the corresponding aggregatedValues field that will be generated for this field.

Examples:

Mark CampaignAggregatedValues.adImpressions with @deprecated

ElasticGraph.define_schema do |schema|
  schema.object_type "Campaign" do |t|
    t.field "id", "ID"

    t.field "adImpressions", "Int" do |f|
      f.customize_aggregated_values_field do |avf|
        avf.directive "deprecated"
      end
    end

    t.index "campaigns"
  end
end

Yields:

  • (Field)

    derived aggregated values field

See Also:



232
233
234
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 232

def customize_aggregated_values_field(&customization_block)
  aggregated_values_customizations << customization_block
end

#customize_filter_field {|Field| ... } ⇒ void

Note:

For each field defined in your schema that is filterable, a corresponding filtering field will be created on the *FilterInput type derived from the parent object type.

This method returns an undefined value.

Registers a customization callback that will be applied to the corresponding filtering field that will be generated for this field.

Examples:

Mark CampaignFilterInput.organizationId with @deprecated

ElasticGraph.define_schema do |schema|
  schema.object_type "Campaign" do |t|
    t.field "id", "ID"

    t.field "organizationId", "ID" do |f|
      f.customize_filter_field do |ff|
        ff.directive "deprecated"
      end
    end

    t.index "campaigns"
  end
end

Yields:

  • (Field)

    derived filtering field

See Also:



200
201
202
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 200

def customize_filter_field(&customization_block)
  filter_customizations << customization_block
end

#customize_grouped_by_field {|Field| ... } ⇒ void

Note:

For each field defined in your schema that is groupable, a corresponding groupedBy field will be created on the *AggregationGroupedBy type derived from the parent object type.

This method returns an undefined value.

Registers a customization callback that will be applied to the corresponding groupedBy field that will be generated for this field.

Examples:

Mark CampaignGroupedBy.organizationId with @deprecated

ElasticGraph.define_schema do |schema|
  schema.object_type "Campaign" do |t|
    t.field "id", "ID"

    t.field "organizationId", "ID" do |f|
      f.customize_grouped_by_field do |gbf|
        gbf.directive "deprecated"
      end
    end

    t.index "campaigns"
  end
end

Yields:

  • (Field)

    derived grouped by field

See Also:



264
265
266
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 264

def customize_grouped_by_field(&customization_block)
  grouped_by_customizations << customization_block
end

#customize_sort_order_enum_values {|SortOrderEnumValue| ... } ⇒ void

Note:

for each sortable field, enum values will be generated on the derived sort order enum type allowing you to sort by the field ASC or DESC.

This method returns an undefined value.

Registers a customization callback that will be applied to the corresponding enum values that will be generated for this field on the derived SortOrder enum type.

Examples:

Mark CampaignSortOrder.organizationId_(ASC|DESC) with @deprecated

ElasticGraph.define_schema do |schema|
  schema.object_type "Campaign" do |t|
    t.field "id", "ID"

    t.field "organizationId", "ID" do |f|
      f.customize_sort_order_enum_values do |soev|
        soev.directive "deprecated"
      end
    end

    t.index "campaigns"
  end
end

Yields:

See Also:



337
338
339
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 337

def customize_sort_order_enum_values(&customization_block)
  sort_order_enum_value_customizations << customization_block
end

#customize_sub_aggregations_field {|Field| ... } ⇒ void

Note:

For each field defined in your schema that is sub-aggregatable (e.g. list fields indexed using the nested mapping type),

This method returns an undefined value.

a corresponding field will be created on the *AggregationSubAggregations type derived from the parent object type.

Registers a customization callback that will be applied to the corresponding subAggregations field that will be generated for this field.

Examples:

Mark TransactionAggregationSubAggregations.fees with @deprecated

ElasticGraph.define_schema do |schema|
  schema.object_type "Transaction" do |t|
    t.field "id", "ID"

    t.field "fees", "[Money!]!" do |f|
      f.mapping type: "nested"

      f.customize_sub_aggregations_field do |saf|
        # Adds a `@deprecated` directive to the `PaymentAggregationSubAggregations.fees`
        # field without also adding it to the `Payment.fees` field.
        saf.directive "deprecated"
      end
    end

    t.index "transactions"
  end

  schema.object_type "Money" do |t|
    t.field "amount", "Int"
    t.field "currency", "String"
  end
end

Yields:

  • (Field)

    derived sub-aggregations field

See Also:



305
306
307
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 305

def customize_sub_aggregations_field(&customization_block)
  sub_aggregations_customizations << customization_block
end

#filterable?Boolean

Indicates if this field is filterable. Filterable fields will be available in the GraphQL schema under the filter argument.

Most fields are filterable, except when:

  • It’s a relation. Relation fields require us to load the related data from another index and can’t be filtered on.
  • The field is an object type that isn’t itself filterable (e.g. due to having no filterable fields or whatever).
  • Explicitly disabled with filterable: false.

Returns:

  • (Boolean)


534
535
536
537
538
539
540
541
542
543
544
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 534

def filterable?
  # Object types that use custom index mappings (as `GeoLocation` does) aren't filterable
  # by default since we can't guess what datastore filtering capabilities they have. We've implemented
  # filtering support for `GeoLocation` fields, though, so we need to explicitly make it fliterable here.
  # TODO: clean this up using an interface instead of checking for `GeoLocation`.
  return true if type.fully_unwrapped.name == "GeoLocation"

  return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:filterable?)
  return true if filterable.nil?
  filterable
end

#groupable?Boolean

Indicates if this field is groupable. Groupable fields will be available under groupedBy for an aggregations query.

Groupability is inferred based on the field type and mapping type, or you can use the groupable: true option to force it.

Returns:

  • (Boolean)


551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 551

def groupable?
  # If the groupability of the field was specified explicitly when the field was defined, use the specified value.
  return groupable unless groupable.nil?

  # We don't want the `id` field of an indexed type to be available to group by, because it's the unique primary key
  # and the groupings would each contain one document. It's simpler and more efficient to just query the raw documents
  # instead.
  return false if parent_type.indexed? && name == "id"

  return false if relationship || type.fully_unwrapped.as_object_type&.does_not_support?(&:groupable?)

  # We don't support grouping an entire list of values, but we do support grouping on individual values in a list.
  # However, we only do so when a `singular_name` has been provided (so that we know what to call the grouped_by field).
  # The semantics are a little odd (since one document can be duplicated in multiple grouping buckets) so we're ok
  # with not offering it by default--the user has to opt-in by telling us what to call the field in its singular form.
  return list_field_groupable_by_single_values? if type.list? && type.fully_unwrapped.leaf?

  # Nested fields will be supported through specific nested aggregation support, and do not
  # work as expected when grouping on the root document type.
  return false if nested?

  # Text fields cannot be efficiently grouped on, so make them non-groupable by default.
  return false if text?

  # In all other cases, default to being groupable.
  true
end

#json_schema(nullable: nil, **options) ⇒ void

Note:

We recommend using JSON schema validations in a limited fashion. Validations that are appropriate to apply when data is entering the system-of-record are often not appropriate on a secondary index like ElasticGraph. Events that violate a JSON schema validation will fail to index (typically they will be sent to the dead letter queue and page an oncall engineer). If an ElasticGraph instance is meant to contain all the data of some source system, you probably don’t want it applying stricter validations than the source system itself has. We recommend limiting your JSON schema validations to situations where violations would prevent ElasticGraph from operating correctly.

This method returns an undefined value.

Defines the JSON schema validations for this field or type. Validations defined here will be included in the generated json_schemas.yaml artifact, which is used by the ElasticGraph indexer to validate events before indexing their data in the datastore. In addition, the publisher may use json_schemas.yaml for code generation and to apply validation before publishing an event to ElasticGraph.

Can be called multiple times; each time, the options will be merged into the existing options.

This is required on a ScalarType (since we don’t know how a custom scalar type should be represented in JSON!). On a ElasticGraph::SchemaDefinition::SchemaElements::Field, this is optional, but can be used to make the JSON schema validation stricter then it would otherwise be. For example, you could use json_schema maxLength: 30 on a String field to limit the length.

You can use any of the JSON schema validation keywords here. In addition, nullable: false is supported to configure the generated JSON schema to disallow null values for the field. Note that if you define a field with a non-nullable GraphQL type (e.g. Int!), the JSON schema will automatically disallow nulls. However, as explained in the TypeWithSubfields#field documentation, we generally recommend against defining non-nullable GraphQL fields. json_schema nullable: false will disallow null values from being indexed, while still keeping the field nullable in the GraphQL schema. If you think you might want to make a field non-nullable in the GraphQL schema some day, it’s a good idea to use json_schema nullable: false now to ensure every indexed record has a non-null value for the field.

Examples:

Define the JSON schema validations of a custom scalar type

ElasticGraph.define_schema do |schema|
  schema.scalar_type "URL" do |t|
    t.mapping type: "keyword"

    # JSON schema has a built-in URI format validator:
    # https://json-schema.org/understanding-json-schema/reference/string.html#resource-identifiers
    t.json_schema type: "string", format: "uri"
  end
end

Define additional validations on a field

ElasticGraph.define_schema do |schema|
  schema.object_type "Card" do |t|
    t.field "id", "ID!"

    t.field "expYear", "Int" do |f|
      # Use JSON schema to ensure the publisher is sending us 4 digit years, not 2 digit years.
      f.json_schema minimum: 2000, maximum: 2099
    end

    t.field "expMonth", "Int" do |f|
      f.json_schema minimum: 1, maximum: 12
    end

    t.index "cards"
  end
end

Parameters:

  • options (Hash<Symbol, Object>)

    JSON schema options



398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 398

def json_schema(nullable: nil, **options)
  if options.key?(:type)
    raise Errors::SchemaError, "Cannot override JSON schema type of field `#{name}` with `#{options.fetch(:type)}`"
  end

  case nullable
  when true
    raise Errors::SchemaError, "`nullable: true` is not allowed on a field--just declare the GraphQL field as being nullable (no `!` suffix) instead."
  when false
    self.non_nullable_in_json_schema = true
  end

  super(**options)
end

#mapping_typeString

The index mapping type in effect for this field. This could come from either the field definition or from the type definition.

Returns:

  • (String)


641
642
643
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 641

def mapping_type
  backing_indexing_field&.mapping_type || (resolve_mapping || {})["type"]
end

#on_each_generated_schema_element {|Field, EnumValue| ... } ⇒ void

This method returns an undefined value.

When you define a ElasticGraph::SchemaDefinition::SchemaElements::Field on an ObjectType or InterfaceType, ElasticGraph generates up to 6 different GraphQL schema elements for it:

This method registers a customization callback which is applied to every element that is generated for this field.

Examples:

ElasticGraph.define_schema do |schema|
  schema.object_type "Transaction" do |t|
    t.field "id", "ID"

    t.field "amount", "Int" do |f|
      f.on_each_generated_schema_element do |element|
        # Adds a `@deprecated` directive to every GraphQL schema element generated for `amount`:
        #
        # - The `Transaction.amount` field.
        # - The `TransactionFilterInput.amount` field.
        # - The `TransactionAggregationGroupedBy.amount` field.
        # - The `TransactionAggregatedValues.amount` field.
        # - The `TransactionSortOrder.amount_ASC` and`TransactionSortOrder.amount_DESC` enum values.
        element.directive "deprecated"
      end
    end

    t.index "transactions"
  end
end

Yields:

See Also:



388
389
390
391
392
393
394
395
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 388

def on_each_generated_schema_element(&customization_block)
  customization_block.call(self)
  customize_filter_field(&customization_block)
  customize_aggregated_values_field(&customization_block)
  customize_grouped_by_field(&customization_block)
  customize_sub_aggregations_field(&customization_block)
  customize_sort_order_enum_values(&customization_block)
end

#renamed_from(old_name) ⇒ void

Note:

In situations where this API applies, ElasticGraph will give you an error message indicating that you need to use this API or TypeWithSubfields#deleted_field. Likewise, when ElasticGraph no longer needs to know about this, it’ll give you a warning indicating the call to this method can be removed.

This method returns an undefined value.

Registers an old name that this field used to have in a prior version of the schema.

Examples:

Indicate that Widget.description used to be called Widget.notes.

ElasticGraph.define_schema do |schema|
  schema.object_type "Widget" do |t|
    t.field "description", "String" do |f|
      f.renamed_from "notes"
    end
  end
end

Parameters:

  • old_name (String)

    old name this field used to have in a prior version of the schema



473
474
475
476
477
478
479
480
481
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 473

def renamed_from(old_name)
  schema_def_state.register_renamed_field(
    parent_type.name,
    from: old_name,
    to: name,
    defined_at: caller_locations(1, 1).first, # : ::Thread::Backtrace::Location
    defined_via: %(field.renamed_from "#{old_name}")
  )
end

#sortable?Boolean

Indicates if this field is sortable. Sortable fields will have corresponding _ASC and _DESC values generated in the sort order EnumType of the parent indexed type.

By default, the sortability is inferred by the field type and mapping. For example, list fields are not sortable, and fields mapped as text are not sortable either. Fields are sortable in most other cases.

The sortable: true option can be used to force a field to be sortable.

Returns:

  • (Boolean)

    true if this field is sortable



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 502

def sortable?
  return sortable unless sortable.nil?

  # List fields are not sortable by default. We'd need to provide the datastore a sort mode option:
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_sort_mode_option
  return false if type.list?

  # Boolean fields are not sortable by default.
  #   - Boolean: sorting all falses before all trues (or whatever) is not generally interesting.
  return false if type.unwrap_non_null.boolean?

  # Elasticsearch/OpenSearch do not support sorting text fields:
  # > Text fields are not used for sorting...
  # (from https://www.elastic.co/guide/en/elasticsearch/reference/current/the datastore.html#text)
  return false if text?

  # 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.
  return false if type.as_object_type&.has_custom_mapping_type?

  # Default every other field to being sortable.
  true
end

#sourced_from(relationship, field_path) ⇒ void

This method returns an undefined value.

Configures ElasticGraph to source a field’s value from a related object. This can be used to denormalize data at ingestion time to support filtering, grouping, sorting, or aggregating data on a field from a related object.

Examples:

Source City.currency from Country.currency

ElasticGraph.define_schema do |schema|
  schema.object_type "Country" do |t|
    t.field "id", "ID"
    t.field "name", "String"
    t.field "currency", "String"
    t.relates_to_one "capitalCity", "City", via: "capitalCityId", dir: :out
    t.index "countries"
  end

  schema.object_type "City" do |t|
    t.field "id", "ID"
    t.field "name", "String"
    t.relates_to_one "capitalOf", "Country", via: "capitalCityId", dir: :in

    t.field "currency", "String" do |f|
      f.sourced_from "capitalOf", "currency"
    end

    t.index "cities"
  end
end

Parameters:

  • relationship (String)

    name of a relationship defined with TypeWithSubfields#relates_to_one using an inbound foreign key which contains the the field you wish to source values from

  • field_path (String)

    dot-separated path to the field on the related type containing values that should be copied to this field



444
445
446
447
448
449
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 444

def sourced_from(relationship, field_path)
  self.source = schema_def_state.factory.new_field_source(
    relationship_name: relationship,
    field_path: field_path
  )
end

#sub_aggregatable?Boolean

Indicates if this field can be used as the basis for a sub-aggregation. Sub-aggregatable fields will be available under subAggregations for an aggregations query.

Only nested fields, and object fields which have nested fields, can be sub-aggregated.

Returns:

  • (Boolean)


604
605
606
607
608
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 604

def sub_aggregatable?
  return false if relationship

  nested? || type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:sub_aggregatable?)
end

#typeTypeReference

Returns the type of this field.

Returns:

  • (TypeReference)

    the type of this field



158
159
160
161
162
163
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb', line 158

def type
  # Here we lazily convert the `original_type` to an input type as needed. This must be lazy because
  # the logic of `as_input` depends on detecting whether the type is an enum type, which it may not
  # be able to do right away--we assume not if we can't tell, and retry every time this method is called.
  original_type.to_final_form(as_input: as_input)
end