strata-cli 0.1.0.beta
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/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +65 -0
- data/LICENSE +21 -0
- data/README.md +465 -0
- data/Rakefile +10 -0
- data/exe/strata +6 -0
- data/lib/strata/cli/ai/client.rb +63 -0
- data/lib/strata/cli/ai/configuration.rb +48 -0
- data/lib/strata/cli/ai/services/table_generator.rb +282 -0
- data/lib/strata/cli/api/client.rb +170 -0
- data/lib/strata/cli/api/connection_error_handler.rb +54 -0
- data/lib/strata/cli/configuration.rb +135 -0
- data/lib/strata/cli/credentials.rb +83 -0
- data/lib/strata/cli/descriptions/create/migration.txt +25 -0
- data/lib/strata/cli/descriptions/create/relation.txt +14 -0
- data/lib/strata/cli/descriptions/create/table.txt +23 -0
- data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
- data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
- data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
- data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
- data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
- data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
- data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
- data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
- data/lib/strata/cli/descriptions/init.txt +14 -0
- data/lib/strata/cli/generators/datasource.rb +83 -0
- data/lib/strata/cli/generators/group.rb +13 -0
- data/lib/strata/cli/generators/migration.rb +71 -0
- data/lib/strata/cli/generators/project.rb +190 -0
- data/lib/strata/cli/generators/relation.rb +64 -0
- data/lib/strata/cli/generators/table.rb +143 -0
- data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
- data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
- data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
- data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
- data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
- data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
- data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
- data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
- data/lib/strata/cli/generators/templates/datasources.yml +4 -0
- data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
- data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
- data/lib/strata/cli/generators/templates/project.yml +36 -0
- data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
- data/lib/strata/cli/generators/templates/strata.yml +24 -0
- data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
- data/lib/strata/cli/generators/templates/test.yml +34 -0
- data/lib/strata/cli/generators/test.rb +48 -0
- data/lib/strata/cli/guard.rb +21 -0
- data/lib/strata/cli/helpers/color_helper.rb +103 -0
- data/lib/strata/cli/helpers/command_context.rb +41 -0
- data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
- data/lib/strata/cli/helpers/description_helper.rb +18 -0
- data/lib/strata/cli/helpers/project_helper.rb +85 -0
- data/lib/strata/cli/helpers/prompts.rb +42 -0
- data/lib/strata/cli/helpers/table_filter.rb +48 -0
- data/lib/strata/cli/main.rb +71 -0
- data/lib/strata/cli/sub_commands/audit.rb +262 -0
- data/lib/strata/cli/sub_commands/create.rb +419 -0
- data/lib/strata/cli/sub_commands/datasource.rb +353 -0
- data/lib/strata/cli/sub_commands/deploy.rb +433 -0
- data/lib/strata/cli/sub_commands/project.rb +38 -0
- data/lib/strata/cli/sub_commands/table.rb +58 -0
- data/lib/strata/cli/terminal.rb +102 -0
- data/lib/strata/cli/ui/autocomplete.rb +93 -0
- data/lib/strata/cli/ui/field_editor.rb +215 -0
- data/lib/strata/cli/utils/archive.rb +137 -0
- data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
- data/lib/strata/cli/utils/git.rb +253 -0
- data/lib/strata/cli/utils/import_manager.rb +190 -0
- data/lib/strata/cli/utils/test_reporter.rb +131 -0
- data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
- data/lib/strata/cli/utils.rb +39 -0
- data/lib/strata/cli/version.rb +7 -0
- data/lib/strata/cli.rb +36 -0
- data/sig/strata/cli.rbs +6 -0
- metadata +306 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
require_relative "../guard"
|
|
2
|
+
require_relative "../credentials"
|
|
3
|
+
require_relative "../terminal"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require_relative "../helpers/datasource_helper"
|
|
6
|
+
require_relative "../helpers/description_helper"
|
|
7
|
+
|
|
8
|
+
module Strata
|
|
9
|
+
module CLI
|
|
10
|
+
module SubCommands
|
|
11
|
+
class Datasource < Thor
|
|
12
|
+
include Thor::Actions
|
|
13
|
+
include Guard
|
|
14
|
+
include Terminal
|
|
15
|
+
include DatasourceHelper
|
|
16
|
+
extend Helpers::DescriptionHelper
|
|
17
|
+
|
|
18
|
+
desc "adapters", "Lists supported data warehouse adapters"
|
|
19
|
+
def adapters
|
|
20
|
+
say "\n\tSupported Adapters\n\n", :yellow
|
|
21
|
+
DWH.adapters.keys.each do
|
|
22
|
+
say "\t\t● #{it}", :magenta
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "list", "List current configured datasources by key and name"
|
|
27
|
+
def list
|
|
28
|
+
ds = begin
|
|
29
|
+
YAML.safe_load_file("datasources.yml", permitted_classes: [Date, Time], aliases: true) || {}
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
{}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if ds.empty?
|
|
35
|
+
say "No datasources configured. Run 'strata datasource add' to add one.", :yellow
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
names = ds.keys.map { "#{it} => #{ds[it]["name"]}" }
|
|
40
|
+
out = "\n #{names.join("\n ")}"
|
|
41
|
+
say out, :magenta
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "add [ADAPTER]", "Add a new datasource interactively"
|
|
45
|
+
long_desc_from_file "datasource/add"
|
|
46
|
+
def add(adapter_name = nil)
|
|
47
|
+
prompt = TTY::Prompt.new
|
|
48
|
+
|
|
49
|
+
if adapter_name && !DWH.adapters.keys.map(&:to_s).include?(adapter_name)
|
|
50
|
+
say "Error: '#{adapter_name}' is not a supported adapter", :red
|
|
51
|
+
say "Supported adapters: #{DWH.adapters.keys.join(", ")}", :yellow
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
adapter = adapter_name || prompt.select("Choose adapter:", DWH.adapters.keys.map(&:to_s))
|
|
56
|
+
default_key = generate_default_ds_key(adapter)
|
|
57
|
+
ds_key = prompt.ask("Datasource key (unique identifier):", default: default_key) do |q|
|
|
58
|
+
q.required true
|
|
59
|
+
q.validate(/\A[a-z_][a-z0-9_]*\z/i,
|
|
60
|
+
"Key must be alphanumeric with underscores, starting with a letter or underscore")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
say "\n Configure #{adapter} datasource (press Enter to accept defaults):\n", :yellow
|
|
64
|
+
|
|
65
|
+
# Collect common fields
|
|
66
|
+
config = {"adapter" => adapter}
|
|
67
|
+
config["name"] = prompt.ask(" Display name:", default: ds_key.upcase)
|
|
68
|
+
config["description"] = prompt.ask(" Description:", default: "#{adapter.capitalize} datasource")
|
|
69
|
+
config["tier"] = prompt.select(" Tier:", %w[hot warm cold], default: "warm")
|
|
70
|
+
config["query_timeout"] = prompt.ask(" Query timeout (seconds):", default: "3600", convert: :int)
|
|
71
|
+
|
|
72
|
+
# Collect adapter-specific fields
|
|
73
|
+
adapter_fields(adapter).each do |field, default_value|
|
|
74
|
+
config[field] = if field == "auth_mode"
|
|
75
|
+
prompt.select(" Authentication mode:", %w[pat kp oauth], default: default_value)
|
|
76
|
+
elsif %w[ssl azure].include?(field)
|
|
77
|
+
prompt.yes?(" #{field.tr("_", " ").capitalize}?", default: default_value)
|
|
78
|
+
elsif field == "port"
|
|
79
|
+
prompt.ask(" #{field.capitalize}:", default: default_value.to_s, convert: :int)
|
|
80
|
+
else
|
|
81
|
+
prompt.ask(" #{humanize_field(field)}:", default: default_value)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
require_relative "../generators/datasource"
|
|
86
|
+
generator = Generators::Datasource.new([adapter, ds_key], options.merge(config: config))
|
|
87
|
+
generator.invoke_all
|
|
88
|
+
|
|
89
|
+
say "\n✔ Added #{adapter} config to datasources.yml", :green
|
|
90
|
+
|
|
91
|
+
# Automatically collect credentials if required
|
|
92
|
+
creds = Credentials.new(adapter)
|
|
93
|
+
if creds.required?
|
|
94
|
+
say "\n Now let's set up credentials:\n", :yellow
|
|
95
|
+
say " Note: Credentials are stored securely in the local .strata file", :cyan
|
|
96
|
+
say " and are NOT committed to the repository (ensured by .gitignore).", :cyan
|
|
97
|
+
say ""
|
|
98
|
+
creds.collect
|
|
99
|
+
creds.write_local(ds_key, self)
|
|
100
|
+
say "\n✔ Credentials saved to .strata file", :green
|
|
101
|
+
else
|
|
102
|
+
say "\n No credentials required for #{adapter}.", :yellow
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# AI Setup
|
|
106
|
+
if ai_not_configured? && prompt.yes?("\n Enable AI-powered features?", default: true)
|
|
107
|
+
collect_ai_config(prompt)
|
|
108
|
+
elsif ai_not_configured?
|
|
109
|
+
say "\n AI features skipped. You can configure them later with 'strata ds auth' or manually in .strata",
|
|
110
|
+
:yellow
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
say "\n✔ Datasource '#{ds_key}' is ready!", :green
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc "auth DS_KEY", "Set credentials for the given datasource key (DS_KEY)."
|
|
117
|
+
long_desc_from_file "datasource/auth"
|
|
118
|
+
method_option :remote, aliases: ["r"], type: :boolean, desc: "Set credentials on remote server"
|
|
119
|
+
def auth(ds_key)
|
|
120
|
+
unless datasources[ds_key]
|
|
121
|
+
say "Error: Datasource '#{ds_key}' not found in datasources.yml", :red
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
adapter = datasources[ds_key]["adapter"]
|
|
126
|
+
creds = Credentials.new(adapter)
|
|
127
|
+
|
|
128
|
+
unless creds.required?
|
|
129
|
+
say "Credentials not required for #{adapter} adapter.", :yellow
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
say "\nEnter credentials for #{ds_key}", :red
|
|
134
|
+
say " Note: Credentials are stored securely in the local .strata file", :cyan
|
|
135
|
+
say " and are NOT committed to the repository (ensured by .gitignore).", :cyan
|
|
136
|
+
say ""
|
|
137
|
+
creds.collect
|
|
138
|
+
creds.write_local(ds_key, self)
|
|
139
|
+
|
|
140
|
+
say "Credentials saved successfully to .strata file.", :green
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
desc "test DS_KEY", "Test connect to the given datasource."
|
|
144
|
+
long_desc_from_file "datasource/test"
|
|
145
|
+
def test(ds_key)
|
|
146
|
+
adapter = create_adapter(ds_key)
|
|
147
|
+
with_spinner("Testing #{ds_key} connection...", success_message: "Connected!",
|
|
148
|
+
failed_message: "Failed to connect.") do
|
|
149
|
+
adapter.test_connection(raise_exception: true)
|
|
150
|
+
end
|
|
151
|
+
rescue => e
|
|
152
|
+
say "\t!! Failed to connect: \n\t#{e.message}", :red
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
desc "tables DS_KEY", "List tables from DS_KEY datasource"
|
|
156
|
+
long_desc_from_file "datasource/tables"
|
|
157
|
+
method_option :pattern, aliases: "p", type: :string, desc: "Regex pattern to filter table list"
|
|
158
|
+
method_option :catalog, aliases: "c", type: :string, desc: "Change the catalog from the configured one."
|
|
159
|
+
method_option :schema, aliases: "s", type: :string, desc: "Change the schema from the configured one."
|
|
160
|
+
def tables(ds_key = nil)
|
|
161
|
+
prompt = TTY::Prompt.new
|
|
162
|
+
ds_key = resolve_datasource(ds_key, prompt: prompt)
|
|
163
|
+
return unless ds_key
|
|
164
|
+
|
|
165
|
+
say "\nListing #{ds_key} tables...\n\n", :yellow
|
|
166
|
+
adapter = create_adapter(ds_key)
|
|
167
|
+
tables = adapter.tables(**options)
|
|
168
|
+
tables = tables.select { it =~ /#{options[:pattern]}/ } if options[:pattern]
|
|
169
|
+
|
|
170
|
+
if tables.empty?
|
|
171
|
+
say "No tables found.", :yellow
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Use interactive list for browsing
|
|
176
|
+
prompt.select("Tables in #{ds_key} (Type to filter):", tables, per_page: 20, filter: true)
|
|
177
|
+
rescue => e
|
|
178
|
+
say "\n\t!!Failed: #{e.message}", :red
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
desc "meta DS_KEY TABLE_NAME", "Show the structure of TABLE_NAME in datasource DS_KEY."
|
|
182
|
+
long_desc_from_file "datasource/meta"
|
|
183
|
+
method_option :catalog, aliases: "c", type: :string, desc: "Change the catalog from the configured one."
|
|
184
|
+
method_option :schema, aliases: "s", type: :string, desc: "Change the schema from the configured one."
|
|
185
|
+
def meta(ds_key, table_name)
|
|
186
|
+
say "\n● Schema for table: #{table_name} (#{ds_key}):\n", :yellow
|
|
187
|
+
adapter = create_adapter(ds_key)
|
|
188
|
+
md = adapter.metadata(table_name, **options.transform_keys { it.to_sym })
|
|
189
|
+
|
|
190
|
+
headings = md.columns.first.to_h.keys
|
|
191
|
+
rows = md.columns.map(&:to_h).map(&:values)
|
|
192
|
+
|
|
193
|
+
say print_table(rows, headers: headings, color: :yellow)
|
|
194
|
+
rescue => e
|
|
195
|
+
say "\n\t!!Failed: #{e.message}", :red
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
desc 'exec DS_KEY -q "select * from my_table"', "Run the given query or queries on DS_KEY"
|
|
199
|
+
long_desc_from_file "datasource/exec"
|
|
200
|
+
method_option :query, aliases: "q", type: :string, desc: "Inline SQL query"
|
|
201
|
+
method_option :file, aliases: "f", type: :string, desc: "SQL query from file"
|
|
202
|
+
def exec(ds_key)
|
|
203
|
+
adapter = create_adapter(ds_key)
|
|
204
|
+
if options[:file]
|
|
205
|
+
file_path = validate_file_path(options[:file])
|
|
206
|
+
queries = File.read(file_path).split(";").reject { it.nil? || it.strip == "" }
|
|
207
|
+
elsif options[:query]
|
|
208
|
+
queries = options[:query].split(";").reject { it.nil? || it.strip == "" }
|
|
209
|
+
else
|
|
210
|
+
raise StrataError, "Either a file (-f) or a query (-q) should b submitted."
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
queries.each_with_index do |query, index|
|
|
214
|
+
puts ""
|
|
215
|
+
res = with_spinner("running #{index + 1}/#{queries.length} queries...") do
|
|
216
|
+
adapter.execute(query, format: :object)
|
|
217
|
+
end
|
|
218
|
+
print_table(res.map(&:values), headers: res.first.keys)
|
|
219
|
+
end
|
|
220
|
+
rescue => e
|
|
221
|
+
say "\n\t!!Failed: #{e.message}", :red
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def validate_file_path(file_path, project_path = Dir.pwd)
|
|
227
|
+
expanded = File.expand_path(file_path, project_path)
|
|
228
|
+
project_root = File.expand_path(project_path)
|
|
229
|
+
unless expanded.start_with?(project_root)
|
|
230
|
+
raise Strata::CommandError, "File path must be within project directory"
|
|
231
|
+
end
|
|
232
|
+
expanded
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def generate_default_ds_key(adapter)
|
|
236
|
+
existing_ds = begin
|
|
237
|
+
YAML.safe_load_file("datasources.yml", permitted_classes: [Date, Time], aliases: true) || {}
|
|
238
|
+
rescue Errno::ENOENT
|
|
239
|
+
{}
|
|
240
|
+
end
|
|
241
|
+
base_key = adapter.downcase
|
|
242
|
+
key_id = 1
|
|
243
|
+
ds_key = base_key
|
|
244
|
+
while existing_ds.key?(ds_key)
|
|
245
|
+
ds_key = "#{base_key}_#{key_id}"
|
|
246
|
+
key_id += 1
|
|
247
|
+
end
|
|
248
|
+
ds_key
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def adapter_fields(adapter)
|
|
252
|
+
case adapter
|
|
253
|
+
when "snowflake"
|
|
254
|
+
{
|
|
255
|
+
"account_identifier" => "myorg-myaccount",
|
|
256
|
+
"database" => "ANALYTICS_DB",
|
|
257
|
+
"warehouse" => "COMPUTE_WH",
|
|
258
|
+
"schema" => "PUBLIC",
|
|
259
|
+
"role" => "ACCOUNTADMIN",
|
|
260
|
+
"auth_mode" => "pat"
|
|
261
|
+
}
|
|
262
|
+
when "postgres"
|
|
263
|
+
{
|
|
264
|
+
"host" => "localhost",
|
|
265
|
+
"port" => 5432,
|
|
266
|
+
"database" => "mydb",
|
|
267
|
+
"username" => "postgres",
|
|
268
|
+
"schema" => "public",
|
|
269
|
+
"ssl" => false
|
|
270
|
+
}
|
|
271
|
+
when "mysql"
|
|
272
|
+
{
|
|
273
|
+
"host" => "127.0.0.1",
|
|
274
|
+
"port" => 3306,
|
|
275
|
+
"database" => "mydb",
|
|
276
|
+
"username" => "root",
|
|
277
|
+
"ssl" => false
|
|
278
|
+
}
|
|
279
|
+
when "trino"
|
|
280
|
+
{
|
|
281
|
+
"host" => "localhost",
|
|
282
|
+
"port" => 8080,
|
|
283
|
+
"catalog" => "native",
|
|
284
|
+
"username" => "strata_user",
|
|
285
|
+
"ssl" => false
|
|
286
|
+
}
|
|
287
|
+
when "athena"
|
|
288
|
+
{
|
|
289
|
+
"region" => "us-east-1",
|
|
290
|
+
"s3_output_location" => "s3://your-athena-results-bucket/queries/",
|
|
291
|
+
"database" => "default",
|
|
292
|
+
"catalog" => "awsdatacatalog"
|
|
293
|
+
}
|
|
294
|
+
when "duckdb"
|
|
295
|
+
{
|
|
296
|
+
"file" => "./data/warehouse.db"
|
|
297
|
+
}
|
|
298
|
+
when "sqlserver"
|
|
299
|
+
{
|
|
300
|
+
"host" => "localhost",
|
|
301
|
+
"port" => 1433,
|
|
302
|
+
"database" => "mydb",
|
|
303
|
+
"username" => "sa",
|
|
304
|
+
"azure" => false
|
|
305
|
+
}
|
|
306
|
+
when "druid"
|
|
307
|
+
{
|
|
308
|
+
"protocol" => "http",
|
|
309
|
+
"host" => "localhost",
|
|
310
|
+
"port" => 7103
|
|
311
|
+
}
|
|
312
|
+
else
|
|
313
|
+
{}
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def humanize_field(field)
|
|
318
|
+
{
|
|
319
|
+
"account_identifier" => "Account identifier",
|
|
320
|
+
"s3_output_location" => "S3 output location",
|
|
321
|
+
"auth_mode" => "Auth mode"
|
|
322
|
+
}.fetch(field, field.split("_").map(&:capitalize).join(" "))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def ai_not_configured?
|
|
326
|
+
ai_key = CLI.config["ai_api_key"]
|
|
327
|
+
ai_key.nil? || ai_key.empty?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def collect_ai_config(prompt)
|
|
331
|
+
require_relative "../ai/configuration"
|
|
332
|
+
|
|
333
|
+
provider = prompt.select(" AI Provider:", AI::Configuration::PROVIDERS)
|
|
334
|
+
|
|
335
|
+
api_key = prompt.mask(" #{provider.capitalize} API Key:")
|
|
336
|
+
|
|
337
|
+
return if api_key.nil? || api_key.empty?
|
|
338
|
+
|
|
339
|
+
# Append AI config to .strata file
|
|
340
|
+
ai_config = "\nai_provider: #{provider}\nai_api_key: #{api_key}\n"
|
|
341
|
+
append_to_file Configuration::STRATA_CONFIG_FILE, ai_config
|
|
342
|
+
|
|
343
|
+
# Set restrictive permissions (read/write for owner only)
|
|
344
|
+
strata_file = Configuration::STRATA_CONFIG_FILE
|
|
345
|
+
File.chmod(0o600, strata_file) if File.exist?(strata_file)
|
|
346
|
+
|
|
347
|
+
say "\n✔ AI configured with #{provider}", :green
|
|
348
|
+
say " Note: API key is stored securely in .strata (not committed to repo)", :cyan
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|