strata-cli 0.1.3.beta → 0.1.5.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/CHANGELOG.md +11 -0
  4. data/README.md +3 -2
  5. data/lib/strata/cli/ai/services/table_generator.rb +8 -4
  6. data/lib/strata/cli/api/client.rb +4 -4
  7. data/lib/strata/cli/api/connection_error_handler.rb +1 -1
  8. data/lib/strata/cli/configuration.rb +4 -3
  9. data/lib/strata/cli/credentials.rb +2 -0
  10. data/lib/strata/cli/descriptions/create/migration.txt +10 -0
  11. data/lib/strata/cli/descriptions/create/relation.txt +6 -0
  12. data/lib/strata/cli/descriptions/create/table.txt +13 -0
  13. data/lib/strata/cli/descriptions/datasource/add.txt +6 -0
  14. data/lib/strata/cli/descriptions/datasource/meta.txt +5 -0
  15. data/lib/strata/cli/descriptions/datasource/tables.txt +6 -0
  16. data/lib/strata/cli/descriptions/datasource/test.txt +5 -0
  17. data/lib/strata/cli/descriptions/deploy/deploy.txt +13 -0
  18. data/lib/strata/cli/descriptions/deploy/status.txt +3 -0
  19. data/lib/strata/cli/descriptions/init.txt +6 -0
  20. data/lib/strata/cli/error_reporter.rb +70 -0
  21. data/lib/strata/cli/generators/datasource.rb +4 -1
  22. data/lib/strata/cli/generators/group.rb +14 -8
  23. data/lib/strata/cli/generators/migration.rb +1 -1
  24. data/lib/strata/cli/generators/project.rb +146 -130
  25. data/lib/strata/cli/generators/table.rb +3 -0
  26. data/lib/strata/cli/generators/templates/table.table_name.yml +6 -0
  27. data/lib/strata/cli/guard.rb +2 -0
  28. data/lib/strata/cli/helpers/command_context.rb +23 -4
  29. data/lib/strata/cli/helpers/datasource_helper.rb +34 -1
  30. data/lib/strata/cli/helpers/description_helper.rb +2 -0
  31. data/lib/strata/cli/helpers/project_helper.rb +28 -0
  32. data/lib/strata/cli/helpers/prompts.rb +2 -0
  33. data/lib/strata/cli/main.rb +5 -1
  34. data/lib/strata/cli/sub_commands/audit.rb +19 -5
  35. data/lib/strata/cli/sub_commands/create.rb +41 -19
  36. data/lib/strata/cli/sub_commands/datasource.rb +11 -2
  37. data/lib/strata/cli/sub_commands/deploy.rb +79 -36
  38. data/lib/strata/cli/sub_commands/project.rb +4 -6
  39. data/lib/strata/cli/sub_commands/table.rb +1 -1
  40. data/lib/strata/cli/terminal.rb +2 -0
  41. data/lib/strata/cli/ui/autocomplete.rb +2 -0
  42. data/lib/strata/cli/ui/field_editor.rb +6 -0
  43. data/lib/strata/cli/utils/archive.rb +1 -3
  44. data/lib/strata/cli/utils/deployment_monitor.rb +27 -25
  45. data/lib/strata/cli/utils/git.rb +10 -7
  46. data/lib/strata/cli/utils/import_manager.rb +21 -23
  47. data/lib/strata/cli/utils/test_reporter.rb +16 -25
  48. data/lib/strata/cli/utils/version_checker.rb +4 -6
  49. data/lib/strata/cli/utils/yaml_import_resolver.rb +5 -2
  50. data/lib/strata/cli/utils.rb +2 -0
  51. data/lib/strata/cli/version.rb +1 -1
  52. data/lib/strata/cli.rb +8 -0
  53. metadata +30 -28
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "group"
2
4
  require_relative "datasource"
3
5
  require_relative "../helpers/project_helper"
@@ -6,185 +8,199 @@ require "faraday"
6
8
  require "json"
7
9
  require "uri"
8
10
 
9
- module Strata::CLI
10
- module Generators
11
- class Project < Group
12
- include API::ConnectionErrorHandler
11
+ module Strata
12
+ module CLI
13
+ module Generators
14
+ class Project < Group
15
+ include API::ConnectionErrorHandler
16
+
17
+ BASE_SERVER_URL = "http://localhost:3000"
18
+
19
+ argument :name, type: :string, required: false, desc: "The name of the project. Optional when using --source."
20
+ class_option :datasource, type: :string, repeatable: true
21
+ class_option :source, type: :string
22
+ class_option :api_key, type: :string
23
+
24
+ desc "Generates a new Strata project."
25
+
26
+ def validate_options
27
+ if options.key?(:source)
28
+ unless options.key?(:api_key)
29
+ raise Strata::CommandError,
30
+ "API key is required when using --source option. Use --api-key option."
31
+ end
32
+ else
33
+ unless @name && !@name.to_s.strip.empty?
34
+ raise Strata::CommandError,
35
+ "PROJECT_NAME is required when not using --source option."
36
+ end
37
+ end
38
+ end
13
39
 
14
- BASE_SERVER_URL = "http://localhost:3000"
40
+ def fetch_and_clone_if_available
41
+ return unless options.key?(:source)
15
42
 
16
- argument :name, type: :string, required: false, desc: "The name of the project. Optional when using --source."
17
- class_option :datasource, type: :string, repeatable: true
18
- class_option :source, type: :string
19
- class_option :api_key, type: :string
43
+ project_info = fetch_project_info
44
+ @project_id = project_info["id"]
45
+ @project_info = project_info
20
46
 
21
- desc "Generates a new Strata project."
47
+ return unless project_info["git_url"] && !project_info["git_url"].empty?
22
48
 
23
- def validate_options
24
- if options.key?(:source)
25
- raise Strata::CommandError, "API key is required when using --source option. Use --api-key option." unless options.key?(:api_key)
26
- else
27
- raise Strata::CommandError, "PROJECT_NAME is required when not using --source option." unless @name && !@name.to_s.strip.empty?
49
+ @cloned_from_git = true
50
+ clone_project(project_info["git_url"])
28
51
  end
29
- end
30
-
31
- def fetch_and_clone_if_available
32
- return unless options.key?(:source)
33
-
34
- project_info = fetch_project_info
35
- @project_id = project_info["id"]
36
- @project_info = project_info
37
52
 
38
- return unless project_info["git_url"] && !project_info["git_url"].empty?
53
+ def create_project_structure
54
+ return if cloned_from_git?
39
55
 
40
- @cloned_from_git = true
41
- clone_project(project_info["git_url"])
42
- end
56
+ empty_directory uid
57
+ empty_directory File.join(uid, "models")
58
+ empty_directory File.join(uid, "tests")
59
+ end
43
60
 
44
- def create_project_structure
45
- return if cloned_from_git?
61
+ def create_strata_config_file
62
+ template "strata.yml", "#{uid}/.strata"
63
+ end
46
64
 
47
- empty_directory uid
48
- empty_directory File.join(uid, "models")
49
- empty_directory File.join(uid, "tests")
50
- end
65
+ def create_project_file
66
+ return if cloned_from_git?
51
67
 
52
- def create_strata_config_file
53
- template "strata.yml", "#{uid}/.strata"
54
- end
68
+ template "project.yml", "#{uid}/project.yml"
69
+ end
55
70
 
56
- def create_project_file
57
- return if cloned_from_git?
71
+ def persist_project_id_if_needed
72
+ # Persist project_id after project.yml exists (either from clone or creation)
73
+ return unless @project_id
58
74
 
59
- template "project.yml", "#{uid}/project.yml"
60
- end
75
+ project_yml_path = File.join(uid, "project.yml")
76
+ return unless File.exist?(project_yml_path)
61
77
 
62
- def persist_project_id_if_needed
63
- # Persist project_id after project.yml exists (either from clone or creation)
64
- return unless @project_id
78
+ persist_project_id_to_project_yml
79
+ end
65
80
 
66
- project_yml_path = File.join(uid, "project.yml")
67
- return unless File.exist?(project_yml_path)
81
+ def create_datasources_file
82
+ return if cloned_from_git?
68
83
 
69
- persist_project_id_to_project_yml
70
- end
84
+ template "datasources.yml", "#{uid}/datasources.yml"
71
85
 
72
- def create_datasources_file
73
- return if cloned_from_git?
86
+ return unless options.key?(:datasource)
74
87
 
75
- template "datasources.yml", "#{uid}/datasources.yml"
88
+ options[:datasource].each do |ds|
89
+ raise DWH::ConfigError, "Unsupported datasource #{ds}" unless DWH.adapter?(ds.to_sym)
76
90
 
77
- return unless options.key?(:datasource)
91
+ Datasource.new([ds.downcase.strip], options.merge({"path" => uid})).invoke_all
92
+ end
93
+ end
78
94
 
79
- options[:datasource].each do |ds|
80
- raise DWH::ConfigError, "Unsupported datasource #{ds}" unless DWH.adapter?(ds.to_sym)
95
+ def initialize_git
96
+ return if cloned_from_git?
81
97
 
82
- Datasource.new([ds.downcase.strip], options.merge({"path" => uid})).invoke_all
98
+ inside uid do
99
+ run "git init", verbose: false, capture: true
100
+ create_file ".gitignore", ".strata\n"
101
+ end
83
102
  end
84
- end
85
103
 
86
- def initialize_git
87
- return if cloned_from_git?
104
+ def setup_datasource
105
+ return if cloned_from_git?
106
+ return if options.key?(:datasource) # Already specified via CLI option
107
+
108
+ say "\n", :white
109
+ say_status :setup, "Let's configure your first datasource", :cyan
88
110
 
89
- inside uid do
90
- run "git init", verbose: false, capture: true
91
- create_file ".gitignore", ".strata\n"
111
+ # Change into the project directory and run the existing add command
112
+ inside(uid) do
113
+ require_relative "../sub_commands/datasource"
114
+ SubCommands::Datasource.new.add
115
+ end
92
116
  end
93
- end
94
117
 
95
- def setup_datasource
96
- return if cloned_from_git?
97
- return if options.key?(:datasource) # Already specified via CLI option
118
+ def completion_message
119
+ say "\n✔ Strata project '#{uid}' is ready!", :green
120
+ say "\nNext steps:", :yellow
121
+ say " 1. cd #{uid}", :cyan
122
+ say " 2. strata datasource add # To add more datasources", :cyan
123
+ say " 3. strata create table # Start adding tables", :cyan
124
+ say "\n"
125
+ end
98
126
 
99
- say "\n", :white
100
- say_status :setup, "Let's configure your first datasource", :cyan
127
+ private
101
128
 
102
- # Change into the project directory and run the existing add command
103
- inside(uid) do
104
- require_relative "../sub_commands/datasource"
105
- SubCommands::Datasource.new.add
129
+ def cloned_from_git?
130
+ @cloned_from_git ||= false
106
131
  end
107
- end
108
132
 
109
- def completion_message
110
- say "\n✔ Strata project '#{uid}' is ready!", :green
111
- say "\nNext steps:", :yellow
112
- say " 1. cd #{uid}", :cyan
113
- say " 2. strata datasource add # To add more datasources", :cyan
114
- say " 3. strata create table # Start adding tables", :cyan
115
- say "\n"
116
- end
133
+ def fetch_project_info
134
+ conn = Faraday.new(url: options[:source]) do |f|
135
+ f.request :authorization, "Bearer", options[:api_key]
136
+ f.response :json
137
+ end
117
138
 
118
- private
139
+ response = with_connection_error_handling(options[:source]) do
140
+ conn.get
141
+ end
119
142
 
120
- def cloned_from_git?
121
- @cloned_from_git ||= false
122
- end
143
+ unless response.success?
144
+ raise Strata::CommandError,
145
+ "Failed to fetch project info from #{options[:source]}. Status: #{response.status}"
146
+ end
123
147
 
124
- def fetch_project_info
125
- conn = Faraday.new(url: options[:source]) do |f|
126
- f.request :authorization, "Bearer", options[:api_key]
127
- f.response :json
148
+ response.body
128
149
  end
129
150
 
130
- response = with_connection_error_handling(options[:source]) do
131
- conn.get
132
- end
151
+ def clone_project(git_url)
152
+ say_status :clone, "Cloning project from #{git_url}", :green
153
+ run "git clone #{git_url} #{uid}", verbose: false, capture: true
154
+
155
+ raise Strata::CommandError, "Failed to clone repository from #{git_url}" unless File.directory?(uid)
133
156
 
134
- unless response.success?
135
- raise Strata::CommandError,
136
- "Failed to fetch project info from #{options[:source]}. Status: #{response.status}"
157
+ say_status :success, "Project cloned successfully", :green
137
158
  end
138
159
 
139
- response.body
140
- end
160
+ def persist_project_id_to_project_yml
161
+ return unless @project_id
141
162
 
142
- def clone_project(git_url)
143
- say_status :clone, "Cloning project from #{git_url}", :green
144
- run "git clone #{git_url} #{uid}", verbose: false, capture: true
163
+ project_yml_path = File.join(uid, "project.yml")
164
+ Helpers::ProjectHelper.persist_project_id_to_yml(@project_id, project_yml_path: project_yml_path)
165
+ end
145
166
 
146
- raise Strata::CommandError, "Failed to clone repository from #{git_url}" unless File.directory?(uid)
167
+ def uid
168
+ @uid ||= if options.key?(:source) && @project_info
169
+ @project_info["uid"] || options[:source].split("/").last
170
+ else
171
+ Utils.url_safe_str(name)
172
+ end
173
+ end
147
174
 
148
- say_status :success, "Project cloned successfully", :green
149
- end
175
+ def name
176
+ # Use name from server if available (when using --source), otherwise use argument
177
+ return @project_info["name"] if options.key?(:source) && @project_info
150
178
 
151
- def persist_project_id_to_project_yml
152
- return unless @project_id
179
+ @name
180
+ end
153
181
 
154
- project_yml_path = File.join(uid, "project.yml")
155
- Helpers::ProjectHelper.persist_project_id_to_yml(@project_id, project_yml_path: project_yml_path)
156
- end
182
+ def server_url
183
+ return options[:source] if options.key?(:source)
157
184
 
158
- def uid
159
- @uid ||= if options.key?(:source) && @project_info
160
- @project_info["uid"] || options[:source].split("/").last
161
- else
162
- Utils.url_safe_str(name)
185
+ BASE_SERVER_URL
163
186
  end
164
- end
165
187
 
166
- def name
167
- # Use name from server if available (when using --source), otherwise use argument
168
- return @project_info["name"] if options.key?(:source) && @project_info
169
- @name
170
- end
188
+ def description
189
+ return @project_info["description"] if options.key?(:source) && @project_info
171
190
 
172
- def server_url
173
- return options[:source] if options.key?(:source)
174
- BASE_SERVER_URL
175
- end
191
+ nil
192
+ end
176
193
 
177
- def description
178
- return @project_info["description"] if options.key?(:source) && @project_info
179
- nil
180
- end
194
+ def production_branch
195
+ if options.key?(:source) && @project_info && @project_info["production_branch"]
196
+ return @project_info["production_branch"]
197
+ end
181
198
 
182
- def production_branch
183
- return @project_info["production_branch"] if options.key?(:source) && @project_info && @project_info["production_branch"]
184
- "main"
185
- end
199
+ "main"
200
+ end
186
201
 
187
- attr_reader :project_id
202
+ attr_reader :project_id
203
+ end
188
204
  end
189
205
  end
190
206
  end
@@ -108,6 +108,9 @@ module Strata
108
108
  "data_type" => normalized[:data_type]
109
109
  }
110
110
 
111
+ synonyms = normalized[:synonyms]
112
+ field_hash["synonyms"] = synonyms if synonyms.is_a?(Array) && !synonyms.empty?
113
+
111
114
  # Build expression with proper nested format
112
115
  expr = normalized[:expression]
113
116
  field_hash["expression"] = {
@@ -96,6 +96,12 @@ fields:
96
96
  # array: true|false (optional)
97
97
  # sql: my_field_column (Required)
98
98
  #
99
+ # # Optional: Alternative names for this field. Helps AI and search
100
+ # # find this field when users refer to it by different names.
101
+ # synonyms:
102
+ # - alt name one
103
+ # - alt name two
104
+ #
99
105
  # # Optional: Exclude certain dimnesions from the group by/filter
100
106
  # exclusion_type: exclude|exclude_all_except|exclude_all
101
107
  # exclusions: # (Required when exclusion type is set and isnt exclude_all)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "utils"
2
4
  module Strata
3
5
  module CLI
@@ -4,6 +4,7 @@ require "tty-prompt"
4
4
  require_relative "datasource_helper"
5
5
  require_relative "color_helper"
6
6
  require_relative "prompts"
7
+ require_relative "../error_reporter"
7
8
 
8
9
  module Strata
9
10
  module CLI
@@ -23,18 +24,36 @@ module Strata
23
24
  @datasource_key ||= resolve_datasource(prompt: prompt)
24
25
  end
25
26
 
26
- def all_tables
27
- @all_tables ||= begin
27
+ def table_fetch_result
28
+ @table_fetch_result ||= begin
28
29
  tables = with_spinner("Fetching tables from #{datasource_key}...") { adapter.tables }
29
30
  if tables.empty?
30
31
  # Assuming Prompts and ColorHelper are available in the class or via module
31
32
  say Prompts::MSG_NO_TABLES_FOUND % datasource_key, ColorHelper.warning
32
- []
33
+ {tables: [], failed: false}
34
+ else
35
+ {tables: tables, failed: false}
36
+ end
37
+ rescue => e
38
+ ErrorReporter.log_error(e, context: "create table: failed fetching tables for '#{datasource_key}'")
39
+
40
+ if ErrorReporter.connection_error?(e)
41
+ say "Could not connect to datasource '#{datasource_key}'.", ColorHelper.warning
33
42
  else
34
- tables
43
+ say "Could not fetch tables from datasource '#{datasource_key}'.", ColorHelper.warning
44
+ say "Reason: #{ErrorReporter.user_message_for(e)}", ColorHelper.warning
35
45
  end
46
+
47
+ say "Hint: verify credentials/settings and run 'strata datasource test #{datasource_key}'.",
48
+ ColorHelper.info
49
+ say "Details logged to '#{ErrorReporter.log_relative_path}'.", ColorHelper.info
50
+ {tables: [], failed: true}
36
51
  end
37
52
  end
53
+
54
+ def all_tables
55
+ table_fetch_result[:tables]
56
+ end
38
57
  end
39
58
  end
40
59
  end
@@ -42,9 +42,26 @@ module Strata
42
42
  config = ds_config(ds_key).merge(Credentials.fetch(ds_key))
43
43
  adapter_sym = config["adapter"].to_sym
44
44
  ensure_adapter_driver_gems!(adapter_sym)
45
+
46
+ # CLI only performs read operations (test, tables, metadata).
47
+ # Use read-only mode for file-based databases to avoid lock conflicts.
48
+ apply_readonly_mode(adapter_sym, config)
49
+
45
50
  DWH.create(adapter_sym, config)
46
51
  end
47
52
 
53
+ def apply_readonly_mode(adapter_sym, config)
54
+ case adapter_sym
55
+ when :duckdb
56
+ config["duck_config"] ||= {}
57
+ config["duck_config"]["access_mode"] = "READ_ONLY"
58
+ when :sqlite
59
+ config["readonly"] = true
60
+ end
61
+ # Client-server databases (postgres, mysql, snowflake, etc.)
62
+ # don't need special handling - no file lock issues
63
+ end
64
+
48
65
  def ensure_adapter_driver_gems!(adapter_sym)
49
66
  required = ADAPTER_DRIVER_GEMS.fetch(adapter_sym, [])
50
67
  return if required.empty?
@@ -88,6 +105,22 @@ module Strata
88
105
  datasources[ds_key]
89
106
  end
90
107
 
108
+ def resolve_datasource_value(value)
109
+ return nil if value.nil? || value.to_s.strip.empty?
110
+
111
+ normalized = value.to_s.downcase.strip
112
+
113
+ # Match by key first
114
+ return value if datasources.key?(normalized)
115
+
116
+ # Match by name (case-insensitive)
117
+ datasources.each do |key, config|
118
+ return key if config.is_a?(Hash) && config["name"].to_s.downcase.strip == normalized
119
+ end
120
+
121
+ nil
122
+ end
123
+
91
124
  private
92
125
 
93
126
  def validate_datasource(ds_key)
@@ -99,7 +132,7 @@ module Strata
99
132
  end
100
133
 
101
134
  def datasources
102
- @datasources_cache ||= begin
135
+ @datasources ||= begin
103
136
  YAML.safe_load_file("datasources.yml", permitted_classes: [Date, Time], aliases: true) || {}
104
137
  rescue Errno::ENOENT
105
138
  {}
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Strata
2
4
  module CLI
3
5
  module Helpers
@@ -46,6 +46,34 @@ module Strata
46
46
  end
47
47
  end
48
48
 
49
+ # Persists server URL to project.yml (updates existing or appends)
50
+ def persist_server_to_project_yml(server, project_yml_path: "project.yml")
51
+ return false unless server && !server.to_s.strip.empty?
52
+ return false unless File.exist?(project_yml_path)
53
+
54
+ project_yml_content = File.read(project_yml_path)
55
+
56
+ begin
57
+ if /^server:\s*.+$/m.match?(project_yml_content)
58
+ updated_content = project_yml_content.gsub(/^(\s*)server:\s*.+$/, "\\1server: #{server}")
59
+ File.write(project_yml_path, updated_content)
60
+ else
61
+ File.open(project_yml_path, "a") do |f|
62
+ f.puts "\n" unless project_yml_content.end_with?("\n")
63
+ f.puts "server: #{server}"
64
+ end
65
+ end
66
+
67
+ true
68
+ rescue Errno::EACCES => e
69
+ raise Strata::CommandError, "Permission denied writing to #{project_yml_path}: #{e.message}"
70
+ rescue Errno::ENOSPC => e
71
+ raise Strata::CommandError, "Disk full: #{e.message}"
72
+ rescue => e
73
+ raise Strata::CommandError, "Failed to write to #{project_yml_path}: #{e.message}"
74
+ end
75
+ end
76
+
49
77
  # Persists git URL to project.yml file if missing
50
78
  def persist_git_url_if_missing(project_yml_path: "project.yml")
51
79
  return false unless File.exist?(project_yml_path)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Strata
2
4
  module CLI
3
5
  module Prompts
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "generators/project"
2
4
  require_relative "sub_commands/datasource"
3
5
  require_relative "sub_commands/deploy"
@@ -33,7 +35,9 @@ module Strata
33
35
  unless project_name || options[:source]
34
36
  raise Strata::CommandError, "PROJECT_NAME is required when not using --source option."
35
37
  end
36
- say_status :started, "Creating #{project_name || "project from source"} - #{options[:datasource]}", ColorHelper.info
38
+
39
+ say_status :started, "Creating #{project_name || "project from source"} - #{options[:datasource]}",
40
+ ColorHelper.info
37
41
  invoke Generators::Project, [project_name], options
38
42
  end
39
43
 
@@ -19,10 +19,10 @@ module Strata
19
19
  include Terminal
20
20
  include DatasourceHelper
21
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]
22
+ REQUIRED_KEYS_FOR_TABLE_MODEL = %w[name physical_name fields datasource].freeze
23
+ REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL = ["datasource"].freeze
24
+ REQUIRED_KEYS_FOR_RELATIONSHIP_DEFINITION = %w[left right sql cardinality].freeze
25
+ RELATIONSHIP_CARDINALITIES = %w[one_to_one one_to_many many_to_one many_to_many].freeze
26
26
 
27
27
  # Set default command so `strata audit` still works as `strata audit all`
28
28
  default_command :all
@@ -131,21 +131,35 @@ module Strata
131
131
  end
132
132
 
133
133
  def validate_structure(content, file, failures)
134
- content.keys.each do |k|
134
+ content.each_key do |k|
135
135
  failures << {file: file, message: "Top level key #{k} is not a string"} unless k.is_a?(String)
136
136
  end
137
137
  end
138
138
 
139
139
  def validate_table_model(content, file, failures)
140
140
  validate_required_keys(content, file, REQUIRED_KEYS_FOR_TABLE_MODEL, failures)
141
+ validate_datasource_reference(content, file, failures)
141
142
  validate_table_fields(content["fields"], file, failures) if content.key?("fields")
142
143
  end
143
144
 
144
145
  def validate_relationship_model(content, file, failures)
145
146
  validate_required_keys(content, file, REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL, failures)
147
+ validate_datasource_reference(content, file, failures)
146
148
  validate_relationship_definitions(content, file, failures)
147
149
  end
148
150
 
151
+ def validate_datasource_reference(content, file, failures)
152
+ ds_value = content["datasource"]
153
+ return if ds_value.nil? || ds_value.to_s.strip.empty?
154
+
155
+ return if resolve_datasource_value(ds_value)
156
+
157
+ failures << {
158
+ file: file,
159
+ message: "Datasource '#{ds_value}' not found. Must match a key or name in datasources.yml"
160
+ }
161
+ end
162
+
149
163
  def validate_required_keys(content, file, required_keys, failures)
150
164
  missing = required_keys - content.keys
151
165
  failures << {file: file, message: "Missing required keys: #{missing.join(", ")}"} if missing.any?