strata-cli 0.1.14 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +15 -6
- data/lib/strata/cli/agent_mode.rb +23 -1
- data/lib/strata/cli/descriptions/init.txt +4 -0
- data/lib/strata/cli/generators/project.rb +20 -12
- data/lib/strata/cli/generators/templates/AGENTS.md +4 -0
- data/lib/strata/cli/generators/templates/project.yml +2 -2
- data/lib/strata/cli/guard.rb +2 -0
- data/lib/strata/cli/helpers/browser_auth.rb +52 -0
- data/lib/strata/cli/helpers/command_context.rb +1 -5
- data/lib/strata/cli/helpers/datasource_helper.rb +1 -1
- data/lib/strata/cli/helpers/prompts.rb +27 -0
- data/lib/strata/cli/helpers/semantic_audit_checks.rb +507 -0
- data/lib/strata/cli/main.rb +0 -8
- data/lib/strata/cli/sub_commands/audit.rb +26 -6
- data/lib/strata/cli/sub_commands/branch.rb +0 -1
- data/lib/strata/cli/sub_commands/datasource.rb +7 -4
- data/lib/strata/cli/sub_commands/deploy.rb +45 -21
- data/lib/strata/cli/sub_commands/project.rb +47 -0
- data/lib/strata/cli/utils/git.rb +31 -9
- data/lib/strata/cli/utils/sql_expression_parser.rb +153 -0
- data/lib/strata/cli/version.rb +1 -1
- metadata +5 -4
- data/lib/strata/cli/descriptions/tutorial.txt +0 -13
- data/lib/strata/cli/generators/tutorial.rb +0 -72
|
@@ -7,6 +7,7 @@ require_relative "../output"
|
|
|
7
7
|
require_relative "../utils/git"
|
|
8
8
|
require "tty-prompt"
|
|
9
9
|
require_relative "../helpers/datasource_helper"
|
|
10
|
+
require_relative "../helpers/prompts"
|
|
10
11
|
require_relative "../helpers/description_helper"
|
|
11
12
|
require_relative "../agent_mode"
|
|
12
13
|
|
|
@@ -62,8 +63,6 @@ module Strata
|
|
|
62
63
|
desc "add [ADAPTER]", "Add a new datasource interactively"
|
|
63
64
|
long_desc_from_file "datasource/add"
|
|
64
65
|
def add(adapter_name = nil)
|
|
65
|
-
prompt = TTY::Prompt.new
|
|
66
|
-
|
|
67
66
|
if adapter_name && !DWH.adapters.keys.map(&:to_s).include?(adapter_name)
|
|
68
67
|
say "Error: '#{adapter_name}' is not a supported adapter", :red
|
|
69
68
|
say "Supported adapters: #{DWH.adapters.keys.join(", ")}", :yellow
|
|
@@ -132,6 +131,11 @@ module Strata
|
|
|
132
131
|
end
|
|
133
132
|
|
|
134
133
|
say "\n✔ Datasource '#{ds_key}' is ready!", :green
|
|
134
|
+
|
|
135
|
+
# 'strata init' runs this command inline and prints its own next steps after.
|
|
136
|
+
return if options["from_init"] || agent_mode?
|
|
137
|
+
|
|
138
|
+
say "\n#{Prompts.build_model_next_steps}", :cyan
|
|
135
139
|
end
|
|
136
140
|
|
|
137
141
|
desc "auth DS_KEY", "Set credentials for the given datasource key (DS_KEY)."
|
|
@@ -182,8 +186,7 @@ module Strata
|
|
|
182
186
|
method_option :catalog, aliases: "c", type: :string, desc: "Change the catalog from the configured one."
|
|
183
187
|
method_option :schema, aliases: "s", type: :string, desc: "Change the schema from the configured one."
|
|
184
188
|
def tables(ds_key = nil)
|
|
185
|
-
|
|
186
|
-
ds_key = resolve_datasource(ds_key, prompt: prompt)
|
|
189
|
+
ds_key = resolve_datasource(ds_key)
|
|
187
190
|
return unless ds_key
|
|
188
191
|
|
|
189
192
|
adapter = create_adapter(ds_key)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
287
|
+
def ensure_working_tree_clean
|
|
276
288
|
return unless Utils::Git.git_repo?
|
|
289
|
+
return unless Utils::Git.uncommitted_changes?
|
|
277
290
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
|
|
@@ -439,6 +456,13 @@ module Strata
|
|
|
439
456
|
return nil unless last_deployment && last_deployment["commit"]
|
|
440
457
|
|
|
441
458
|
commit_hash = last_deployment["commit"]
|
|
459
|
+
unless Utils::Git.validate_commit_hash(commit_hash)
|
|
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
|
+
)
|
|
464
|
+
return nil
|
|
465
|
+
end
|
|
442
466
|
print_info("Found last successful deployment at commit: #{commit_hash[0..7]}...\n")
|
|
443
467
|
commit_hash
|
|
444
468
|
rescue Strata::CommandError
|
|
@@ -4,9 +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"
|
|
11
|
+
require "fileutils"
|
|
12
|
+
require "tty-prompt"
|
|
10
13
|
|
|
11
14
|
module Strata
|
|
12
15
|
module CLI
|
|
@@ -16,6 +19,50 @@ module Strata
|
|
|
16
19
|
include Terminal
|
|
17
20
|
include Output
|
|
18
21
|
include AgentMode
|
|
22
|
+
include Helpers::BrowserAuth
|
|
23
|
+
|
|
24
|
+
SAMPLE_REPO = "https://github.com/stratasite/tpc-ds-retail-sample"
|
|
25
|
+
SAMPLE_DIR = "tpc-ds-retail-sample"
|
|
26
|
+
SAMPLE_PROJECT_UID = "tpc-ds-retail-sample"
|
|
27
|
+
DEFAULT_SERVER = "http://localhost:8080"
|
|
28
|
+
|
|
29
|
+
desc "sample", "Clone the Strata sample project and set it up interactively"
|
|
30
|
+
option :api_key, aliases: ["a"], type: :string, desc: "Strata API key (optional — skip browser auth)"
|
|
31
|
+
option :server, aliases: ["s"], type: :string, desc: "Strata server URL (optional — prompted if omitted)"
|
|
32
|
+
def sample
|
|
33
|
+
if options[:api_key] && options[:server]
|
|
34
|
+
server = options[:server].delete_suffix("/")
|
|
35
|
+
api_key = options[:api_key]
|
|
36
|
+
else
|
|
37
|
+
server = (options[:server] || prompt.ask("Strata server URL:", default: DEFAULT_SERVER) { |q| q.modify :strip }).delete_suffix("/")
|
|
38
|
+
api_key = fetch_api_key_via_browser(server)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if File.directory?(SAMPLE_DIR)
|
|
42
|
+
print_info("Directory '#{SAMPLE_DIR}' already exists — re-using it")
|
|
43
|
+
else
|
|
44
|
+
print_info("Cloning sample project from #{SAMPLE_REPO}...")
|
|
45
|
+
system("git clone #{SAMPLE_REPO} #{SAMPLE_DIR}", exception: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Dir.chdir(SAMPLE_DIR) do
|
|
49
|
+
FileUtils.rm_rf(".git")
|
|
50
|
+
system("git init -b main", exception: true)
|
|
51
|
+
File.write(".gitignore", ".strata\n.strata_cli/\n")
|
|
52
|
+
|
|
53
|
+
strata_path = ".strata"
|
|
54
|
+
File.write(strata_path, "api_key: #{api_key}\nserver: #{server}\n")
|
|
55
|
+
File.chmod(0o600, strata_path)
|
|
56
|
+
|
|
57
|
+
Helpers::ProjectHelper.persist_project_id_to_yml(SAMPLE_PROJECT_UID)
|
|
58
|
+
|
|
59
|
+
system("git add -A", exception: true)
|
|
60
|
+
system('git commit -m "Initial sample project setup"', exception: true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
print_success("✔ Sample project '#{SAMPLE_DIR}' is ready!")
|
|
64
|
+
print_info(" Next: cd #{SAMPLE_DIR} && strata deploy")
|
|
65
|
+
end
|
|
19
66
|
|
|
20
67
|
desc "link PROJECT_ID", "Link local project to an existing project on the server"
|
|
21
68
|
def link(project_id)
|
data/lib/strata/cli/utils/git.rb
CHANGED
|
@@ -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
|
-
|
|
93
|
-
return [] unless diff_output
|
|
96
|
+
return [] unless git_repo?
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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.
|
|
237
|
+
output.lines.filter_map do |line|
|
|
232
238
|
parse_diff_status(line.strip)
|
|
233
|
-
end
|
|
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
|
data/lib/strata/cli/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.1.16
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ajo Abraham
|
|
@@ -264,7 +264,6 @@ files:
|
|
|
264
264
|
- lib/strata/cli/descriptions/deploy/deploy.txt
|
|
265
265
|
- lib/strata/cli/descriptions/deploy/status.txt
|
|
266
266
|
- lib/strata/cli/descriptions/init.txt
|
|
267
|
-
- lib/strata/cli/descriptions/tutorial.txt
|
|
268
267
|
- lib/strata/cli/error_reporter.rb
|
|
269
268
|
- lib/strata/cli/generators/datasource.rb
|
|
270
269
|
- lib/strata/cli/generators/group.rb
|
|
@@ -293,13 +292,14 @@ files:
|
|
|
293
292
|
- lib/strata/cli/generators/templates/table.table_name.yml
|
|
294
293
|
- lib/strata/cli/generators/templates/test.yml
|
|
295
294
|
- lib/strata/cli/generators/test.rb
|
|
296
|
-
- lib/strata/cli/generators/tutorial.rb
|
|
297
295
|
- lib/strata/cli/guard.rb
|
|
296
|
+
- lib/strata/cli/helpers/browser_auth.rb
|
|
298
297
|
- lib/strata/cli/helpers/command_context.rb
|
|
299
298
|
- lib/strata/cli/helpers/datasource_helper.rb
|
|
300
299
|
- lib/strata/cli/helpers/description_helper.rb
|
|
301
300
|
- lib/strata/cli/helpers/project_helper.rb
|
|
302
301
|
- lib/strata/cli/helpers/prompts.rb
|
|
302
|
+
- lib/strata/cli/helpers/semantic_audit_checks.rb
|
|
303
303
|
- lib/strata/cli/helpers/table_filter.rb
|
|
304
304
|
- lib/strata/cli/main.rb
|
|
305
305
|
- lib/strata/cli/output.rb
|
|
@@ -318,6 +318,7 @@ files:
|
|
|
318
318
|
- lib/strata/cli/utils/deployment_monitor.rb
|
|
319
319
|
- lib/strata/cli/utils/git.rb
|
|
320
320
|
- lib/strata/cli/utils/import_manager.rb
|
|
321
|
+
- lib/strata/cli/utils/sql_expression_parser.rb
|
|
321
322
|
- lib/strata/cli/utils/test_reporter.rb
|
|
322
323
|
- lib/strata/cli/utils/version_checker.rb
|
|
323
324
|
- lib/strata/cli/utils/yaml_import_resolver.rb
|
|
@@ -342,7 +343,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
342
343
|
- !ruby/object:Gem::Version
|
|
343
344
|
version: '0'
|
|
344
345
|
requirements: []
|
|
345
|
-
rubygems_version: 3.6.
|
|
346
|
+
rubygems_version: 3.6.9
|
|
346
347
|
specification_version: 4
|
|
347
348
|
summary: Command-line interface for the Strata Semantic Analytics System
|
|
348
349
|
test_files: []
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
Sets up the Strata tutorial project locally by cloning the tutorial repo,
|
|
2
|
-
wiring it to your Strata instance, and preparing it for `strata deploy`.
|
|
3
|
-
|
|
4
|
-
The --api-key value is shown on the tutorial setup page
|
|
5
|
-
in your Strata instance. Copy-paste them directly from there.
|
|
6
|
-
|
|
7
|
-
After running this command, `cd` into the project directory and run
|
|
8
|
-
`strata deploy` to deploy the tutorial to your Strata instance.
|
|
9
|
-
|
|
10
|
-
Example:
|
|
11
|
-
strata tutorial -a your_api_key
|
|
12
|
-
cd tutorial
|
|
13
|
-
strata deploy
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "group"
|
|
4
|
-
require_relative "../helpers/project_helper"
|
|
5
|
-
require_relative "../output"
|
|
6
|
-
require "yaml"
|
|
7
|
-
require "fileutils"
|
|
8
|
-
|
|
9
|
-
module Strata
|
|
10
|
-
module CLI
|
|
11
|
-
module Generators
|
|
12
|
-
class Tutorial < Group
|
|
13
|
-
TUTORIAL_REPO = "https://github.com/stratasite/strata-tutorial-project"
|
|
14
|
-
DEFAULT_SERVER = "http://localhost:8080"
|
|
15
|
-
TUTORIAL_PROJECT = "strata-tutorial-project"
|
|
16
|
-
|
|
17
|
-
class_option :api_key, aliases: ["a"], type: :string, desc: "Strata API key"
|
|
18
|
-
|
|
19
|
-
desc "Sets up the Strata tutorial project locally."
|
|
20
|
-
|
|
21
|
-
def validate_options
|
|
22
|
-
raise Strata::CommandError, "--api-key (-a) is required." unless options[:api_key]
|
|
23
|
-
@uid = TUTORIAL_PROJECT
|
|
24
|
-
@project_id = 1
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def clone_tutorial_repo
|
|
28
|
-
if File.directory?(@uid)
|
|
29
|
-
Output.print_status(:skip, "Directory '#{@uid}' already exists — re-using it", type: :info, context: self)
|
|
30
|
-
return
|
|
31
|
-
end
|
|
32
|
-
Output.print_status(:clone, "Cloning tutorial from #{TUTORIAL_REPO}", type: :success, context: self)
|
|
33
|
-
run "git clone #{TUTORIAL_REPO} #{@uid}", verbose: false, capture: true
|
|
34
|
-
raise Strata::CommandError, "Failed to clone tutorial repo." unless File.directory?(@uid)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def reinitialize_git
|
|
38
|
-
inside @uid do
|
|
39
|
-
FileUtils.rm_rf(".git")
|
|
40
|
-
run "git init -b main", verbose: false, capture: true
|
|
41
|
-
create_file ".gitignore", ".strata\n.strata_cli/\n", force: true
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def write_strata_config
|
|
46
|
-
strata_path = File.join(@uid, ".strata")
|
|
47
|
-
File.write(strata_path, "api_key: #{options[:api_key]}\nserver: #{DEFAULT_SERVER}\n")
|
|
48
|
-
File.chmod(0o600, strata_path)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def update_project_yml
|
|
52
|
-
project_yml = File.join(@uid, "project.yml")
|
|
53
|
-
return unless File.exist?(project_yml)
|
|
54
|
-
|
|
55
|
-
Helpers::ProjectHelper.persist_project_id_to_yml(@project_id, project_yml_path: project_yml)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def initial_commit
|
|
59
|
-
inside @uid do
|
|
60
|
-
run "git add -A", verbose: false, capture: true
|
|
61
|
-
run 'git commit -m "Initial tutorial project setup"', verbose: false, capture: true
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def completion_message
|
|
66
|
-
Output.print_success("\n✔ Tutorial project '#{@uid}' is ready!\n", context: self)
|
|
67
|
-
Output.print_info(" Next: cd #{@uid} && strata deploy --skip-audit\n", context: self)
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|