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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +3 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CLAUDE.md +65 -0
  5. data/LICENSE +21 -0
  6. data/README.md +465 -0
  7. data/Rakefile +10 -0
  8. data/exe/strata +6 -0
  9. data/lib/strata/cli/ai/client.rb +63 -0
  10. data/lib/strata/cli/ai/configuration.rb +48 -0
  11. data/lib/strata/cli/ai/services/table_generator.rb +282 -0
  12. data/lib/strata/cli/api/client.rb +170 -0
  13. data/lib/strata/cli/api/connection_error_handler.rb +54 -0
  14. data/lib/strata/cli/configuration.rb +135 -0
  15. data/lib/strata/cli/credentials.rb +83 -0
  16. data/lib/strata/cli/descriptions/create/migration.txt +25 -0
  17. data/lib/strata/cli/descriptions/create/relation.txt +14 -0
  18. data/lib/strata/cli/descriptions/create/table.txt +23 -0
  19. data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
  20. data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
  21. data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
  22. data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
  23. data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
  24. data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
  25. data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
  26. data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
  27. data/lib/strata/cli/descriptions/init.txt +14 -0
  28. data/lib/strata/cli/generators/datasource.rb +83 -0
  29. data/lib/strata/cli/generators/group.rb +13 -0
  30. data/lib/strata/cli/generators/migration.rb +71 -0
  31. data/lib/strata/cli/generators/project.rb +190 -0
  32. data/lib/strata/cli/generators/relation.rb +64 -0
  33. data/lib/strata/cli/generators/table.rb +143 -0
  34. data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
  35. data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
  36. data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
  37. data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
  38. data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
  39. data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
  40. data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
  41. data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
  42. data/lib/strata/cli/generators/templates/datasources.yml +4 -0
  43. data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
  44. data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
  45. data/lib/strata/cli/generators/templates/project.yml +36 -0
  46. data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
  47. data/lib/strata/cli/generators/templates/strata.yml +24 -0
  48. data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
  49. data/lib/strata/cli/generators/templates/test.yml +34 -0
  50. data/lib/strata/cli/generators/test.rb +48 -0
  51. data/lib/strata/cli/guard.rb +21 -0
  52. data/lib/strata/cli/helpers/color_helper.rb +103 -0
  53. data/lib/strata/cli/helpers/command_context.rb +41 -0
  54. data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
  55. data/lib/strata/cli/helpers/description_helper.rb +18 -0
  56. data/lib/strata/cli/helpers/project_helper.rb +85 -0
  57. data/lib/strata/cli/helpers/prompts.rb +42 -0
  58. data/lib/strata/cli/helpers/table_filter.rb +48 -0
  59. data/lib/strata/cli/main.rb +71 -0
  60. data/lib/strata/cli/sub_commands/audit.rb +262 -0
  61. data/lib/strata/cli/sub_commands/create.rb +419 -0
  62. data/lib/strata/cli/sub_commands/datasource.rb +353 -0
  63. data/lib/strata/cli/sub_commands/deploy.rb +433 -0
  64. data/lib/strata/cli/sub_commands/project.rb +38 -0
  65. data/lib/strata/cli/sub_commands/table.rb +58 -0
  66. data/lib/strata/cli/terminal.rb +102 -0
  67. data/lib/strata/cli/ui/autocomplete.rb +93 -0
  68. data/lib/strata/cli/ui/field_editor.rb +215 -0
  69. data/lib/strata/cli/utils/archive.rb +137 -0
  70. data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
  71. data/lib/strata/cli/utils/git.rb +253 -0
  72. data/lib/strata/cli/utils/import_manager.rb +190 -0
  73. data/lib/strata/cli/utils/test_reporter.rb +131 -0
  74. data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
  75. data/lib/strata/cli/utils.rb +39 -0
  76. data/lib/strata/cli/version.rb +7 -0
  77. data/lib/strata/cli.rb +36 -0
  78. data/sig/strata/cli.rbs +6 -0
  79. 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