Class: ElasticGraph::SchemaDefinition::SchemaElements::ScalarType

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

Overview

Defines a GraphQL scalar type. ElasticGraph itself uses this to define a few common scalar types (e.g. Date and DateTime), but it is also available to you to use to define your own custom scalar types.

Examples:

Define a scalar type

ElasticGraph.define_schema do |schema|
  schema.scalar_type "URL" do |t|
    t.mapping type: "keyword"
    t.json_schema type: "string", format: "uri"
  end
end

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, #json_schema_options, #mapping_options

Methods included from Mixins::HasDerivedGraphQLTypeCustomizations

#customize_derived_type_fields, #customize_derived_types

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::CanBeGraphQLOnly

#graphql_only, #graphql_only?

Methods included from Mixins::VerifiesGraphQLName

verify_name!

Instance Attribute Details

#schema_def_stateState (readonly)

Returns schema definition state.

Returns:

  • (State)

    schema definition state



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
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb', line 42

class ScalarType < Struct.new(:schema_def_state, :type_ref, :mapping_type, :runtime_metadata, :aggregated_values_customizations)
  # `Struct.new` provides the following methods:
  # @dynamic type_ref, runtime_metadata
  prepend Mixins::VerifiesGraphQLName
  include Mixins::CanBeGraphQLOnly
  include Mixins::HasDocumentation
  include Mixins::HasDirectives
  include Mixins::HasDerivedGraphQLTypeCustomizations
  include Mixins::HasReadableToSAndInspect.new { |t| t.name }

  # `HasTypeInfo` provides the following methods:
  # @dynamic mapping_options, json_schema_options
  include Mixins::HasTypeInfo

  # @dynamic graphql_only?

  # @private
  def initialize(schema_def_state, name)
    super(schema_def_state, schema_def_state.type_ref(name).to_final_form)

    # Default the runtime metadata before yielding, so it can be overridden as needed.
    self. = SchemaArtifacts::RuntimeMetadata::ScalarType.new(
      coercion_adapter_ref: SchemaArtifacts::RuntimeMetadata::ScalarType::DEFAULT_COERCION_ADAPTER_REF,
      indexing_preparer_ref: SchemaArtifacts::RuntimeMetadata::ScalarType::DEFAULT_INDEXING_PREPARER_REF
    )

    yield self

    missing = [
      ("`mapping`" if mapping_options.empty?),
      ("`json_schema`" if json_schema_options.empty?)
    ].compact

    if missing.any?
      raise Errors::SchemaError, "Scalar types require `mapping` and `json_schema` to be configured, but `#{name}` lacks #{missing.join(" and ")}."
    end
  end

  # @return [String] name of the scalar type
  def name
    type_ref.name
  end

  # (see Mixins::HasTypeInfo#mapping)
  def mapping(**options)
    self.mapping_type = options.fetch(:type) do
      raise Errors::SchemaError, "Must specify a mapping `type:` on custom scalars but was missing on the `#{name}` type."
    end

    super
  end

  # Specifies the scalar coercion adapter that should be used for this scalar type. The scalar coercion adapter is responsible
  # for validating and coercing scalar input values, and converting scalar return values to a form suitable for JSON serialization.
  #
  # @note For examples of scalar coercion adapters, see `ElasticGraph::GraphQL::ScalarCoercionAdapters`.
  # @note If the `defined_at` require path requires any directories be put on the Ruby `$LOAD_PATH`, you are responsible for doing
  #   that before booting {ElasticGraph::GraphQL}.
  #
  # @param adapter_name [String] fully qualified Ruby class name of the adapter
  # @param defined_at [String] the `require` path of the adapter
  # @return [void]
  #
  # @example Register a coercion adapter
  #   ElasticGraph.define_schema do |schema|
  #     schema.scalar_type "PhoneNumber" do |t|
  #       t.mapping type: "keyword"
  #       t.json_schema type: "string", pattern: "^\\+[1-9][0-9]{1,14}$"
  #       t.coerce_with "CoercionAdapters::PhoneNumber", defined_at: "./coercion_adapters/phone_number"
  #     end
  #   end
  def coerce_with(adapter_name, defined_at:)
    self. = .with(coercion_adapter_ref: {
      "extension_name" => adapter_name,
      "require_path" => defined_at
    }).tap(&:load_coercion_adapter) # verify the adapter is valid.
  end

  # Specifies an indexing preparer that should be used for this scalar type. The indexing preparer is responsible for preparing
  # scalar values before indexing them, performing any desired formatting or normalization.
  #
  # @note For examples of scalar coercion adapters, see `ElasticGraph::Indexer::IndexingPreparers`.
  # @note If the `defined_at` require path requires any directories be put on the Ruby `$LOAD_PATH`, you are responsible for doing
  #   that before booting {ElasticGraph::GraphQL}.
  #
  # @param preparer_name [String] fully qualified Ruby class name of the indexing preparer
  # @param defined_at [String] the `require` path of the preparer
  # @return [void]
  #
  # @example Register an indexing preparer
  #   ElasticGraph.define_schema do |schema|
  #     schema.scalar_type "PhoneNumber" do |t|
  #       t.mapping type: "keyword"
  #       t.json_schema type: "string", pattern: "^\\+[1-9][0-9]{1,14}$"
  #
  #       t.prepare_for_indexing_with "IndexingPreparers::PhoneNumber",
  #         defined_at: "./indexing_preparers/phone_number"
  #     end
  #   end
  def prepare_for_indexing_with(preparer_name, defined_at:)
    self. = .with(indexing_preparer_ref: {
      "extension_name" => preparer_name,
      "require_path" => defined_at
    }).tap(&:load_indexing_preparer) # verify the preparer is valid.
  end

  # @return [String] the GraphQL SDL form of this scalar
  def to_sdl
    "#{formatted_documentation}scalar #{name} #{directives_sdl}"
  end

  # Registers a block which will be used to customize the derived `*AggregatedValues` object type.
  #
  # @private
  def customize_aggregated_values_type(&block)
    self.aggregated_values_customizations = block
  end

  # @private
  def aggregated_values_type
    if aggregated_values_customizations
      type_ref.as_aggregated_values
    else
      schema_def_state.type_ref("NonNumeric").as_aggregated_values
    end
  end

  # @private
  def to_indexing_field_type
    Indexing::FieldType::Scalar.new(scalar_type: self)
  end

  # @private
  def derived_graphql_types
    return [] if graphql_only?

    pagination_types =
      if schema_def_state.paginated_collection_element_types.include?(name)
        schema_def_state.factory.build_relay_pagination_types(name, include_total_edge_count: true)
      else
        [] # : ::Array[ObjectType]
      end

    (to_input_filters + pagination_types).tap do |derived_types|
      if (aggregated_values_type = to_aggregated_values_type)
        derived_types << aggregated_values_type
      end
    end
  end

  # @private
  def indexed?
    false
  end

  private

  EQUAL_TO_ANY_OF_DOC = <<~EOS
    Matches records where the field value is equal to any of the provided values.
    This works just like an IN operator in SQL.

    Will be ignored when `null` is passed. When an empty list is passed, will cause this
    part of the filter to match no documents. When `null` is passed in the list, will
    match records where the field value is `null`.
  EOS

  GT_DOC = <<~EOS
    Matches records where the field value is greater than (>) the provided value.

    Will be ignored when `null` is passed.
  EOS

  GTE_DOC = <<~EOS
    Matches records where the field value is greater than or equal to (>=) the provided value.

    Will be ignored when `null` is passed.
  EOS

  LT_DOC = <<~EOS
    Matches records where the field value is less than (<) the provided value.

    Will be ignored when `null` is passed.
  EOS

  LTE_DOC = <<~EOS
    Matches records where the field value is less than or equal to (<=) the provided value.

    Will be ignored when `null` is passed.
  EOS

  def to_input_filters
    # Note: all fields on inputs should be nullable, to support parameterized queries where
    # the parameters are allowed to be set to `null`. We also now support nulls within lists.

    # For floats, we may want to remove the `equal_to_any_of` operator at some point.
    # In many languages. checking exact equality with floats is problematic.
    # For example, in IRB:
    #
    # 2.7.1 :003 > 0.3 == (0.1 + 0.2)
    # => false
    #
    # However, it's not yet clear if that issue will come up with GraphQL, because
    # float values are serialized on the wire as JSON, using an exact decimal
    # string representation. So for now we are keeping `equal_to_any_of`.
    schema_def_state.factory.build_standard_filter_input_types_for_index_leaf_type(name) do |t|
      # Normally, we use a nullable type for `equal_to_any_of`, to allow a filter expression like this:
      #
      # filter: {optional_field: {equal_to_any_of: [null]}}
      #
      # That filter expression matches documents where `optional_field == null`. However,
      # we cannot support this:
      #
      # filter: {tags: {any_satisfy: {equal_to_any_of: [null]}}}
      #
      # We can't support that because we implement filtering on `null` using an `exists` query:
      # https://www.elastic.co/guide/en/elasticsearch/reference/8.10/query-dsl-exists-query.html
      #
      # ...but that works based on the field existing (or not), and does not let us filter on the
      # presence or absence of `null` within a list.
      #
      # So, here we make the field non-null if we're in an `any_satisfy` context (as indicated by
      # the type ending with `ListElementFilterInput`).
      equal_to_any_of_type = t.type_ref.list_element_filter_input? ? "[#{name}!]" : "[#{name}]"

      t.field schema_def_state.schema_elements.equal_to_any_of, equal_to_any_of_type do |f|
        f.documentation EQUAL_TO_ANY_OF_DOC
      end

      if mapping_type_efficiently_comparable?
        t.field schema_def_state.schema_elements.gt, name do |f|
          f.documentation GT_DOC
        end

        t.field schema_def_state.schema_elements.gte, name do |f|
          f.documentation GTE_DOC
        end

        t.field schema_def_state.schema_elements.lt, name do |f|
          f.documentation LT_DOC
        end

        t.field schema_def_state.schema_elements.lte, name do |f|
          f.documentation LTE_DOC
        end
      end
    end
  end

  def to_aggregated_values_type
    return nil unless (customization_block = aggregated_values_customizations)
    schema_def_state.factory.new_aggregated_values_type_for_index_leaf_type(name, &customization_block)
  end

  # https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html
  # https://www.elastic.co/guide/en/elasticsearch/reference/7.13/number.html#number
  NUMERIC_TYPES = %w[long integer short byte double float half_float scaled_float unsigned_long].to_set
  DATE_TYPES = %w[date date_nanos].to_set
  # The Elasticsearch/OpenSearch docs do not exhaustively give a list of types on which range queries are efficient,
  # but the docs are clear that it is efficient on numeric and date types, and is inefficient on string
  # types: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html
  COMPARABLE_TYPES = NUMERIC_TYPES | DATE_TYPES

  def mapping_type_efficiently_comparable?
    COMPARABLE_TYPES.include?(mapping_type)
  end
end

Instance Method Details

#coerce_with(adapter_name, defined_at:) ⇒ void

Note:

For examples of scalar coercion adapters, see ElasticGraph::GraphQL::ScalarCoercionAdapters.

Note:

If the defined_at require path requires any directories be put on the Ruby $LOAD_PATH, you are responsible for doing that before booting GraphQL.

This method returns an undefined value.

Specifies the scalar coercion adapter that should be used for this scalar type. The scalar coercion adapter is responsible for validating and coercing scalar input values, and converting scalar return values to a form suitable for JSON serialization.

Examples:

Register a coercion adapter

ElasticGraph.define_schema do |schema|
  schema.scalar_type "PhoneNumber" do |t|
    t.mapping type: "keyword"
    t.json_schema type: "string", pattern: "^\\+[1-9][0-9]{1,14}$"
    t.coerce_with "CoercionAdapters::PhoneNumber", defined_at: "./coercion_adapters/phone_number"
  end
end

Parameters:

  • adapter_name (String)

    fully qualified Ruby class name of the adapter

  • defined_at (String)

    the require path of the adapter



113
114
115
116
117
118
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb', line 113

def coerce_with(adapter_name, defined_at:)
  self. = .with(coercion_adapter_ref: {
    "extension_name" => adapter_name,
    "require_path" => defined_at
  }).tap(&:load_coercion_adapter) # verify the adapter is valid.
end

#mapping(**options) ⇒ void

This method returns an undefined value.

Defines the Elasticsearch/OpenSearch field mapping type and mapping parameters for a field or type. The options passed here will be included in the generated datastore_config.yaml artifact that ElasticGraph uses to configure Elasticsearch/OpenSearch.

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

This is required on a ElasticGraph::SchemaDefinition::SchemaElements::ScalarType; without it, ElasticGraph would have no way to know how the datatype should be indexed in the datastore.

On a Field, this can be used to customize how a field is indexed. For example, String fields are normally indexed as keywords; to instead index a String field for full text search, you’d need to configure mapping type: "text".

On a ObjectType, this can be used to use a specific Elasticsearch/OpenSearch data type for something that is modeled as an object in GraphQL. For example, we use it for the GeoLocation type so they get indexed in Elasticsearch using the geo_point type.

Examples:

Define the mapping of a custom scalar type

ElasticGraph.define_schema do |schema|
  schema.scalar_type "URL" do |t|
    t.mapping type: "keyword"
    t.json_schema type: "string", format: "uri"
  end
end

Customize the mapping of a field

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

    t.field "cardholderName", "String" do |f|
      # index this field for full text search
      f.mapping type: "text"
    end

    t.field "expYear", "Int" do |f|
      # Use a smaller numeric type to save space in the datastore
      f.mapping type: "short"
      f.json_schema minimum: 2000, maximum: 2099
    end

    t.field "expMonth", "Int" do |f|
      # Use a smaller numeric type to save space in the datastore
      f.mapping type: "byte"
      f.json_schema minimum: 1, maximum: 12
    end

    t.index "cards"
  end
end

Parameters:



86
87
88
89
90
91
92
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb', line 86

def mapping(**options)
  self.mapping_type = options.fetch(:type) do
    raise Errors::SchemaError, "Must specify a mapping `type:` on custom scalars but was missing on the `#{name}` type."
  end

  super
end

#nameString

Returns name of the scalar type.

Returns:

  • (String)

    name of the scalar type



81
82
83
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb', line 81

def name
  type_ref.name
end

#prepare_for_indexing_with(preparer_name, defined_at:) ⇒ void

Note:

For examples of scalar coercion adapters, see ElasticGraph::Indexer::IndexingPreparers.

Note:

If the defined_at require path requires any directories be put on the Ruby $LOAD_PATH, you are responsible for doing that before booting GraphQL.

This method returns an undefined value.

Specifies an indexing preparer that should be used for this scalar type. The indexing preparer is responsible for preparing scalar values before indexing them, performing any desired formatting or normalization.

Examples:

Register an indexing preparer

ElasticGraph.define_schema do |schema|
  schema.scalar_type "PhoneNumber" do |t|
    t.mapping type: "keyword"
    t.json_schema type: "string", pattern: "^\\+[1-9][0-9]{1,14}$"

    t.prepare_for_indexing_with "IndexingPreparers::PhoneNumber",
      defined_at: "./indexing_preparers/phone_number"
  end
end

Parameters:

  • preparer_name (String)

    fully qualified Ruby class name of the indexing preparer

  • defined_at (String)

    the require path of the preparer



141
142
143
144
145
146
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb', line 141

def prepare_for_indexing_with(preparer_name, defined_at:)
  self. = .with(indexing_preparer_ref: {
    "extension_name" => preparer_name,
    "require_path" => defined_at
  }).tap(&:load_indexing_preparer) # verify the preparer is valid.
end

#to_sdlString

Returns the GraphQL SDL form of this scalar.

Returns:

  • (String)

    the GraphQL SDL form of this scalar



149
150
151
# File 'elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/scalar_type.rb', line 149

def to_sdl
  "#{formatted_documentation}scalar #{name} #{directives_sdl}"
end