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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strata
4
+ module CLI
5
+ # Shared module for table filtering and display name extraction
6
+ module TableFilter
7
+ # Extract display name from full table path
8
+ # e.g., "sales/analytics/customer" -> "customer"
9
+ # @param table_path [String] Full table path
10
+ # @return [String] Last element of the path
11
+ def self.display_name(table_path)
12
+ table_path.split("/").last || table_path
13
+ end
14
+
15
+ # Filter tables by pattern (case-insensitive)
16
+ # @param tables [Array<String>] List of table paths
17
+ # @param pattern [String] Search pattern
18
+ # @return [Array<String>] Filtered tables
19
+ def self.filter(tables, pattern)
20
+ return tables if pattern.nil? || pattern.empty?
21
+
22
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
23
+ tables.select { |table| table.match?(regex) || display_name(table).match?(regex) }
24
+ end
25
+
26
+ # Paginate array of items
27
+ # @param items [Array] Items to paginate
28
+ # @param page [Integer] Current page (1-indexed)
29
+ # @param per_page [Integer] Items per page
30
+ # @return [Array] Items for current page
31
+ def self.paginate(items, page: 1, per_page: 10)
32
+ start_idx = (page - 1) * per_page
33
+ end_idx = start_idx + per_page
34
+ items[start_idx...end_idx] || []
35
+ end
36
+
37
+ # Get total pages for pagination
38
+ # @param items [Array] Items to paginate
39
+ # @param per_page [Integer] Items per page
40
+ # @return [Integer] Total number of pages
41
+ def self.total_pages(items, per_page: 10)
42
+ return 1 if items.empty?
43
+
44
+ (items.length.to_f / per_page).ceil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,71 @@
1
+ require_relative "generators/project"
2
+ require_relative "sub_commands/datasource"
3
+ require_relative "sub_commands/deploy"
4
+ require_relative "sub_commands/project"
5
+ require_relative "sub_commands/table"
6
+ require_relative "sub_commands/create"
7
+ require_relative "sub_commands/audit"
8
+ require_relative "helpers/description_helper"
9
+
10
+ module Strata
11
+ module CLI
12
+ class Main < Thor
13
+ include Guard
14
+ extend Helpers::DescriptionHelper
15
+
16
+ def self.exit_on_failure?
17
+ true
18
+ end
19
+
20
+ desc "version", "Prints this version of strata-cli"
21
+ def version
22
+ say VERSION
23
+ end
24
+
25
+ desc "init [PROJECT_NAME]", "Initializes a new Strata project. PROJECT_NAME is optional when using --source."
26
+ long_desc_from_file "init"
27
+ option :datasource, aliases: ["d"], type: :string, desc: "One of the supported data warehouse adapters.",
28
+ repeatable: true
29
+ option :source, aliases: ["s"], type: :string, desc: "URL of existing project"
30
+ option :api_key, aliases: ["a"], type: :string, desc: "Api Key. Required if initializing existing project."
31
+
32
+ def init(project_name = nil)
33
+ unless project_name || options[:source]
34
+ raise Strata::CommandError, "PROJECT_NAME is required when not using --source option."
35
+ end
36
+ say_status :started, "Creating #{project_name || "project from source"} - #{options[:datasource]}", ColorHelper.info
37
+ invoke Generators::Project, [project_name], options
38
+ end
39
+
40
+ desc "adapters", "Lists supported data warehouse adapters"
41
+ def adapters
42
+ out = " SUPPORTED ADAPTERS: \n\t#{DWH.adapters.keys.join("\n\t")}"
43
+ say out, ColorHelper.info
44
+ end
45
+
46
+ desc "datasource", "Manage project datasources"
47
+ subcommand "datasource", SubCommands::Datasource
48
+
49
+ desc "create", "Create semantic models (tables, relations)"
50
+ subcommand "create", SubCommands::Create
51
+
52
+ desc "table", "Manage semantic tables"
53
+ subcommand "table", SubCommands::Table
54
+
55
+ desc "audit", "Audit project configuration and models"
56
+ subcommand "audit", SubCommands::Audit
57
+
58
+ desc "deploy", "Deploy project to Strata server"
59
+ subcommand "deploy", SubCommands::Deploy
60
+
61
+ desc "project", "Manage project configuration"
62
+ subcommand "project", SubCommands::Project
63
+
64
+ # Creating aliases
65
+ map "t" => "table"
66
+ map "tbl" => "table"
67
+ map "ds" => "datasource"
68
+ map "a" => "audit"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guard"
4
+ require_relative "../terminal"
5
+ require_relative "../credentials"
6
+ require_relative "../helpers/color_helper"
7
+ require_relative "../helpers/datasource_helper"
8
+ require_relative "../utils/yaml_import_resolver"
9
+ require_relative "../utils/import_manager"
10
+ require "yaml"
11
+ require "pathname"
12
+
13
+ module Strata
14
+ module CLI
15
+ module SubCommands
16
+ class Audit < Thor
17
+ include Thor::Actions
18
+ include Guard
19
+ include Terminal
20
+ include DatasourceHelper
21
+
22
+ REQUIRED_KEYS_FOR_TABLE_MODEL = %w[name physical_name fields datasource]
23
+ REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL = ["datasource"]
24
+ REQUIRED_KEYS_FOR_RELATIONSHIP_DEFINITION = %w[left right sql cardinality]
25
+ RELATIONSHIP_CARDINALITIES = %w[one_to_one one_to_many many_to_one many_to_many]
26
+
27
+ # Set default command so `strata audit` still works as `strata audit all`
28
+ default_command :all
29
+
30
+ desc "all", "Run all audit checks"
31
+ def all
32
+ results = {}
33
+ results[:yaml] = run_check("Checking YAML syntax") { audit_yaml_syntax }
34
+ results[:models] = run_check("Checking model definitions") { audit_models }
35
+ results[:connections] = run_check("Checking data source connections") { audit_connections }
36
+
37
+ report_results(results)
38
+ end
39
+
40
+ desc "syntax", "Check YAML syntax of configuration files"
41
+ def syntax
42
+ results = {yaml: run_check("Checking YAML syntax") { audit_yaml_syntax }}
43
+ report_results(results)
44
+ end
45
+
46
+ desc "models", "Check model definitions and schema structure"
47
+ def models
48
+ results = {models: run_check("Checking model definitions") { audit_models }}
49
+ report_results(results)
50
+ end
51
+
52
+ desc "connections", "Check data source connections"
53
+ def connections
54
+ results = {connections: run_check("Checking data source connections") { audit_connections }}
55
+ report_results(results)
56
+ end
57
+
58
+ private
59
+
60
+ def report_results(results)
61
+ if results.values.all?
62
+ # All checks passed - no need for additional message, spinners already show success
63
+ else
64
+ say "\n Some checks failed.", ColorHelper.error
65
+ exit(1)
66
+ end
67
+ end
68
+
69
+ def run_check(message)
70
+ failures = []
71
+ with_spinner(message) do
72
+ failures = yield
73
+ raise StandardError, "Check failed" if failures.any?
74
+ end
75
+ true
76
+ rescue
77
+ failures.each do |f|
78
+ msg = f.is_a?(Hash) ? "#{f[:file]}: #{f[:message]}" : f.to_s
79
+ say " ✖ #{msg}", ColorHelper.error
80
+ end
81
+ false
82
+ end
83
+
84
+ def audit_yaml_syntax
85
+ files = Dir.glob("models/**/*.yml") + ["datasources.yml"].select { |f| File.exist?(f) }
86
+
87
+ failures = []
88
+ files.each do |file|
89
+ YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true)
90
+ rescue Psych::SyntaxError => e
91
+ failures << {file: file, message: e.message}
92
+ rescue => e
93
+ failures << {file: file, message: e.message}
94
+ end
95
+ failures
96
+ end
97
+
98
+ def audit_models
99
+ failures = []
100
+ Dir.glob("models/**/*.yml").each do |file|
101
+ audit_model_file(file, failures)
102
+ audit_imports(file, failures)
103
+ end
104
+ failures
105
+ end
106
+
107
+ def audit_model_file(file, failures)
108
+ content = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true) || {}
109
+ return unless content.is_a?(Hash)
110
+
111
+ validate_structure(content, file, failures)
112
+
113
+ case model_type(file)
114
+ when :table
115
+ validate_table_model(content, file, failures)
116
+ when :relationship
117
+ validate_relationship_model(content, file, failures)
118
+ end
119
+ rescue
120
+ # Ignore errors here, they are handled in audit_yaml_syntax if needed
121
+ # or simply skipped if the file is unreadable
122
+ end
123
+
124
+ def model_type(file)
125
+ filename = File.basename(file)
126
+ if filename.start_with?("tbl.")
127
+ :table
128
+ elsif filename.start_with?("rel.")
129
+ :relationship
130
+ end
131
+ end
132
+
133
+ def validate_structure(content, file, failures)
134
+ content.keys.each do |k|
135
+ failures << {file: file, message: "Top level key #{k} is not a string"} unless k.is_a?(String)
136
+ end
137
+ end
138
+
139
+ def validate_table_model(content, file, failures)
140
+ validate_required_keys(content, file, REQUIRED_KEYS_FOR_TABLE_MODEL, failures)
141
+ validate_table_fields(content["fields"], file, failures) if content.key?("fields")
142
+ end
143
+
144
+ def validate_relationship_model(content, file, failures)
145
+ validate_required_keys(content, file, REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL, failures)
146
+ validate_relationship_definitions(content, file, failures)
147
+ end
148
+
149
+ def validate_required_keys(content, file, required_keys, failures)
150
+ missing = required_keys - content.keys
151
+ failures << {file: file, message: "Missing required keys: #{missing.join(", ")}"} if missing.any?
152
+ end
153
+
154
+ def validate_table_fields(fields, file, failures)
155
+ unless fields.is_a?(Array)
156
+ failures << {file: file, message: "'fields' must be an array"}
157
+ return
158
+ end
159
+
160
+ fields.each_with_index do |field, idx|
161
+ unless field.is_a?(Hash) && field.key?("name")
162
+ failures << {file: file, message: "Field at index #{idx} missing 'name'"}
163
+ end
164
+ end
165
+ end
166
+
167
+ def validate_relationship_definitions(content, file, failures)
168
+ content.each do |key, definition|
169
+ next if key == "datasource"
170
+
171
+ unless definition.is_a?(Hash)
172
+ failures << {file: file, message: "Relationship '#{key}' is not a hash definition"}
173
+ next
174
+ end
175
+
176
+ validate_relationship_properties(key, definition, file, failures)
177
+ end
178
+ end
179
+
180
+ def validate_relationship_properties(name, definition, file, failures)
181
+ REQUIRED_KEYS_FOR_RELATIONSHIP_DEFINITION.each do |req|
182
+ failures << {file: file, message: "Relationship '#{name}' missing '#{req}'"} unless definition.key?(req)
183
+ end
184
+ validate_cardinality(name, definition["cardinality"], file, failures) if definition.key?("cardinality")
185
+ end
186
+
187
+ def validate_cardinality(name, cardinality, file, failures)
188
+ return if RELATIONSHIP_CARDINALITIES.include?(cardinality)
189
+
190
+ failures << {file: file, message: "Relationship '#{name}' has invalid cardinality '#{cardinality}'"}
191
+ end
192
+
193
+ def audit_connections
194
+ failures = []
195
+
196
+ unless File.exist?("datasources.yml")
197
+ failures << {file: "datasources.yml", message: "File not found"}
198
+ return failures
199
+ end
200
+
201
+ if datasources.empty?
202
+ failures << {file: "datasources.yml",
203
+ message: "No datasources found. Run 'strata datasource add' to configure one."}
204
+ return failures
205
+ end
206
+
207
+ datasources.each_key do |ds_key|
208
+ adapter = create_adapter(ds_key)
209
+ adapter.test_connection(raise_exception: true)
210
+ rescue => e
211
+ failures << {
212
+ file: "datasources.yml",
213
+ message: "Connection failed for '#{ds_key}': #{e.message}"
214
+ }
215
+ end
216
+
217
+ failures
218
+ end
219
+
220
+ def audit_imports(file, failures)
221
+ content = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true) || {}
222
+ return unless content.is_a?(Hash) && content["imports"]
223
+
224
+ project_path = Dir.pwd
225
+ base_dir = File.dirname(file)
226
+
227
+ content["imports"].each do |import_path|
228
+ # Validate import path is relative (will raise InvalidImportPathError if absolute)
229
+ Utils::ImportManager.validate_import_path(import_path)
230
+
231
+ # Validate that relative path resolves to an existing file
232
+ resolved_path = File.expand_path(import_path, base_dir)
233
+ unless File.exist?(resolved_path)
234
+ failures << {file: file, message: "Import file not found: #{import_path}"}
235
+ next
236
+ end
237
+
238
+ resolved_path = Utils::ImportManager.resolve_with_fallback(import_path, base_dir, project_path)
239
+ imported_content = YAML.safe_load_file(resolved_path, permitted_classes: [Date, Time], aliases: true) || {}
240
+
241
+ unless imported_content.is_a?(Hash)
242
+ failures << {file: file, message: "Imported file '#{import_path}' does not contain valid YAML hash"}
243
+ next
244
+ end
245
+
246
+ if model_type(file) == :table && imported_content["fields"]
247
+ validate_table_fields(imported_content["fields"], file, failures)
248
+ end
249
+ rescue Strata::InvalidImportPathError => e
250
+ failures << {file: file, message: e.message}
251
+ rescue Strata::MissingImportError
252
+ failures << {file: file, message: "Import file not found: #{import_path}"}
253
+ rescue Strata::CircularImportError => e
254
+ failures << {file: file, message: "Circular import detected: #{e.message}"}
255
+ rescue => e
256
+ failures << {file: file, message: "Error processing import '#{import_path}': #{e.message}"}
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end