-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathfield.rb
More file actions
1257 lines (1129 loc) · 56.5 KB
/
field.rb
File metadata and controls
1257 lines (1129 loc) · 56.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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
# Copyright 2024 - 2026 Block, Inc.
#
# Use of this source code is governed by an MIT-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
#
# frozen_string_literal: true
require "delegate"
require "elastic_graph/constants"
require "elastic_graph/schema_artifacts/runtime_metadata/configured_graphql_resolver"
require "elastic_graph/schema_definition/indexing/field"
require "elastic_graph/schema_definition/indexing/field_reference"
require "elastic_graph/schema_definition/mixins/has_directives"
require "elastic_graph/schema_definition/mixins/has_documentation"
require "elastic_graph/schema_definition/mixins/has_readable_to_s_and_inspect"
require "elastic_graph/schema_definition/mixins/has_type_info"
require "elastic_graph/schema_definition/mixins/verifies_graphql_name"
require "elastic_graph/support/graphql_formatter"
module ElasticGraph
module SchemaDefinition
module SchemaElements
# Represents a [GraphQL field](https://spec.graphql.org/October2021/#sec-Language.Fields).
#
# @example 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
#
# @!attribute [r] name
# @return [String] name of the field
# @!attribute [r] schema_def_state
# @return [State] schema definition state
# @!attribute [r] graphql_only
# @return [Boolean] true if this field exists only in the GraphQL schema and is not indexed
# @!attribute [r] name_in_index
# @return [String] the name of this field in the datastore index
#
# @!attribute [rw] original_type
# @private
# @!attribute [rw] parent_type
# @private
# @!attribute [rw] original_type_for_derived_types
# @private
# @!attribute [rw] accuracy_confidence
# @private
# @!attribute [rw] filter_customizations
# @private
# @!attribute [rw] grouped_by_customizations
# @private
# @!attribute [rw] highlights_customizations
# @private
# @!attribute [rw] sub_aggregations_customizations
# @private
# @!attribute [rw] aggregated_values_customizations
# @private
# @!attribute [rw] sort_order_enum_value_customizations
# @private
# @!attribute [rw] args
# @private
# @!attribute [rw] sortable
# @private
# @!attribute [rw] filterable
# @private
# @!attribute [rw] aggregatable
# @private
# @!attribute [rw] groupable
# @private
# @!attribute [rw] highlightable
# @private
# @!attribute [rw] source
# @private
# @!attribute [rw] runtime_field_script
# @private
# @!attribute [rw] relationship
# @private
# @!attribute [rw] singular_name
# @private
# @!attribute [rw] computation_detail
# @private
# @!attribute [rw] non_nullable_in_json_schema
# @private
# @!attribute [rw] as_input
# @private
# @!attribute [rw] type_already_final
# @private
class Field < Struct.new(
:name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
:filter_customizations, :grouped_by_customizations, :highlights_customizations, :sub_aggregations_customizations,
:aggregated_values_customizations, :sort_order_enum_value_customizations, :args,
:sortable, :filterable, :aggregatable, :groupable, :highlightable,
:graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
:computation_detail, :non_nullable_in_json_schema, :as_input,
:name_in_index, :resolver, :type_already_final
)
include Mixins::HasDocumentation
include Mixins::HasDirectives
include Mixins::HasTypeInfo
include Mixins::HasReadableToSAndInspect.new(&:to_qualified_sdl)
# @private
def initialize(
name:, type:, parent_type:, schema_def_state:,
accuracy_confidence: :high, name_in_index: name,
type_for_derived_types: nil, graphql_only: nil, singular: nil,
sortable: nil, filterable: nil, aggregatable: nil, groupable: nil, highlightable: nil,
as_input: false, resolver: nil, type_already_final: 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: [],
highlights_customizations: [],
sub_aggregations_customizations: [],
aggregated_values_customizations: [],
sort_order_enum_value_customizations: [],
args: {},
sortable: sortable,
filterable: filterable,
aggregatable: aggregatable,
groupable: groupable,
highlightable: highlightable,
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,
as_input: as_input,
resolver: resolver,
type_already_final: type_already_final
)
if name != name_in_index
if graphql_only
schema_def_state.after_user_definition_complete do
unless backing_indexing_field
raise Errors::SchemaError,
"GraphQL-only field `#{parent_type.name}.#{name}` has a `name_in_index` (#{name_in_index}) which does not reference an " \
"existing indexing field. To proceed, remove `graphql_only: true` or update `name_in_index` to match an existing indexing field."
end
end
elsif name_in_index.include?(".")
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
end
schema_def_state.register_user_defined_field(self)
yield self if block_given?
end
private :resolver=
# @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
# When `type_already_final` is set, the type reference has already been resolved to its final form
# (e.g. for transition input enum filter fields that must reference the InputEnum type directly).
return original_type if type_already_final
# 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
return original_type_for_derived_types if type_already_final
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_highlights_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_highlights_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_highlights_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 highlightable, a corresponding highlights field will be created on the
# `*Highlights` type derived from the parent object type.
#
# Registers a customization callback that will be applied to the corresponding highlights field that will be generated for this
# field.
#
# @yield [Field] derived highlights field
# @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
# @see #on_each_generated_schema_element
#
# @example Mark `CampaignHighlights.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_highlights_field do |gbf|
# gbf.directive "deprecated"
# end
# end
#
# t.index "campaigns"
# end
# end
def customize_highlights_field(&customization_block)
highlights_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_highlights_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_highlights_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 `*Highlights` {ObjectType} derived from the parent {ObjectType} or {InterfaceType}. This is
# used by clients to request search highlights for a field.
# * 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_highlights_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 "currency", "String" do |f|
# f.on_each_generated_schema_element do |element|
# # Adds a `@deprecated` directive to every GraphQL schema element generated for `currency`:
# #
# # - The `Transaction.currency` field.
# # - The `TransactionFilterInput.currency` field.
# # - The `TransactionHighlights.currency` field.
# # - The `TransactionAggregationGroupedBy.currency` field.
# # - The `TransactionAggregatedValues.currency` field.
# # - The `TransactionSortOrder.currency_ASC` and`TransactionSortOrder.currency_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_highlights_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
# (see Mixins::HasTypeInfo#mapping)
def mapping(**options)
# ElasticGraph has special handling for the nested type (e.g. we generate sub-aggregation types in the GraphQL schema for
# nested fields), and that special handling requires that `nested` only be used on list-of-objects fields; otherwise
# confusing errors can result when dumping schema artifacts. It only makes sense to use `nested` on a list-of-objects
# field, anyway.
if options[:type] == "nested"
schema_def_state.after_user_definition_complete do
unless type_for_derived_types.list? && type.fully_unwrapped.object?
raise Errors::SchemaError, "The `nested` mapping type has been used on field `#{to_qualified_sdl}`, " \
'but `nested` is only valid on a list-of-objects field. Remove `field.mapping type: "nested"` to continue.'
end
end
end
super
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" do |i|
# i.has_had_multiple_sources!
# end
# 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
# Configures the GraphQL resolver used to resolve this field. If not set, the resolver configured on the parent type
# via {Mixins::HasIndices#resolve_fields_with} will be used.
#
# @param resolver_name [Symbol] name of the GraphQL resolver
# @param config [Hash<Symbol, Object>] configuration parameters for the resolver
# @return [void]
# @see API#register_graphql_resolver
#
# @example Use a custom resolver for a custom `Query` field
# # In `add_resolver.rb`:
# class AddResolver
# def initialize(elasticgraph_graphql:, config:)
# @multiplier = config.fetch(:multiplier, 1)
# end
#
# def resolve(field:, object:, args:, context:)
# sum = args.fetch("x") + args.fetch("y")
# sum * @multiplier
# end
# end
#
# # In `config/schema.rb`:
# ElasticGraph.define_schema do |schema|
# require(resolver_path = "add_resolver")
# schema.register_graphql_resolver :add, AddResolver, defined_at: resolver_path
#
# schema.on_root_query_type do |t|
# t.field "add", "Int" do |f|
# f.argument "x", "Int!"
# f.argument "y", "Int!"
#
# # Extra args (`multiplier: 2`, in this example) are passed to the resolver within `config`.
# f.resolve_with :add, multiplier: 2
# end
# end
# end
def resolve_with(resolver_name, **config)
self.resolver = resolver_name&.then { SchemaArtifacts::RuntimeMetadata::ConfiguredGraphQLResolver.new(it, config) }
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).to_a.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
# Returns the definition of this field in SDL form, qualified by the parent type.
# @private
def to_qualified_sdl
"#{parent_type.name}.#{name}: #{type}"
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 a root document 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.root_document_type? && 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
# @private
HIGHLIGHTABLE_MAPPING_TYPES = %w[keyword text match_only_text]
def highlightable?
return highlightable unless highlightable.nil?
return false if relationship
return true if HIGHLIGHTABLE_MAPPING_TYPES.include?(mapping_type)
type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:highlightable?)
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)
grouped_by_customizations.each { |block| block.call(f) }
end
end
# @private
def define_highlights_field(parent_type)
return unless highlightable?
unwrapped_type = type_for_derived_types.fully_unwrapped
type_name =
if unwrapped_type.leaf?
"[String!]!"
else
unwrapped_type.as_highlights.name
end
parent_type.field name, type_name, name_in_index: name_in_index, graphql_only: true do |f|
f.documentation derived_documentation("Search highlights for the `#{name}`, providing snippets of the matching text")
highlights_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?
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?
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,
resolver: nil
)
schema_def_state.factory.new_field(**params).tap do |f|
f.documentation derived_documentation(
"Used to filter on the `#{name}` field",
"When `null` or an empty object is passed, matches all documents"
)
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,
doc_comment: doc_comment
)
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.
#