strata-cli 0.1.15 → 0.1.16

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.
@@ -5,6 +5,7 @@ require_relative "../terminal"
5
5
  require_relative "../output"
6
6
  require_relative "../helpers/project_helper"
7
7
  require_relative "../helpers/description_helper"
8
+ require_relative "../helpers/browser_auth"
8
9
  require_relative "../agent_mode"
9
10
  require_relative "../api/client"
10
11
  require_relative "../credentials"
@@ -25,6 +26,7 @@ module Strata
25
26
  include Terminal
26
27
  include Output
27
28
  include AgentMode
29
+ include Helpers::BrowserAuth
28
30
  extend Helpers::DescriptionHelper
29
31
 
30
32
  default_command :deploy
@@ -37,6 +39,9 @@ module Strata
37
39
  desc "deploy", "Deploy project to Strata server for the current branch"
38
40
  long_desc_from_file "deploy/deploy"
39
41
  def deploy
42
+ # Before anything that prompts, signs in, or hits the server.
43
+ ensure_working_tree_clean
44
+
40
45
  config = load_deployment_configuration
41
46
  validate_deployment_configuration(config)
42
47
  run_pre_deploy_checks unless options[:skip_audit]
@@ -128,8 +133,9 @@ module Strata
128
133
  end
129
134
 
130
135
  def validate_deployment_configuration(config)
131
- ensure_api_key(config)
136
+ # Server first: browser sign-in needs its URL to build the auth link
132
137
  ensure_server(config)
138
+ ensure_api_key(config)
133
139
  ensure_git_url_populated
134
140
  ensure_project_id(config)
135
141
  end
@@ -139,7 +145,7 @@ module Strata
139
145
  return
140
146
  end
141
147
 
142
- config["api_key"] = collect_api_key_interactively
148
+ config["api_key"] = collect_api_key_interactively(config["server"])
143
149
  end
144
150
 
145
151
  def ensure_server(config)
@@ -149,8 +155,6 @@ module Strata
149
155
  end
150
156
 
151
157
  def collect_server_interactively
152
- prompt = TTY::Prompt.new
153
-
154
158
  say "\nServer URL not found (not in project.yml)", :yellow
155
159
  say "You can add it to project.yml or enter it now here\n", :cyan
156
160
 
@@ -199,16 +203,24 @@ module Strata
199
203
  persist_project_id(project_id)
200
204
  end
201
205
 
202
- def collect_api_key_interactively
203
- prompt = TTY::Prompt.new
204
-
206
+ def collect_api_key_interactively(server)
205
207
  say "\nAPI key not found in .strata file", :yellow
206
- say "\nTo deploy to Strata, you need an API key.\nYou can get your API key from:", :white
207
- say " • Your Strata server dashboard (Settings → API Keys)\n • Your Strata administrator\n", :cyan
208
208
 
209
- api_key = prompt.mask("Enter your Strata API key:") do |q|
210
- q.required true
211
- q.validate(/\S+/, "API key cannot be empty")
209
+ choice = prompt.select("How would you like to authenticate?") do |menu|
210
+ menu.choice "Sign in via browser (recommended)", :browser
211
+ menu.choice "Paste an API key", :manual
212
+ end
213
+
214
+ api_key = if choice == :browser
215
+ fetch_api_key_via_browser(server)
216
+ else
217
+ say "\nYou can get your API key from:", :white
218
+ say " • Your Strata server dashboard (Settings → API Keys)\n • Your Strata administrator\n", :cyan
219
+
220
+ prompt.mask("Enter your Strata API key:") do |q|
221
+ q.required true
222
+ q.validate(/\S+/, "API key cannot be empty")
223
+ end
212
224
  end
213
225
 
214
226
  # Save API key to .strata file
@@ -272,15 +284,15 @@ module Strata
272
284
  raise Strata::CommandError, "Audit checks failed. Fix errors before deploying." if e.status != 0
273
285
  end
274
286
 
275
- def run_git_status_checks(branch_name)
287
+ def ensure_working_tree_clean
276
288
  return unless Utils::Git.git_repo?
289
+ return unless Utils::Git.uncommitted_changes?
277
290
 
278
- with_spinner("Checking git repository status") do
279
- # Check for uncommitted changes
280
- if Utils::Git.uncommitted_changes?
281
- raise Strata::CommandError, "You have uncommitted changes. Please commit or stash them before deploying."
282
- end
283
- end
291
+ raise Strata::CommandError, "You have uncommitted changes. Please commit or stash them before deploying."
292
+ end
293
+
294
+ def run_git_status_checks(branch_name)
295
+ return unless Utils::Git.git_repo?
284
296
 
285
297
  # Fetch from remote
286
298
  with_spinner("Fetching latest changes from remote") do
@@ -309,10 +321,16 @@ module Strata
309
321
  commit_info = Utils::Git.commit_info
310
322
  committer_info = Utils::Git.committer_info
311
323
 
312
- # Get file modifications since last deployment or since HEAD~1
324
+ # Get file modifications since the last deployment. Without a valid baseline
325
+ # commit we can't compute an incremental diff, so send the full file list
326
+ # rather than silently narrowing to the last commit.
313
327
  file_modifications = if last_deployment_commit
314
328
  Utils::Git.changed_files_since(last_deployment_commit)
315
329
  else
330
+ print_warning(
331
+ "No valid previous deployment commit to diff against — sending all files. " \
332
+ "Renames can't be auto-detected this deploy; verify renamed tables afterward or use a rename migration."
333
+ )
316
334
  Utils::Git.file_modifications
317
335
  end
318
336
 
@@ -411,7 +429,6 @@ module Strata
411
429
  production_branch = get_production_branch(config)
412
430
  return unless branch_id == production_branch
413
431
 
414
- prompt = TTY::Prompt.new
415
432
  message = "Deploying to production branch '#{branch_id}'. Continue? [y/N]"
416
433
  return if prompt.yes?(message, default: false)
417
434
 
@@ -440,7 +457,10 @@ module Strata
440
457
 
441
458
  commit_hash = last_deployment["commit"]
442
459
  unless Utils::Git.validate_commit_hash(commit_hash)
443
- print_info("No previous git-based deployment found. Including all files.\n")
460
+ print_warning(
461
+ "Last deployment commit #{commit_hash.inspect} is not a valid git SHA " \
462
+ "(likely a non-git deploy). Can't compute an incremental diff; falling back to a full-file deploy.\n"
463
+ )
444
464
  return nil
445
465
  end
446
466
  print_info("Found last successful deployment at commit: #{commit_hash[0..7]}...\n")
@@ -4,15 +4,12 @@ require_relative "../guard"
4
4
  require_relative "../terminal"
5
5
  require_relative "../output"
6
6
  require_relative "../helpers/project_helper"
7
+ require_relative "../helpers/browser_auth"
7
8
  require_relative "../agent_mode"
8
9
  require_relative "../api/client"
9
10
  require "yaml"
10
11
  require "fileutils"
11
12
  require "tty-prompt"
12
- require "net/http"
13
- require "json"
14
- require "securerandom"
15
- require "rbconfig"
16
13
 
17
14
  module Strata
18
15
  module CLI
@@ -22,12 +19,12 @@ module Strata
22
19
  include Terminal
23
20
  include Output
24
21
  include AgentMode
22
+ include Helpers::BrowserAuth
25
23
 
26
24
  SAMPLE_REPO = "https://github.com/stratasite/tpc-ds-retail-sample"
27
25
  SAMPLE_DIR = "tpc-ds-retail-sample"
28
26
  SAMPLE_PROJECT_UID = "tpc-ds-retail-sample"
29
27
  DEFAULT_SERVER = "http://localhost:8080"
30
- AUTH_TIMEOUT_SECONDS = 300
31
28
 
32
29
  desc "sample", "Clone the Strata sample project and set it up interactively"
33
30
  option :api_key, aliases: ["a"], type: :string, desc: "Strata API key (optional — skip browser auth)"
@@ -37,7 +34,6 @@ module Strata
37
34
  server = options[:server].delete_suffix("/")
38
35
  api_key = options[:api_key]
39
36
  else
40
- prompt = TTY::Prompt.new
41
37
  server = (options[:server] || prompt.ask("Strata server URL:", default: DEFAULT_SERVER) { |q| q.modify :strip }).delete_suffix("/")
42
38
  api_key = fetch_api_key_via_browser(server)
43
39
  end
@@ -68,42 +64,6 @@ module Strata
68
64
  print_info(" Next: cd #{SAMPLE_DIR} && strata deploy")
69
65
  end
70
66
 
71
- no_commands do
72
- def fetch_api_key_via_browser(server)
73
- state = SecureRandom.hex(16)
74
- auth_url = "#{server}/cli/auth?state=#{state}"
75
- poll_url = "#{server}/api/v1/cli/auth/#{state}"
76
-
77
- say "\nOpening your browser to sign in to Strata...", :cyan
78
- open_browser(auth_url)
79
- say "Waiting for authentication (you may close the browser tab once done)", :white
80
-
81
- deadline = Time.now + AUTH_TIMEOUT_SECONDS
82
- loop do
83
- raise Strata::CommandError, "Authentication timed out. Run `strata project sample` to try again." if Time.now > deadline
84
-
85
- begin
86
- response = Net::HTTP.get_response(URI(poll_url))
87
- return JSON.parse(response.body)["api_key"] if response.code == "200"
88
- rescue Errno::ECONNREFUSED
89
- raise Strata::CommandError, "Cannot reach server at #{server}. Is it running?"
90
- end
91
-
92
- print "."
93
- $stdout.flush
94
- sleep 1
95
- end
96
- end
97
-
98
- def open_browser(url)
99
- case RbConfig::CONFIG["host_os"]
100
- when /darwin/ then system("open", url)
101
- when /linux/ then system("xdg-open", url)
102
- when /mswin|mingw/ then system("start", "", url)
103
- end
104
- end
105
- end
106
-
107
67
  desc "link PROJECT_ID", "Link local project to an existing project on the server"
108
68
  def link(project_id)
109
69
  raise Strata::CommandError, "Project ID is required." unless project_id && !project_id.to_s.strip.empty?
@@ -88,12 +88,17 @@ module Strata
88
88
  }
89
89
  end
90
90
 
91
+ # The file-modifications payload to send when there is no valid baseline
92
+ # commit to diff against: the full list of tracked YAML model files, so no
93
+ # added/modified file is silently dropped. NOTE: renames cannot be detected
94
+ # without a baseline — in this mode a rename appears as add + delete.
91
95
  def file_modifications
92
- diff_output = run_git_command("diff", "--name-status", "HEAD~1")
93
- return [] unless diff_output
96
+ return [] unless git_repo?
94
97
 
95
- modifications = parse_diff_output(diff_output)
96
- filter_yml_files(modifications)
98
+ output = run_git_command("ls-files", "*.yml", "*.yaml")
99
+ return [] unless output
100
+
101
+ output.lines.map(&:strip).reject(&:empty?).map { |path| ["M", path] }
97
102
  end
98
103
 
99
104
  def changed_files_since(commit_hash)
@@ -108,8 +113,9 @@ module Strata
108
113
  _, _, status = Open3.capture3("git", "rev-parse", "--verify", "#{commit_hash}^{commit}")
109
114
  return [] unless status.success?
110
115
 
111
- # Get all changed files since that commit (including added, modified, deleted, renamed)
112
- diff_output = run_git_command("diff", "--name-status", commit_hash, "HEAD")
116
+ # Get all changed files since that commit (including added, modified, deleted, renamed).
117
+ # --find-renames makes rename (R) detection explicit rather than relying on git config.
118
+ diff_output = run_git_command("diff", "--name-status", "--find-renames", commit_hash, "HEAD")
113
119
  return [] unless diff_output
114
120
 
115
121
  modifications = parse_diff_output(diff_output)
@@ -130,7 +136,7 @@ module Strata
130
136
 
131
137
  # Get file paths that changed (for added and modified files only, exclude deleted)
132
138
  # --diff-filter=ACMR: Added, Copied, Modified, Renamed (excludes Deleted)
133
- diff_output = run_git_command("diff", "--name-status", "--diff-filter=ACMR", commit_hash, "HEAD")
139
+ diff_output = run_git_command("diff", "--name-status", "--find-renames", "--diff-filter=ACMR", commit_hash, "HEAD")
134
140
  return [] unless diff_output
135
141
 
136
142
  paths = []
@@ -228,9 +234,9 @@ module Strata
228
234
  end
229
235
 
230
236
  def parse_diff_output(output)
231
- output.lines.map do |line|
237
+ output.lines.filter_map do |line|
232
238
  parse_diff_status(line.strip)
233
- end.compact
239
+ end
234
240
  end
235
241
 
236
242
  def parse_diff_status(line)
@@ -314,6 +320,22 @@ module Strata
314
320
  end
315
321
  end
316
322
 
323
+ # Commits everything in a freshly initialized repo. Skips a repo that already
324
+ # has history, so running this inside an existing project never touches it.
325
+ # Returns :committed, :skipped or :failed (usually an unset user.name/email).
326
+ def initial_commit(message)
327
+ return :skipped unless git_repo?
328
+
329
+ _, _, head = Open3.capture3("git", "rev-parse", "--verify", "HEAD")
330
+ return :skipped if head.success?
331
+
332
+ _, _, add = Open3.capture3("git", "add", "-A")
333
+ return :failed unless add.success?
334
+
335
+ _, _, commit = Open3.capture3("git", "commit", "-m", message)
336
+ commit.success? ? :committed : :failed
337
+ end
338
+
317
339
  def commit_file(file_path, commit_message, project_path = Dir.pwd)
318
340
  return false unless git_repo?
319
341
 
@@ -0,0 +1,153 @@
1
+ # Vendored from strata (server) lib/sql/expression_parser.rb, restyled to standard — keep the logic in sync.
2
+ module Strata
3
+ module CLI
4
+ module Sql
5
+ class ExpressionParser
6
+ attr_reader :expression, :columns, :field_refs, :measure_refs, :dimension_refs, :functions
7
+
8
+ def initialize(expression, reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions)
9
+ @expression = expression.downcase
10
+ @reserved = reserved
11
+ @aggregates = aggregates
12
+ @columns = []
13
+ @field_refs = []
14
+ @measure_refs = []
15
+ @dimension_refs = []
16
+ @functions = []
17
+ @wild = false
18
+ parse
19
+ end
20
+
21
+ def wild?
22
+ @wild
23
+ end
24
+
25
+ # Does this expression apply an aggregate function (sum, max, count, ...)?
26
+ # Used to decide whether a calculation behaves as a measure.
27
+ def has_aggregate_functions?
28
+ @functions.any? { |f| @aggregates.include?(f) }
29
+ end
30
+
31
+ # Does the expression carry a window (`... over (...)`)? Windows are valid wherever the
32
+ # expression evaluates over final rows — including a non-grouped query — but can never
33
+ # sit inside a GROUP BY.
34
+ def has_window_functions?
35
+ !!@windowed
36
+ end
37
+
38
+ # The @d dimension references that sit outside every aggregate call. In an
39
+ # aggregating expression these are what SQL demands be grouped: a dimension
40
+ # inside sum(case when [Region]@d = 'x' …) is fine, one in
41
+ # case when [Region]@d is null then null else sum(…) end is not unless
42
+ # the query groups by Region.
43
+ def unaggregated_dimension_refs
44
+ @unaggregated_dimension_refs ||= begin
45
+ refs = []
46
+ stack = [] # one entry per open paren: is it an aggregate call?
47
+ # Bracketed references are consumed atomically FIRST, so a parenthesis inside a
48
+ # reference name ([Month(Date)]) cannot unbalance the stack.
49
+ remove_string_literals(@expression).scan(/\[([^\]]+)\]@d|\[[^\]]+\](?:@m)?|([a-z_][a-z0-9_]*)?\(|\)/) do
50
+ token = Regexp.last_match(0)
51
+ if Regexp.last_match(1)
52
+ ref = Regexp.last_match(1).strip
53
+ refs << ref unless stack.any? || refs.include?(ref)
54
+ elsif token.start_with?("[")
55
+ next # an @m or alias reference: opaque
56
+ elsif token == ")"
57
+ stack.pop
58
+ else
59
+ stack << @aggregates.include?(Regexp.last_match(2).to_s)
60
+ end
61
+ end
62
+ refs
63
+ end
64
+ end
65
+
66
+ class << self
67
+ # Can this (resolved) SQL expression legally appear in a GROUP BY? Aggregates and
68
+ # windows evaluate over the grouped rows, so an expression carrying either cannot —
69
+ # the binder rejects it ("GROUP BY clause cannot contain aggregates").
70
+ def ungroupable?(sql)
71
+ parsed = parse(sql)
72
+ parsed.has_aggregate_functions? || parsed.has_window_functions?
73
+ end
74
+
75
+ def entity_gsub(sql, entity_name, replacement)
76
+ sql.gsub(/\[\s*#{Regexp.quote(entity_name)}\s*\](@[mdMD])?/i, replacement)
77
+ end
78
+
79
+ # Qualify a column reference within sql with table_alias and adapter quoting.
80
+ # Handles both double-quoted identifiers ("limit") and bare identifiers (amount).
81
+ # The quoted form is replaced first; negative lookarounds on the bare-word pass
82
+ # prevent re-matching col that now appears inside the qualified replacement.
83
+ def column_gsub(sql, col, adapter, table_alias)
84
+ qualified = "#{table_alias}.#{adapter.quote(col)}"
85
+ sql = sql.gsub("\"#{col}\"", qualified)
86
+ sql.gsub(/(?<!")\b#{Regexp.quote(col)}\b(?!")/i, qualified)
87
+ end
88
+
89
+ def parse(expression, reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions)
90
+ new(expression, reserved: reserved, aggregates: aggregates)
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def parse
97
+ # Sanitize the expression first
98
+ sanitized = remove_string_literals(@expression)
99
+
100
+ matched_refs = []
101
+ # Extract field references in bracketed notation — BEFORE scanning functions, so a
102
+ # parenthesis inside a reference name ([Month(Date)]) cannot masquerade as a call.
103
+ sanitized.scan(/\[([^\]]+)\](@[md])?/).each do |ref, type|
104
+ matched_refs << "[#{ref}]#{type}"
105
+ case type
106
+ when "@m"
107
+ @measure_refs << ref.strip unless @measure_refs.include?(ref)
108
+ when "@d"
109
+ @dimension_refs << ref.strip unless @dimension_refs.include?(ref)
110
+ else
111
+ @field_refs << ref.strip unless @field_refs.include?(ref)
112
+ end
113
+ end
114
+
115
+ sanitized = remove_matched_refs(sanitized, matched_refs)
116
+
117
+ # Extract functions
118
+ sanitized.scan(/\b([a-zA-Z_][a-zA-Z0-9_]*)\(/).each do |func|
119
+ @functions << func.first
120
+ end
121
+
122
+ # A window clause marks the expression as computed over the final rows — after any
123
+ # grouping — which is what has_window_functions? reports.
124
+ @windowed = sanitized.match?(/\bover\s*\(/)
125
+
126
+ # Extract quoted columns
127
+ @expression.scan(/"([^"]+)"/).flatten.each do |col|
128
+ @columns << col
129
+ end
130
+
131
+ # Extract column references (alphanumeric with optional underscores)
132
+ sanitized.scan(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/).flatten.each do |col|
133
+ @columns << col unless @reserved.include?(col) || @functions.include?(col) || @columns.include?(col)
134
+ end
135
+
136
+ @wild = true if sanitized.match?(/\w+\(\s?\*\s?\)/)
137
+ end
138
+
139
+ def remove_string_literals(expr)
140
+ expr.gsub(/'[^']*'/, "'__str__'")
141
+ end
142
+
143
+ def remove_matched_refs(sanitized, matched_refs)
144
+ l = sanitized
145
+ matched_refs.each do |ref|
146
+ l.gsub!(ref, "")
147
+ end
148
+ l
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Strata
4
4
  module CLI
5
- VERSION = "0.1.15"
5
+ VERSION = "0.1.16"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strata-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajo Abraham
@@ -293,11 +293,13 @@ files:
293
293
  - lib/strata/cli/generators/templates/test.yml
294
294
  - lib/strata/cli/generators/test.rb
295
295
  - lib/strata/cli/guard.rb
296
+ - lib/strata/cli/helpers/browser_auth.rb
296
297
  - lib/strata/cli/helpers/command_context.rb
297
298
  - lib/strata/cli/helpers/datasource_helper.rb
298
299
  - lib/strata/cli/helpers/description_helper.rb
299
300
  - lib/strata/cli/helpers/project_helper.rb
300
301
  - lib/strata/cli/helpers/prompts.rb
302
+ - lib/strata/cli/helpers/semantic_audit_checks.rb
301
303
  - lib/strata/cli/helpers/table_filter.rb
302
304
  - lib/strata/cli/main.rb
303
305
  - lib/strata/cli/output.rb
@@ -316,6 +318,7 @@ files:
316
318
  - lib/strata/cli/utils/deployment_monitor.rb
317
319
  - lib/strata/cli/utils/git.rb
318
320
  - lib/strata/cli/utils/import_manager.rb
321
+ - lib/strata/cli/utils/sql_expression_parser.rb
319
322
  - lib/strata/cli/utils/test_reporter.rb
320
323
  - lib/strata/cli/utils/version_checker.rb
321
324
  - lib/strata/cli/utils/yaml_import_resolver.rb
@@ -340,7 +343,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
340
343
  - !ruby/object:Gem::Version
341
344
  version: '0'
342
345
  requirements: []
343
- rubygems_version: 3.6.7
346
+ rubygems_version: 3.6.9
344
347
  specification_version: 4
345
348
  summary: Command-line interface for the Strata Semantic Analytics System
346
349
  test_files: []