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,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guard"
4
+ require_relative "../terminal"
5
+ require_relative "../ui/autocomplete"
6
+ require_relative "../ui/field_editor"
7
+ require_relative "../credentials"
8
+ require_relative "../helpers/table_filter"
9
+ require_relative "../ai/services/table_generator"
10
+ require_relative "../helpers/color_helper"
11
+ require_relative "../helpers/prompts"
12
+ require_relative "../helpers/description_helper"
13
+ require_relative "../helpers/command_context"
14
+ require "tty-prompt"
15
+ require "tty-spinner"
16
+ require "yaml"
17
+ require "dwh"
18
+ require "fileutils"
19
+
20
+ module Strata
21
+ module CLI
22
+ module SubCommands
23
+ class Create < Thor
24
+ include Guard
25
+ include Terminal
26
+ include Prompts
27
+ include Thor::Actions
28
+ include Helpers::CommandContext
29
+ extend Helpers::DescriptionHelper
30
+
31
+ desc "table TABLE_PATH", "Create a semantic table model"
32
+ long_desc_from_file("create/table")
33
+
34
+ method_option :datasource, aliases: "-d", type: :string, required: false,
35
+ desc: "Datasource key from datasources.yml"
36
+
37
+ def table(table_path = nil)
38
+ return unless datasource_key
39
+
40
+ # If table_path provided, skip search for speed
41
+ if table_path
42
+ handle_table_creation_with_path(table_path)
43
+ else
44
+ handle_table_creation_interactive
45
+ end
46
+ end
47
+
48
+ desc "relation RELATION_PATH", "Create a relation (join) definition file"
49
+ long_desc_from_file("create/relation")
50
+
51
+ method_option :datasource, aliases: "-d", type: :string, required: false,
52
+ desc: "Datasource key from datasources.yml"
53
+
54
+ def relation(relation_path)
55
+ return unless datasource_key
56
+
57
+ create_relation_file(relation_path)
58
+ end
59
+
60
+ desc "migration SUBCOMMAND(rename or swap)", "Create migration files (rename or swap)"
61
+ long_desc_from_file "create/migration"
62
+ method_option :entity, aliases: "-e", type: :string, required: false,
63
+ desc: "Entity type: dimension, measure, table, or datasource"
64
+ method_option :from, aliases: "-f", type: :string, required: false,
65
+ desc: "Current/source entity name"
66
+ method_option :to, aliases: "-t", type: :string, required: false,
67
+ desc: "New/target entity name"
68
+
69
+ def migration(subcommand)
70
+ case subcommand
71
+ when "rename"
72
+ migration_rename
73
+ when "swap"
74
+ migration_swap
75
+ else
76
+ raise Strata::CommandError, "Unknown migration subcommand: #{subcommand}. Use 'rename' or 'swap'."
77
+ end
78
+ end
79
+
80
+ desc "migration rename", "Create a migration file to rename an entity"
81
+ method_option :entity, aliases: "-e", type: :string, required: true,
82
+ desc: "Entity type: dimension, measure, table, or datasource"
83
+ method_option :from, aliases: "-f", type: :string, required: true,
84
+ desc: "Current entity name"
85
+ method_option :to, aliases: "-t", type: :string, required: true,
86
+ desc: "New entity name"
87
+
88
+ def migration_rename
89
+ validate_entity_type(options[:entity], "rename")
90
+ validate_required_params(options[:entity], options[:from], options[:to])
91
+
92
+ hook = prompt_migration_hook("rename")
93
+ create_rename_migration(options[:entity], options[:from], options[:to], hook)
94
+ end
95
+
96
+ desc "migration swap", "Create a migration file to swap entity references"
97
+ method_option :entity, aliases: "-e", type: :string, required: true,
98
+ desc: "Entity type: dimension, measure, or table"
99
+ method_option :from, aliases: "-f", type: :string, required: true,
100
+ desc: "Source entity name"
101
+ method_option :to, aliases: "-t", type: :string, required: true,
102
+ desc: "Target entity name"
103
+
104
+ def migration_swap
105
+ validate_entity_type(options[:entity], "swap")
106
+ validate_required_params(options[:entity], options[:from], options[:to])
107
+
108
+ hook = prompt_migration_hook("swap")
109
+ create_swap_migration(options[:entity], options[:from], options[:to], hook)
110
+ end
111
+
112
+ private
113
+
114
+ def handle_table_creation_with_path(table_path)
115
+ # Parse the path to extract schema, physical name, and model path
116
+ parsed = parse_table_path(table_path)
117
+
118
+ # Try to find the table in the database
119
+ table_found = find_table_in_database(parsed[:physical_name], parsed[:schema])
120
+
121
+ if table_found
122
+ proceed_with_table_creation(table_found, parsed)
123
+ else
124
+ handle_table_not_found(parsed)
125
+ end
126
+ end
127
+
128
+ def handle_table_creation_interactive
129
+ return if all_tables.empty?
130
+
131
+ selected_table = resolve_table_selection(nil)
132
+ return unless selected_table
133
+
134
+ # For interactive, derive path from table name
135
+ default_path = TableFilter.display_name(selected_table).downcase.gsub(/\s+/, "_")
136
+ table_path = prompt.ask(" Table path (e.g., games/event_details):", default: default_path)
137
+ return unless table_path
138
+
139
+ parsed = parse_table_path(table_path)
140
+ proceed_with_table_creation(selected_table, parsed)
141
+ end
142
+
143
+ def parse_table_path(path)
144
+ # Examples:
145
+ # "call_center" -> { model_path: "call_center", physical_name: "call_center", schema: nil }
146
+ # "games/event_details" -> { model_path: "games/event_details", physical_name: "event_details", schema: nil }
147
+ # "contact/dse.call_center_d" -> { model_path: "contact/dse.call_center_d", physical_name: "call_center_d", schema: "dse" }
148
+ # "contact/help_center/visits" -> { model_path: "contact/help_center/visits", physical_name: "visits", schema: nil }
149
+
150
+ path_parts = path.split("/")
151
+ last_part = path_parts.last
152
+
153
+ # Check if last part contains schema (e.g., "dse.call_center_d")
154
+ schema = nil
155
+ physical_name = last_part
156
+
157
+ if last_part.include?(".")
158
+ parts = last_part.split(".", 2)
159
+ schema = parts[0]
160
+ physical_name = parts[1]
161
+ end
162
+
163
+ {
164
+ model_path: path,
165
+ physical_name: physical_name.downcase,
166
+ schema: schema&.downcase
167
+ }
168
+ end
169
+
170
+ def find_table_in_database(physical_name, schema)
171
+ # Try to fetch metadata directly (skip table list for speed when path provided)
172
+ qualifiers = schema ? {schema: schema} : {}
173
+ qualified_name = schema ? "#{schema}.#{physical_name}" : physical_name
174
+
175
+ begin
176
+ with_spinner("Checking table existence...") do
177
+ table_metadata = adapter.metadata(physical_name, **qualifiers)
178
+ # If metadata returns a table with no columns, the table doesn't exist
179
+ # (some adapters return empty table instead of raising error)
180
+ raise StandardError, "Table not found" if table_metadata.columns.empty?
181
+
182
+ qualified_name
183
+ end
184
+ rescue
185
+ nil
186
+ end
187
+ end
188
+
189
+ def handle_table_not_found(parsed)
190
+ table_name = parsed[:physical_name]
191
+ schema_info = parsed[:schema] ? " in schema '#{parsed[:schema]}'" : ""
192
+ say "\nCould not find table '#{table_name}'#{schema_info} in datasource '#{datasource_key}'.", :red
193
+
194
+ if prompt.yes?("Proceed anyway or cancel?")
195
+ proceed_with_table_creation(nil, parsed)
196
+ else
197
+ say "Cancelled.", :yellow
198
+ end
199
+ end
200
+
201
+ def proceed_with_table_creation(selected_table, parsed)
202
+ # If table not found, use parsed physical_name
203
+ physical_table_name = selected_table || (parsed[:schema] ? "#{parsed[:schema]}.#{parsed[:physical_name]}" : parsed[:physical_name])
204
+
205
+ model_config = prompt_model_config(parsed)
206
+ return unless model_config[:name]
207
+
208
+ # Only fetch columns if we have a valid table
209
+ columns = []
210
+ if selected_table
211
+ columns = fetch_columns(selected_table)
212
+ say " Found #{columns.length} columns\n", :cyan
213
+ end
214
+
215
+ fields = if columns.any?
216
+ generate_fields(physical_table_name, columns, model_config)
217
+ else
218
+ []
219
+ end
220
+
221
+ # Interactive field editor
222
+ result = run_field_editor(fields, physical_table_name, columns, model_config)
223
+ return if result.nil?
224
+
225
+ confirmed_fields = result[:fields]
226
+ final_model_context = result[:model_context]
227
+
228
+ if confirmed_fields.empty?
229
+ say MSG_NO_FIELDS_CONFIRMED, :yellow
230
+ return unless prompt.yes?(MSG_CONTINUE_NO_FIELDS)
231
+ else
232
+ say MSG_FIELDS_CONFIRMED % confirmed_fields.length, :cyan
233
+ end
234
+
235
+ save_model_file(parsed, physical_table_name, confirmed_fields, final_model_context)
236
+ end
237
+
238
+ def resolve_table_selection(initial_filter)
239
+ autocomplete = Autocomplete.new(prompt: prompt)
240
+ autocomplete.select(
241
+ MSG_SEARCH_TABLE,
242
+ all_tables,
243
+ display_transform: ->(table) { TableFilter.display_name(table) },
244
+ default_filter: initial_filter
245
+ )
246
+ end
247
+
248
+ def prompt_model_config(parsed)
249
+ # Default name should be the table name (without schema), not a display name
250
+ # This matches the requirement: "The name and physical name will be event_details"
251
+ default_table_name = parsed[:physical_name]
252
+
253
+ {
254
+ name: prompt.ask(MSG_MODEL_DISPLAY_NAME, default: default_table_name),
255
+ description: prompt.ask(MSG_MODEL_DESCRIPTION, default: "")
256
+ }
257
+ end
258
+
259
+ def fetch_columns(table_name)
260
+ with_spinner("Fetching column metadata...") do
261
+ # Parse qualified table name if present (e.g., "schema.table" -> table name and schema)
262
+ qualifiers = {}
263
+
264
+ if table_name.include?(".")
265
+ parts = table_name.split(".", 2)
266
+ qualifiers[:schema] = parts.first
267
+ end
268
+
269
+ metadata = adapter.metadata(table_name, **qualifiers)
270
+ metadata.columns.map { |c| {name: c.name, type: c.data_type} }
271
+ end
272
+ end
273
+
274
+ def generate_fields(table, columns, context)
275
+ generator = AI::Services::TableGenerator.new
276
+
277
+ if generator.ai_available?
278
+ with_spinner("Analyzing table columns...") do
279
+ generator.call(
280
+ table_name: table,
281
+ columns: columns,
282
+ datasource: datasource_key,
283
+ user_context: context
284
+ )
285
+ end
286
+ else
287
+ with_spinner("Generating basic field definitions...") do
288
+ generator.call(
289
+ table_name: table,
290
+ columns: columns,
291
+ datasource: datasource_key
292
+ )
293
+ end
294
+ end
295
+ end
296
+
297
+ def run_field_editor(fields, table, columns, context)
298
+ editor = FieldEditor.new(
299
+ fields,
300
+ prompt: prompt,
301
+ table_context: {
302
+ table_name: table,
303
+ columns: columns,
304
+ datasource: datasource_key
305
+ },
306
+ model_context: context
307
+ )
308
+ result = editor.run
309
+
310
+ if result.nil?
311
+ say "\nCancelled.", :yellow
312
+ nil
313
+ else
314
+ result
315
+ end
316
+ end
317
+
318
+ def save_model_file(parsed, physical_table_name, fields, context)
319
+ # Build output path based on parsed model_path
320
+ # e.g., "games/event_details" -> "models/games/tbl.event_details.yml"
321
+ # e.g., "contact/dse.call_center_d" -> "models/contact/tbl.dse.call_center_d.yml"
322
+ # Filenames are standardized to lowercase
323
+
324
+ path_parts = parsed[:model_path].split("/")
325
+ last_part = path_parts.last.downcase
326
+
327
+ if path_parts.length > 1
328
+ subdir = path_parts[0..-2].join("/")
329
+ output_path = "models/#{subdir}/tbl.#{last_part}.yml"
330
+ else
331
+ output_path = "models/tbl.#{last_part}.yml"
332
+ end
333
+
334
+ # Extract physical name (without schema for the name field, but keep full for physical_name)
335
+ parsed[:physical_name]
336
+ parsed[:physical_name] # Use just the table name, not schema
337
+
338
+ options_with_name = options.merge(name: context[:name])
339
+
340
+ require_relative "../generators/table"
341
+ gen = Generators::Table.new(
342
+ [parsed[:model_path], datasource_key, physical_table_name, fields, context],
343
+ options_with_name
344
+ )
345
+ gen.invoke_all
346
+
347
+ say MSG_CREATED_MODEL % output_path, :green
348
+ say MSG_EDIT_MODEL_HINT, :yellow
349
+ end
350
+
351
+ def create_relation_file(relation_path)
352
+ require_relative "../generators/relation"
353
+
354
+ relation_generator = Generators::Relation.new([relation_path, datasource_key])
355
+ relation_generator.invoke_all
356
+
357
+ say "\n💡 Edit the file to define your joins between tables", :yellow
358
+ end
359
+
360
+ def validate_entity_type(entity, operation)
361
+ valid_entities = case operation
362
+ when "rename"
363
+ %w[dimension measure table datasource]
364
+ when "swap"
365
+ %w[dimension measure table]
366
+ else
367
+ []
368
+ end
369
+
370
+ unless valid_entities.include?(entity&.downcase)
371
+ valid_list = valid_entities.join(", ")
372
+ if operation == "swap" && entity&.downcase == "datasource"
373
+ raise Strata::CommandError, "Swap operation does not support 'datasource' entity type. Valid types: #{valid_list}"
374
+ else
375
+ raise Strata::CommandError, "Invalid entity type: #{entity}. Valid types for #{operation}: #{valid_list}"
376
+ end
377
+ end
378
+ end
379
+
380
+ def validate_required_params(entity, from, to)
381
+ raise Strata::CommandError, "Missing required option: --entity" if entity.nil? || entity.strip.empty?
382
+ raise Strata::CommandError, "Missing required option: --from" if from.nil? || from.strip.empty?
383
+ raise Strata::CommandError, "Missing required option: --to" if to.nil? || to.strip.empty?
384
+
385
+ if from.strip == to.strip
386
+ say "Warning: --from and --to have the same value. Migration will have no effect.", ColorHelper.warning
387
+ end
388
+ end
389
+
390
+ def prompt_migration_hook(operation)
391
+ hook_options = Prompts::MIGRATION_HOOK_OPTIONS
392
+ default_key = Prompts.default_migration_hook(operation)
393
+
394
+ prompt.select("Migration hook:", hook_options, default: default_key)
395
+ end
396
+
397
+ def create_rename_migration(entity, from, to, hook = "pre")
398
+ require_relative "../generators/migration"
399
+
400
+ migration_generator = Generators::Migration.new(
401
+ ["rename", entity, from, to],
402
+ {hook: hook}
403
+ )
404
+ migration_generator.invoke_all
405
+ end
406
+
407
+ def create_swap_migration(entity, from, to, hook = "post")
408
+ require_relative "../generators/migration"
409
+
410
+ migration_generator = Generators::Migration.new(
411
+ ["swap", entity, from, to],
412
+ {hook: hook}
413
+ )
414
+ migration_generator.invoke_all
415
+ end
416
+ end
417
+ end
418
+ end
419
+ end