elasticgraph-graphql 0.18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +3 -0
- data/elasticgraph-graphql.gemspec +23 -0
- data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
- data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
- data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
- data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
- data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
- data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
- data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
- data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
- data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
- data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
- data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
- data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
- data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
- data/lib/elastic_graph/graphql/client.rb +43 -0
- data/lib/elastic_graph/graphql/config.rb +81 -0
- data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
- data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
- data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
- data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
- data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
- data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
- data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
- data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
- data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
- data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
- data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
- data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
- data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
- data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
- data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
- data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
- data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
- data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
- data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
- data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
- data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
- data/lib/elastic_graph/graphql/query_executor.rb +200 -0
- data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
- data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
- data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
- data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
- data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
- data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
- data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
- data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
- data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
- data/lib/elastic_graph/graphql/schema/field.rb +147 -0
- data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
- data/lib/elastic_graph/graphql/schema/type.rb +263 -0
- data/lib/elastic_graph/graphql/schema.rb +164 -0
- data/lib/elastic_graph/graphql.rb +253 -0
- data/script/dump_time_zones +81 -0
- data/script/dump_time_zones.java +17 -0
- metadata +503 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
#
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
require "base64"
|
|
10
|
+
require "elastic_graph/constants"
|
|
11
|
+
require "elastic_graph/error"
|
|
12
|
+
require "elastic_graph/support/memoizable_data"
|
|
13
|
+
require "json"
|
|
14
|
+
|
|
15
|
+
module ElasticGraph
|
|
16
|
+
class GraphQL
|
|
17
|
+
# Provides the in-memory representation of a cursor after it has been decoded, as a simple hash of sort values.
|
|
18
|
+
#
|
|
19
|
+
# The datastore's `search_after` pagination uses an array of values (which represent values of the fields you are
|
|
20
|
+
# sorting by). A cursor returned when we applied one sort is generally not valid when we apply a completely
|
|
21
|
+
# different sort. To ensure we can detect this, the encoder encodes a hash of sort fields and values, ensuring
|
|
22
|
+
# each value in the cursor is properly labeled with what field it came from. This allows us
|
|
23
|
+
# to detect situations where the client uses a cursor with a completely different sort applied, while
|
|
24
|
+
# allowing some minor variation in the sort. The following are still allowed:
|
|
25
|
+
#
|
|
26
|
+
# - Changing the direction of the sort (from `asc` to `desc` or vice-versa)
|
|
27
|
+
# - Re-ordering the sort (e.g. changing from `[amount_money_DESC, created_at_ASC]`
|
|
28
|
+
# to `[created_at_ASC, amount_money_DESC]`
|
|
29
|
+
# - Removing fields from the sort (e.g. changing from `[amount_money_DESC, created_at_ASC]`
|
|
30
|
+
# to `[amount_money_DESC]`) -- but adding fields is not allowed
|
|
31
|
+
#
|
|
32
|
+
# While we don't necessarily recommend clients change these things between pagination requests (the
|
|
33
|
+
# behavior may be surprising to the user), there is no ambiguity in how to support them, and we do not
|
|
34
|
+
# feel like it makes sense to restrict it at this point.
|
|
35
|
+
class DecodedCursor < Support::MemoizableData.define(:sort_values)
|
|
36
|
+
# Methods provided by `MemoizableData.define`:
|
|
37
|
+
# @dynamic initialize, sort_values
|
|
38
|
+
|
|
39
|
+
# Tries to decode the given string cursor, returning `nil` if it is invalid.
|
|
40
|
+
def self.try_decode(string)
|
|
41
|
+
decode!(string)
|
|
42
|
+
rescue InvalidCursorError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Tries to decode the given string cursor, raising an `InvalidCursorError` if it's invalid.
|
|
47
|
+
def self.decode!(string)
|
|
48
|
+
return SINGLETON if string == SINGLETON_CURSOR
|
|
49
|
+
json = ::Base64.urlsafe_decode64(string)
|
|
50
|
+
new(::JSON.parse(json))
|
|
51
|
+
rescue ::ArgumentError, ::JSON::ParserError
|
|
52
|
+
raise InvalidCursorError, "`#{string}` is an invalid cursor."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Encodes the cursor to a string using JSON and Base64 encoding.
|
|
56
|
+
def encode
|
|
57
|
+
@encode ||= begin
|
|
58
|
+
json = ::JSON.fast_generate(sort_values)
|
|
59
|
+
::Base64.urlsafe_encode64(json, padding: false)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A special cursor instance for when we need a cursor but have only a static collection of a single
|
|
64
|
+
# element without any sort of key we can encode.
|
|
65
|
+
SINGLETON = new({}).tap do |sc|
|
|
66
|
+
# Ensure the special string value is returned even though our `sort_values` are empty.
|
|
67
|
+
def sc.encode
|
|
68
|
+
SINGLETON_CURSOR
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Used to build decoded cursor values for the given `sort_fields`.
|
|
73
|
+
class Factory < Data.define(:sort_fields)
|
|
74
|
+
# Methods provided by `Data.define`:
|
|
75
|
+
# @dynamic initialize, sort_fields
|
|
76
|
+
|
|
77
|
+
# Builds a factory from a list like:
|
|
78
|
+
# `[{ 'amount_money.amount' => 'asc' }, { 'created_at' => 'desc' }]`.
|
|
79
|
+
def self.from_sort_list(sort_list)
|
|
80
|
+
sort_fields = sort_list.map do |hash|
|
|
81
|
+
if hash.values.any? { |v| !v.is_a?(::Hash) } || hash.values.flat_map(&:keys) != ["order"]
|
|
82
|
+
raise InvalidSortFieldsError,
|
|
83
|
+
"Given `sort_list` contained an invalid entry. Each must be a flat hash with one entry. Got: #{sort_list.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Steep thinks it could be `nil` because `hash.keys` could be empty, but we raise an error above in
|
|
87
|
+
# that case, so we know this will wind up being a `String`. `_` here silences Steep's type check error.
|
|
88
|
+
_ = hash.keys.first
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if sort_fields.uniq.size < sort_fields.size
|
|
92
|
+
raise InvalidSortFieldsError,
|
|
93
|
+
"Given `sort_list` contains a duplicate field, which the CursorEncoder cannot handler. " \
|
|
94
|
+
"The caller is responsible for de-duplicating the sort list fist. Got: #{sort_list.inspect}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
new(sort_fields)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build(sort_values)
|
|
101
|
+
unless sort_values.size == sort_fields.size
|
|
102
|
+
raise CursorEncodingError,
|
|
103
|
+
"size of sort values (#{sort_values.inspect}) does not match the " \
|
|
104
|
+
"size of sort fields (#{sort_fields.inspect})"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
DecodedCursor.new(sort_fields.zip(sort_values).to_h)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
alias_method :to_s, :inspect
|
|
111
|
+
|
|
112
|
+
module Null
|
|
113
|
+
def self.build(sort_values)
|
|
114
|
+
DecodedCursor.new(sort_values.map(&:to_s).zip(sort_values).to_h)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
#
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
module ElasticGraph
|
|
10
|
+
class GraphQL
|
|
11
|
+
module Filtering
|
|
12
|
+
# BooleanQuery is an internal class for composing a datastore query:
|
|
13
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html
|
|
14
|
+
#
|
|
15
|
+
# It is composed of:
|
|
16
|
+
# 1) The occurrence type (:must, :filter, :should, or :must_not)
|
|
17
|
+
# 2) A list of query clauses evaluated by the given occurrence type
|
|
18
|
+
# 3) An optional flag indicating whether the occurrence should be negated
|
|
19
|
+
class BooleanQuery < ::Data.define(:occurrence, :clauses)
|
|
20
|
+
def self.must(*clauses)
|
|
21
|
+
new(:must, clauses)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.filter(*clauses)
|
|
25
|
+
new(:filter, clauses)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.should(*clauses)
|
|
29
|
+
new(:should, clauses)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merge_into(bool_node)
|
|
33
|
+
bool_node[occurrence].concat(clauses)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# For `any_of: []` we need a way to force the datastore to match no documents, but
|
|
37
|
+
# I haven't found any sort of literal `false` we can pass in the compound expression
|
|
38
|
+
# or even a literal `1 = 0` as is sometimes used in SQL. Instead, we use this for that
|
|
39
|
+
# case.
|
|
40
|
+
empty_array = [] # : ::Array[untyped]
|
|
41
|
+
ALWAYS_FALSE_FILTER = filter({ids: {values: empty_array}})
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
#
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
require "elastic_graph/constants"
|
|
10
|
+
|
|
11
|
+
module ElasticGraph
|
|
12
|
+
class GraphQL
|
|
13
|
+
module Filtering
|
|
14
|
+
# Tracks state related to field paths as we traverse our filtering data structure in order to translate
|
|
15
|
+
# it to its Elasticsearch/OpenSearch form.
|
|
16
|
+
#
|
|
17
|
+
# Instances of this class are immutable--callers must use the provided APIs (`+`, `counts_path`, `nested`)
|
|
18
|
+
# to get back new instances with state changes applied.
|
|
19
|
+
FieldPath = ::Data.define(
|
|
20
|
+
# The path from the overall document root.
|
|
21
|
+
:from_root,
|
|
22
|
+
# The path from the current parent document. Usually `from_parent` and `from_root` are the same,
|
|
23
|
+
# but they'll be different when we encounter a list field indexed using the `nested` mapping type.
|
|
24
|
+
# When we're traversing a subfield of a `nested` field, `from_root` will contain the full path from
|
|
25
|
+
# the original, overall document root, while `from_parent` will contain the path from the current
|
|
26
|
+
# nested document's root.
|
|
27
|
+
:from_parent
|
|
28
|
+
) do
|
|
29
|
+
# @implements FieldPath
|
|
30
|
+
|
|
31
|
+
# Builds an empty instance.
|
|
32
|
+
def self.empty
|
|
33
|
+
new([], [])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.of(parts)
|
|
37
|
+
new(parts, parts)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Used when we encounter a `nested` field to restart the `from_parent` path (while preserving the `from_root` path).
|
|
41
|
+
def nested
|
|
42
|
+
FieldPath.new(from_root, [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Creates a new instance with `sub_path` appended.
|
|
46
|
+
def +(other)
|
|
47
|
+
FieldPath.new(from_root + [other], from_parent + [other])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Converts the current paths to what they need to be to be able to query our hidden `__counts` field (which
|
|
51
|
+
# is a map containing the counts of elements of every list field on the document). The `__counts` field
|
|
52
|
+
# sits a the root of every document (for both an overall root document and a `nested` document). Here's an
|
|
53
|
+
# example (which assumes `seasons` and `seasons.players` fields which are both `nested` and an `awards` field
|
|
54
|
+
# which is a list of strings). Given a filter like this:
|
|
55
|
+
#
|
|
56
|
+
# filter: {seasons: {any_satisfy: {players: {any_satisfy: {results: {awards: {count: {gt: 1}}}}}}}}
|
|
57
|
+
#
|
|
58
|
+
# ...after processing the `awards` key, our `FieldPath` will be:
|
|
59
|
+
#
|
|
60
|
+
# FieldPath.new(["seasons", "players", "results", "awards"], ["results", "awards"])
|
|
61
|
+
#
|
|
62
|
+
# When we then reach the `count` sub field and `counts_path` is called on it, the following will be returned:
|
|
63
|
+
#
|
|
64
|
+
# FieldPath.new(["seasons", "players", LIST_COUNTS_FIELD, "results|awards"], [LIST_COUNTS_FIELD, "results|awards"])
|
|
65
|
+
#
|
|
66
|
+
# This gives us what we want:
|
|
67
|
+
# - The path from the root is `seasons.players.__counts.results|awards`.
|
|
68
|
+
# - The path from the (nested) parent is `__counts.results|awards`.
|
|
69
|
+
#
|
|
70
|
+
# Note that our `__counts` field is a flat map which uses `|` (the `LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR` character)
|
|
71
|
+
# to separate its parts (hence, it's `results|awards` instead of `results.awards`).
|
|
72
|
+
def counts_path
|
|
73
|
+
from_root_to_parent_of_counts_field = from_root[0...-from_parent.size] # : ::Array[::String]
|
|
74
|
+
counts_sub_field = [LIST_COUNTS_FIELD, from_parent.join(LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR)]
|
|
75
|
+
|
|
76
|
+
FieldPath.new(from_root_to_parent_of_counts_field + counts_sub_field, counts_sub_field)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
#
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
module ElasticGraph
|
|
10
|
+
class GraphQL
|
|
11
|
+
module Filtering
|
|
12
|
+
# Responsible for translating a `filter` expression from GraphQL field names to the internal
|
|
13
|
+
# `name_in_index` of each field. This is necessary so that when a field is defined with
|
|
14
|
+
# an alternate `name_in_index`, the query against the index uses that name even while
|
|
15
|
+
# the name in the GraphQL schema is different.
|
|
16
|
+
#
|
|
17
|
+
# In addition, we translate the enum value names to enum value objects, so that any runtime
|
|
18
|
+
# metadata associated with that enum value is available to our `FilterInterpreter`.
|
|
19
|
+
class FilterArgsTranslator < ::Data.define(:filter_arg_name)
|
|
20
|
+
def initialize(schema_element_names:)
|
|
21
|
+
super(filter_arg_name: schema_element_names.filter)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Translates the `filter` expression from the given `args` and `field` into their equivalent
|
|
25
|
+
# form using the `name_in_index` for any fields that are named differently in the index
|
|
26
|
+
# vs GraphQL.
|
|
27
|
+
def translate_filter_args(field:, args:)
|
|
28
|
+
return nil unless (filter_hash = args[filter_arg_name])
|
|
29
|
+
filter_type = field.schema.type_from(field.graphql_field.arguments[filter_arg_name].type)
|
|
30
|
+
convert(filter_type, filter_hash)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def convert(parent_type, filter_object)
|
|
36
|
+
case filter_object
|
|
37
|
+
when ::Hash
|
|
38
|
+
filter_object.to_h do |key, value|
|
|
39
|
+
field = parent_type.field_named(key)
|
|
40
|
+
[field.name_in_index.to_s, convert(field.type.unwrap_fully, value)]
|
|
41
|
+
end
|
|
42
|
+
when ::Array
|
|
43
|
+
filter_object.map { |value| convert(parent_type, value) }
|
|
44
|
+
when nil
|
|
45
|
+
nil
|
|
46
|
+
else
|
|
47
|
+
if parent_type.enum?
|
|
48
|
+
# Replace the name of an enum value with the value itself.
|
|
49
|
+
parent_type.enum_value_named(filter_object)
|
|
50
|
+
else
|
|
51
|
+
filter_object
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|