dbee 2.0.2 → 3.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 +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +14 -10
- data/.ruby-version +1 -1
- data/.travis.yml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +257 -43
- data/dbee.gemspec +17 -6
- data/exe/.gitkeep +0 -0
- data/lib/dbee.rb +10 -10
- data/lib/dbee/base.rb +7 -49
- data/lib/dbee/constant_resolver.rb +34 -0
- data/lib/dbee/dsl/association.rb +8 -19
- data/lib/dbee/dsl_schema_builder.rb +86 -0
- data/lib/dbee/key_chain.rb +11 -0
- data/lib/dbee/model.rb +50 -38
- data/lib/dbee/model/relationships.rb +24 -0
- data/lib/dbee/model/relationships/basic.rb +47 -0
- data/lib/dbee/query.rb +30 -24
- data/lib/dbee/query/field.rb +35 -5
- data/lib/dbee/schema.rb +66 -0
- data/lib/dbee/schema_creator.rb +107 -0
- data/lib/dbee/schema_from_tree_based_model.rb +47 -0
- data/lib/dbee/util/make_keyed_by.rb +50 -0
- data/lib/dbee/version.rb +1 -1
- data/spec/dbee/base_spec.rb +9 -72
- data/spec/dbee/constant_resolver_spec.rb +58 -0
- data/spec/dbee/dsl_schema_builder_spec.rb +106 -0
- data/spec/dbee/key_chain_spec.rb +24 -0
- data/spec/dbee/model/constraints_spec.rb +6 -7
- data/spec/dbee/model_spec.rb +62 -59
- data/spec/dbee/query/field_spec.rb +54 -6
- data/spec/dbee/query/filters_spec.rb +16 -17
- data/spec/dbee/query_spec.rb +55 -62
- data/spec/dbee/schema_creator_spec.rb +163 -0
- data/spec/dbee/schema_from_tree_based_model_spec.rb +31 -0
- data/spec/dbee/schema_spec.rb +62 -0
- data/spec/dbee_spec.rb +17 -37
- data/spec/fixtures/models.yaml +254 -56
- data/spec/spec_helper.rb +7 -0
- metadata +83 -18
data/dbee.gemspec
CHANGED
@@ -15,20 +15,31 @@ Gem::Specification.new do |s|
|
|
15
15
|
s.email = ['mruggio@bluemarblepayroll.com']
|
16
16
|
s.files = `git ls-files`.split("\n")
|
17
17
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
-
s.
|
18
|
+
s.bindir = 'exe'
|
19
|
+
s.executables = []
|
19
20
|
s.homepage = 'https://github.com/bluemarblepayroll/dbee'
|
20
21
|
s.license = 'MIT'
|
22
|
+
s.metadata = {
|
23
|
+
'bug_tracker_uri' => 'https://github.com/bluemarblepayroll/dbee/issues',
|
24
|
+
'changelog_uri' => 'https://github.com/bluemarblepayroll/dbee/blob/master/CHANGELOG.md',
|
25
|
+
'documentation_uri' => 'https://www.rubydoc.info/gems/dbee',
|
26
|
+
'homepage_uri' => s.homepage,
|
27
|
+
'source_code_uri' => s.homepage
|
28
|
+
}
|
21
29
|
|
22
|
-
s.required_ruby_version = '>= 2.
|
30
|
+
s.required_ruby_version = '>= 2.5'
|
23
31
|
|
24
|
-
s.add_dependency('acts_as_hashable', '~>1', '>=1.
|
32
|
+
s.add_dependency('acts_as_hashable', '~>1', '>=1.2.0')
|
25
33
|
s.add_dependency('dry-inflector', '~>0')
|
26
34
|
|
27
35
|
s.add_development_dependency('guard-rspec', '~>4.7')
|
28
36
|
s.add_development_dependency('pry', '~>0')
|
37
|
+
s.add_development_dependency('pry-byebug')
|
29
38
|
s.add_development_dependency('rake', '~> 13')
|
30
39
|
s.add_development_dependency('rspec')
|
31
|
-
s.add_development_dependency('rubocop', '~>
|
32
|
-
s.add_development_dependency('
|
33
|
-
s.add_development_dependency('
|
40
|
+
s.add_development_dependency('rubocop', '~> 1')
|
41
|
+
s.add_development_dependency('rubocop-rake')
|
42
|
+
s.add_development_dependency('rubocop-rspec')
|
43
|
+
s.add_development_dependency('simplecov', '~>0.19.0')
|
44
|
+
s.add_development_dependency('simplecov-console', '~>0.7.0')
|
34
45
|
end
|
data/exe/.gitkeep
ADDED
File without changes
|
data/lib/dbee.rb
CHANGED
@@ -12,11 +12,16 @@ require 'dry/inflector'
|
|
12
12
|
require 'forwardable'
|
13
13
|
|
14
14
|
require_relative 'dbee/base'
|
15
|
+
require_relative 'dbee/constant_resolver'
|
16
|
+
require_relative 'dbee/dsl_schema_builder'
|
15
17
|
require_relative 'dbee/key_chain'
|
16
18
|
require_relative 'dbee/key_path'
|
17
19
|
require_relative 'dbee/model'
|
18
|
-
require_relative 'dbee/query'
|
19
20
|
require_relative 'dbee/providers'
|
21
|
+
require_relative 'dbee/query'
|
22
|
+
require_relative 'dbee/schema'
|
23
|
+
require_relative 'dbee/schema_creator'
|
24
|
+
require_relative 'dbee/schema_from_tree_based_model'
|
20
25
|
|
21
26
|
# Top-level namespace that provides the main public API.
|
22
27
|
module Dbee
|
@@ -30,16 +35,11 @@ module Dbee
|
|
30
35
|
@inflector ||= Dry::Inflector.new
|
31
36
|
end
|
32
37
|
|
33
|
-
def sql(
|
34
|
-
|
35
|
-
model =
|
36
|
-
if model.is_a?(Hash) || model.is_a?(Model)
|
37
|
-
Model.make(model)
|
38
|
-
else
|
39
|
-
model.to_model(query.key_chain)
|
40
|
-
end
|
38
|
+
def sql(schema_or_model, query_input, provider)
|
39
|
+
raise ArgumentError, 'a provider is required' unless provider
|
41
40
|
|
42
|
-
|
41
|
+
schema_compat = SchemaCreator.new(schema_or_model, query_input)
|
42
|
+
provider.sql(schema_compat.schema, schema_compat.query)
|
43
43
|
end
|
44
44
|
end
|
45
45
|
end
|
data/lib/dbee/base.rb
CHANGED
@@ -7,8 +7,8 @@
|
|
7
7
|
# LICENSE file in the root directory of this source tree.
|
8
8
|
#
|
9
9
|
|
10
|
-
require_relative 'dsl/association'
|
11
10
|
require_relative 'dsl/association_builder'
|
11
|
+
require_relative 'dsl/association'
|
12
12
|
require_relative 'dsl/methods'
|
13
13
|
require_relative 'dsl/reflectable'
|
14
14
|
|
@@ -22,23 +22,9 @@ module Dbee
|
|
22
22
|
BASE_CLASS_CONSTANT = Dbee::Base
|
23
23
|
|
24
24
|
class << self
|
25
|
-
#
|
26
|
-
|
27
|
-
|
28
|
-
# of a Query. This is not true for configuration-first Model definitions because, in that
|
29
|
-
# case, cycles do not exist since the nature of the configuration is flat.
|
30
|
-
def to_model(key_chain, name = nil, constraints = [], path_parts = [])
|
31
|
-
derived_name = name.to_s.empty? ? inflected_class_name(self.name) : name.to_s
|
32
|
-
key = [key_chain, derived_name, constraints, path_parts]
|
33
|
-
|
34
|
-
to_models[key] ||= Model.make(
|
35
|
-
model_config(
|
36
|
-
key_chain,
|
37
|
-
derived_name,
|
38
|
-
constraints,
|
39
|
-
path_parts + [name]
|
40
|
-
)
|
41
|
-
)
|
25
|
+
# Returns the smallest needed Dbee::Schema for the provided key_chain.
|
26
|
+
def to_schema(key_chain)
|
27
|
+
DslSchemaBuilder.new(self, key_chain).to_schema
|
42
28
|
end
|
43
29
|
|
44
30
|
def inherited_table_name
|
@@ -58,43 +44,15 @@ module Dbee
|
|
58
44
|
end
|
59
45
|
end
|
60
46
|
|
61
|
-
|
62
|
-
|
63
|
-
def model_config(key_chain, name, constraints, path_parts)
|
64
|
-
{
|
65
|
-
constraints: constraints,
|
66
|
-
models: associations(key_chain, path_parts),
|
67
|
-
name: name,
|
68
|
-
partitioners: inherited_partitioners,
|
69
|
-
table: inherited_table_name
|
70
|
-
}
|
71
|
-
end
|
72
|
-
|
73
|
-
def associations(key_chain, path_parts)
|
74
|
-
inherited_associations.select { |c| key_chain.ancestor_path?(path_parts, c.name) }
|
75
|
-
.map do |association|
|
76
|
-
model_constant = association.model_constant
|
77
|
-
|
78
|
-
model_constant.to_model(
|
79
|
-
key_chain,
|
80
|
-
association.name,
|
81
|
-
association.constraints,
|
82
|
-
path_parts
|
83
|
-
)
|
84
|
-
end
|
47
|
+
def inflected_class_name
|
48
|
+
inflector.underscore(inflector.demodulize(name))
|
85
49
|
end
|
86
50
|
|
87
|
-
|
88
|
-
@to_models ||= {}
|
89
|
-
end
|
51
|
+
private
|
90
52
|
|
91
53
|
def inflected_table_name(name)
|
92
54
|
inflector.pluralize(inflector.underscore(inflector.demodulize(name)))
|
93
55
|
end
|
94
|
-
|
95
|
-
def inflected_class_name(name)
|
96
|
-
inflector.underscore(inflector.demodulize(name))
|
97
|
-
end
|
98
56
|
end
|
99
57
|
end
|
100
58
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Dbee
|
11
|
+
# This class is responsible for turning strings and symbols into constants.
|
12
|
+
# It does not deal with inflection, simply just constant resolution.
|
13
|
+
class ConstantResolver
|
14
|
+
# Only use Module constant resolution if a string or symbol was passed in.
|
15
|
+
# Any other type is defined as an acceptable constant and is simply returned.
|
16
|
+
def constantize(value)
|
17
|
+
value.is_a?(String) || value.is_a?(Symbol) ? object_constant(value) : value
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
# If the constant has been loaded, we can safely use it through const_get.
|
23
|
+
# If the constant has not been loaded, we need to defer to const_missing to resolve it.
|
24
|
+
# If we blindly call const_get, it may return false positives for namespaced constants
|
25
|
+
# or anything nested.
|
26
|
+
def object_constant(value)
|
27
|
+
if Object.const_defined?(value, false)
|
28
|
+
Object.const_get(value, false)
|
29
|
+
else
|
30
|
+
Object.const_missing(value)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/dbee/dsl/association.rb
CHANGED
@@ -19,16 +19,17 @@ module Dbee
|
|
19
19
|
raise ArgumentError, 'inflector is required' unless inflector
|
20
20
|
raise ArgumentError, 'name is required' if name.to_s.empty?
|
21
21
|
|
22
|
-
@on_class_name
|
23
|
-
@inflector
|
24
|
-
@name
|
25
|
-
@opts
|
22
|
+
@on_class_name = on_class_name
|
23
|
+
@inflector = inflector
|
24
|
+
@name = name.to_s
|
25
|
+
@opts = opts || {}
|
26
|
+
@constant_resolver = ConstantResolver.new
|
26
27
|
|
27
28
|
freeze
|
28
29
|
end
|
29
30
|
|
30
31
|
def model_constant
|
31
|
-
constantize(class_name)
|
32
|
+
constant_resolver.constantize(class_name)
|
32
33
|
end
|
33
34
|
|
34
35
|
def constraints
|
@@ -37,6 +38,8 @@ module Dbee
|
|
37
38
|
|
38
39
|
private
|
39
40
|
|
41
|
+
attr_reader :constant_resolver
|
42
|
+
|
40
43
|
def class_name
|
41
44
|
opts[:model] || relative_class_name
|
42
45
|
end
|
@@ -47,20 +50,6 @@ module Dbee
|
|
47
50
|
def relative_class_name
|
48
51
|
(on_class_name.split('::')[0...-1] + [inflector.classify(name)]).join('::')
|
49
52
|
end
|
50
|
-
|
51
|
-
# Only use Module constant resolution if a string or symbol was passed in.
|
52
|
-
# Any other type is defined as an acceptable constant and is simply returned.
|
53
|
-
def constantize(value)
|
54
|
-
value.is_a?(String) || value.is_a?(Symbol) ? object_constant(value) : value
|
55
|
-
end
|
56
|
-
|
57
|
-
# If the constant has been loaded, we can safely use it through const_get.
|
58
|
-
# If the constant has not been loaded, we need to defer to const_missing to resolve it.
|
59
|
-
# If we blindly call const_get, it may return false positives for namespaced constants
|
60
|
-
# or anything nested.
|
61
|
-
def object_constant(value)
|
62
|
-
Object.const_defined?(value) ? Object.const_get(value) : Object.const_missing(value)
|
63
|
-
end
|
64
53
|
end
|
65
54
|
end
|
66
55
|
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Dbee
|
11
|
+
# Builds a Dbee::Schema given a Dbee::Base (DSL) model. Note that this class
|
12
|
+
# does not exist in the "Dsl" module as it is not used for defining the DSL
|
13
|
+
# itself.
|
14
|
+
class DslSchemaBuilder # :nodoc:
|
15
|
+
attr_reader :dsl_model, :key_chain
|
16
|
+
|
17
|
+
def initialize(dsl_model, key_chain)
|
18
|
+
@dsl_model = dsl_model || ArgumentError('dsl_model is required')
|
19
|
+
@key_chain = key_chain || ArgumentError('key_chain is required')
|
20
|
+
|
21
|
+
freeze
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_schema
|
25
|
+
schema_spec = { dsl_model.inflected_class_name => model_config(dsl_model) }
|
26
|
+
|
27
|
+
ancestor_paths(key_chain).each do |key_path|
|
28
|
+
start_model = dsl_model
|
29
|
+
|
30
|
+
key_path.ancestor_names.each do |association_name|
|
31
|
+
start_model = append_model_and_relationship(schema_spec, start_model, association_name)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
Schema.new(schema_spec)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def model_config(dsl_model) #:nodoc:
|
41
|
+
{
|
42
|
+
name: dsl_model.inflected_class_name,
|
43
|
+
partitioners: dsl_model.inherited_partitioners,
|
44
|
+
table: dsl_model.inherited_table_name,
|
45
|
+
relationships: {}
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def append_model_and_relationship(schema_spec, base_model, association_name)
|
50
|
+
association = find_association!(base_model, association_name)
|
51
|
+
target_model = association.model_constant
|
52
|
+
|
53
|
+
schema_spec[target_model.inflected_class_name] ||= model_config(target_model)
|
54
|
+
|
55
|
+
schema_spec[base_model.inflected_class_name][:relationships][association.name] = {
|
56
|
+
constraints: association.constraints,
|
57
|
+
model: relationship_model_name(association, target_model)
|
58
|
+
}
|
59
|
+
|
60
|
+
target_model
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns a unique list of ancestor paths from a key_chain. Omits any
|
64
|
+
# fields on the base model.
|
65
|
+
def ancestor_paths(key_chain)
|
66
|
+
key_chain.to_unique_ancestors.key_path_set.select do |key_path|
|
67
|
+
key_path.ancestor_names.any?
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def find_association!(base_model, association_name)
|
72
|
+
base_model.inherited_associations.find { |assoc| assoc.name == association_name } \
|
73
|
+
||
|
74
|
+
raise(
|
75
|
+
ArgumentError,
|
76
|
+
"no association #{association_name} exists on model #{base_model.name}"
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
def relationship_model_name(association, target_model)
|
81
|
+
return nil if association.name == target_model.inflected_class_name
|
82
|
+
|
83
|
+
target_model.inflected_class_name
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
data/lib/dbee/key_chain.rb
CHANGED
@@ -38,5 +38,16 @@ module Dbee
|
|
38
38
|
|
39
39
|
ancestor_path_set.include?(path)
|
40
40
|
end
|
41
|
+
|
42
|
+
# Returns a unique set of ancestors by considering all column names to be the same.
|
43
|
+
def to_unique_ancestors # :nodoc:
|
44
|
+
normalized_paths = key_path_set.map do |kp|
|
45
|
+
KeyPath.new((kp.ancestor_names + COLUMN_PLACEHOLDER).join(KeyPath::SPLIT_CHAR))
|
46
|
+
end
|
47
|
+
self.class.new(normalized_paths.uniq)
|
48
|
+
end
|
49
|
+
|
50
|
+
COLUMN_PLACEHOLDER = ['any_column'].freeze
|
51
|
+
private_constant :COLUMN_PLACEHOLDER
|
41
52
|
end
|
42
53
|
end
|
data/lib/dbee/model.rb
CHANGED
@@ -9,65 +9,53 @@
|
|
9
9
|
|
10
10
|
require_relative 'model/constraints'
|
11
11
|
require_relative 'model/partitioner'
|
12
|
+
require_relative 'model/relationships'
|
13
|
+
require_relative 'util/make_keyed_by'
|
12
14
|
|
13
15
|
module Dbee
|
14
16
|
# In DB terms, a Model is usually a table, but it does not have to be. You can also re-model
|
15
17
|
# your DB schema using Dbee::Models.
|
16
18
|
class Model
|
17
|
-
extend Forwardable
|
18
19
|
acts_as_hashable
|
20
|
+
extend Dbee::Util::MakeKeyedBy
|
21
|
+
extend Forwardable
|
19
22
|
|
20
23
|
class ModelNotFoundError < StandardError; end
|
21
24
|
|
22
|
-
attr_reader :constraints, :filters, :name, :partitioners, :table
|
25
|
+
attr_reader :constraints, :filters, :name, :partitioners, :relationships, :table
|
23
26
|
|
24
27
|
def_delegator :models_by_name, :values, :models
|
25
28
|
def_delegator :models, :sort, :sorted_models
|
26
29
|
def_delegator :constraints, :sort, :sorted_constraints
|
27
30
|
def_delegator :partitioners, :sort, :sorted_partitioners
|
28
31
|
|
29
|
-
def initialize(
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
32
|
+
def initialize(
|
33
|
+
name:,
|
34
|
+
constraints: [], # Exists here for tree based model backward compatibility.
|
35
|
+
relationships: [],
|
36
|
+
models: [], # Exists here for tree based model backward compatibility.
|
37
|
+
partitioners: [],
|
38
|
+
table: ''
|
39
|
+
)
|
40
|
+
@name = name
|
41
|
+
@constraints = Constraints.array(constraints || []).uniq
|
42
|
+
@relationships = Relationships.make_keyed_by(:name, relationships)
|
34
43
|
@models_by_name = name_hash(Model.array(models))
|
35
44
|
@partitioners = Partitioner.array(partitioners).uniq
|
36
45
|
@table = table.to_s.empty? ? @name : table.to_s
|
37
46
|
|
47
|
+
ensure_input_is_valid
|
48
|
+
|
38
49
|
freeze
|
39
50
|
end
|
40
51
|
|
41
|
-
|
42
|
-
|
43
|
-
# The hash key will be an array of strings (model names) and the value will be the
|
44
|
-
# identified model.
|
45
|
-
def ancestors!(parts = [], visited_parts = [], found = {})
|
46
|
-
return found if Array(parts).empty?
|
47
|
-
|
48
|
-
# Take the first entry in parts
|
49
|
-
model_name = parts.first.to_s
|
50
|
-
|
51
|
-
# Ensure we have it registered as a child, or raise error
|
52
|
-
model = assert_model(model_name, visited_parts)
|
53
|
-
|
54
|
-
# Push onto visited list
|
55
|
-
visited_parts += [model_name]
|
56
|
-
|
57
|
-
# Add found model to flattened structure
|
58
|
-
found[visited_parts] = model
|
59
|
-
|
60
|
-
# Recursively call for next parts in the chain
|
61
|
-
model.ancestors!(parts[1..-1], visited_parts, found)
|
52
|
+
def relationship_for_name(relationship_name)
|
53
|
+
relationships[relationship_name]
|
62
54
|
end
|
63
55
|
|
64
56
|
def ==(other)
|
65
57
|
other.instance_of?(self.class) &&
|
66
|
-
other.name == name &&
|
67
|
-
other.table == table &&
|
68
|
-
other.sorted_constraints == sorted_constraints &&
|
69
|
-
other.sorted_partitioners == sorted_partitioners &&
|
70
|
-
other.sorted_models == sorted_models
|
58
|
+
other.name == name && other.table == table && children_are_equal(other)
|
71
59
|
end
|
72
60
|
alias eql? ==
|
73
61
|
|
@@ -75,17 +63,41 @@ module Dbee
|
|
75
63
|
name <=> other.name
|
76
64
|
end
|
77
65
|
|
66
|
+
def hash
|
67
|
+
[
|
68
|
+
name.hash,
|
69
|
+
table.hash,
|
70
|
+
relationships.hash,
|
71
|
+
sorted_constraints.hash,
|
72
|
+
sorted_partitioners.hash,
|
73
|
+
sorted_models.hash
|
74
|
+
].hash
|
75
|
+
end
|
76
|
+
|
77
|
+
def to_s
|
78
|
+
name
|
79
|
+
end
|
80
|
+
|
78
81
|
private
|
79
82
|
|
80
83
|
attr_reader :models_by_name
|
81
84
|
|
82
|
-
def assert_model(model_name, visited_parts)
|
83
|
-
models_by_name[model_name] ||
|
84
|
-
raise(ModelNotFoundError, "Missing: #{model_name}, after: #{visited_parts}")
|
85
|
-
end
|
86
|
-
|
87
85
|
def name_hash(array)
|
88
86
|
array.map { |a| [a.name, a] }.to_h
|
89
87
|
end
|
88
|
+
|
89
|
+
def children_are_equal(other)
|
90
|
+
other.relationships == relationships &&
|
91
|
+
other.sorted_constraints == sorted_constraints &&
|
92
|
+
other.sorted_partitioners == sorted_partitioners &&
|
93
|
+
other.sorted_models == sorted_models
|
94
|
+
end
|
95
|
+
|
96
|
+
def ensure_input_is_valid
|
97
|
+
raise ArgumentError, 'name is required' if name.to_s.empty?
|
98
|
+
|
99
|
+
constraints&.any? && relationships&.any? && \
|
100
|
+
raise(ArgumentError, 'constraints and relationships are mutually exclusive')
|
101
|
+
end
|
90
102
|
end
|
91
103
|
end
|