strata-cli 0.1.21 → 0.1.22

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8114ee5b03683396612b2831650670281cdcf3da321c40abc259e2e1a31847c9
4
- data.tar.gz: e84f43d3831b3cfb28da805c728b9005801aca677c0deeb620639ccaf632d213
3
+ metadata.gz: 55ce87ef5e374c4b0a1c60a5e23081596108a353bc543272a7aa2b749fd56930
4
+ data.tar.gz: 27bc1dab19e8dbc6efb1256b25c36389a182157dad90bd9fdfd14f9c73f648af
5
5
  SHA512:
6
- metadata.gz: e6c15b69e6170dd5196f94c31b91cb0d5c229c2703402d588c72b6de6899a07e73f62af5ebd2ef7fb127df2e8832eca594644c947ec917e40bcc3daec91822a6
7
- data.tar.gz: 81f8cec6b27936af833f2bbfae9eb1b878d14a3fd0e828c7acab5399235cb3a64e8c19b10bb6b5345ad8331e7b0cfbbf4250ff48e23362648386abdd0bcfb32c
6
+ metadata.gz: 9e9824b911b205b5876c1c104cfa2c187c7d768197b87ffda5bf3d891bfd2abc9140309b422c173fe8d964a9fa4939aa331bdc6e8412e25b0e6a37d172e4453e
7
+ data.tar.gz: 0bc343e01fac99afacc7bb5e3c188d530c7b7ceafa359c0c269d38c32e536b837bca2038b95ad2962672c5ebcc961eb7a972f72fee91abc5a3707c22ebf64a49
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.22] - 2026-11-05
4
+
5
+ ### Fixed
6
+
7
+ The local semantic audit added in 0.1.16 mirrors the server's deploy-time
8
+ validations. Several of its checks rejected models the server accepts, which blocked
9
+ `strata audit` -- and `strata deploy`, which runs it first.
10
+
11
+ - **A table name reused in a second datasource**: the audit resolved a relationship's tables by name across the whole project, so two datasources declaring the same table collided and whichever file was read last won. A valid join in the other datasource failed with "is not in datasource ... (tables can only join within one datasource)", and the same join defined in both datasources failed as a duplicate. Table names are unique per datasource on the server, so both are now scoped to the relationship's own datasource. This is what hot-tier routing looks like, where one table is defined in a hot and a cold datasource.
12
+ - **Datasource references that differ only in case**: `datasource: TPCDS` in a relationship file and `tpcds` in a table file resolved to different strings and compared as different datasources. An upper-case key in `datasources.yml` was also rejected outright as "not found", though the server resolves it. Both now match case-insensitively, as the server does.
13
+ - **Measures written across lines**: the aggregate-function check was missing the server's `/m` flag, so any measure written as a YAML block scalar (`sql: |`) or spanning lines was reported as having no aggregation function.
14
+ - **Quoted identifiers on backtick warehouses**: expressions were always parsed with ANSI double quotes, so on MySQL, BigQuery, ClickHouse and Databricks a backtick-quoted column read as no column at all and dimensions were rejected for not referencing one. The parser now takes the adapter's own identifier quote.
15
+ - **`join` and `cardinality` casing**: `join: Inner` and `cardinality: Many_To_One` were rejected. The server downcases and strips both before validating, and defaults a missing `join` to `inner`.
16
+ - **Explicitly empty keys**: `snapshot:`, `display_type:` and `join:` left blank were rejected; the server treats nil as absent.
17
+ - **Tables with no fields**: `fields` was required and `fields: []` rejected, though the server accepts a table that declares none. It now warns instead.
18
+ - **`.yaml` model files**: only `.yml` was ever read, so a `tbl.*.yaml` was reported as a stray file to delete, never validated, invisible to relationship checks, and left out of the deploy archive -- even though the server deploys `tbl.*.{yml,yaml}`.
19
+
20
+ ### Added
21
+
22
+ The deployer silently swallows a number of mistakes: the deploy goes green and the
23
+ model differs from the YAML. Those are the ones worth catching locally, so these are
24
+ errors even though the server accepts them.
25
+
26
+ - **Measure-only keys on a dimension**: `snapshot`, `exclusion_type`, `exclusions` and `inclusions` apply to measures only, but all four are columns on the shared `fields` table and the deployer writes them to a dimension untyped -- `snapshot: ending` stores `0`, which reads back as `beginning`, and shows up as `snapshot: 0` in field JSON. Reported once, naming the keys, instead of as several misleading enum errors.
27
+ - **`exclusions` without `exclusion_type`**: the rules are stored, never validated and never applied, so the measure aggregates at the grain the rules existed to prevent; under row-level security the query then fails with an internal "Exclusion dim ids are not cached" error.
28
+ - **Unknown table-level keys**: the deployer slices a `tbl` file down to the keys it reads, so anything else is dropped. In particular `snapshot_date:` -- which this project's own table template suggested until now -- deployed a table with no snapshot date at all.
29
+ - **`lookup: "no"` and friends**: these land on boolean columns, and `ActiveModel::Type::Boolean` casts everything outside its `FALSE_VALUES` to **true**, so a quoted `"no"`, `"No"` or a bare `n` silently stores the opposite of what was written. Bare `no` is fine, so this is a one-character quoting difference that inverts join handling and is invisible in a diff. The error now says so.
30
+ - **Table template**: `table.table_name.yml` suggested `snapshot_date:`, but the deployer reads `snapshot:`. Corrected in `AGENTS.md` in 0.1.18 and missed here.
31
+
32
+ ### Changed
33
+
34
+ - **`strata audit connections` still fails on an unreachable warehouse; `strata audit all` now warns.** The server only checks that a datasource's config can build an adapter and never requires the warehouse to be reachable from this machine, so a warehouse behind a VPN or an IP allow-list no longer blocks a deploy that would have worked. Running `connections` is an explicit request to test them, so there it is still an error.
35
+
3
36
  ## [0.1.21] - 2026-11-04
4
37
 
5
38
  ### Added
@@ -26,7 +26,7 @@ cost: 10
26
26
  # Optional: If this is a snapshot table like an inventory table, you can
27
27
  # set the snapshot date dimension here. This will allow the creation of
28
28
  # snapshot measures on this table.
29
- # snapshot_date: <snapshot dimension name>
29
+ # snapshot: <snapshot dimension name>
30
30
 
31
31
  # Optional: Tag table for additional metadata.
32
32
  # tags:
@@ -153,8 +153,9 @@ module Strata
153
153
 
154
154
  normalized = value.to_s.downcase.strip
155
155
 
156
- # Match by key first
157
- return value if datasources.key?(normalized)
156
+ datasources.each_key do |key|
157
+ return key if key.to_s.downcase.strip == normalized
158
+ end
158
159
 
159
160
  # Match by name (case-insensitive)
160
161
  datasources.each do |key, config|
@@ -13,6 +13,7 @@ module Strata
13
13
  #
14
14
  # Server-only checks (universe/path integrity, physical table existence
15
15
  # in the warehouse, licenses) are deliberately not replicated here.
16
+
16
17
  module SemanticAuditChecks
17
18
  # deployer/handlers/table.rb dispatches on the literal string
18
19
  # "dimension"; any other value silently becomes a measure and a
@@ -24,7 +25,7 @@ module Strata
24
25
  DISPLAY_TYPES = %w[default html url email phone_number image].freeze
25
26
  DATE_GRAINS = %w[raw millisecond second minute hour day month week quarter year].freeze
26
27
  NUMERIC_DATA_TYPES = %w[integer decimal bigint].freeze
27
- # semantic/measure.rb + measure/excludable.rb + measure/includable.rb
28
+ # semantic/measure.rb + semantic/measure/excludable.rb + semantic/measure/includable.rb
28
29
  SNAPSHOT_TYPES = %w[beginning ending].freeze
29
30
  EXCLUSION_TYPES = %w[exclude exclude_all_except exclude_all].freeze
30
31
  EXCLUSION_ENTITY_TYPES = %w[dimension table universe].freeze
@@ -34,8 +35,8 @@ module Strata
34
35
  DYNAMIC_DATE = /(\d{1,3})([hdwmqy])/i
35
36
  # semantic/join_def.rb enums — note: no many_to_many.
36
37
  JOIN_TYPES = %w[inner left right].freeze
37
- # semantic/expression.rb:17 — the server accepts any word(...) call.
38
- MEASURE_SQL_FORMAT = /\A.*\w+\(.*\).*\z/i
38
+ # semantic/expression.rb:17 — any word(...) call. /m so a block scalar or a measure spanning lines still matches.
39
+ MEASURE_SQL_FORMAT = /\A.*\w+\(.*\).*\z/im
39
40
 
40
41
  private
41
42
 
@@ -65,9 +66,11 @@ module Strata
65
66
  next if name.empty?
66
67
 
67
68
  if seen_names[name]
69
+ # handlers/datasource.rb:18-20 merges them onto one row, so tables
70
+ # silently repoint at the other warehouse.
68
71
  failures << {
69
72
  file: "datasources.yml",
70
- message: "Datasource '#{key}' duplicates the name '#{config["name"]}' used by '#{seen_names[name]}'"
73
+ message: "Datasource '#{key}' duplicates the name '#{config["name"]}' used by '#{seen_names[name]}' (on deploy they merge into one datasource)"
71
74
  }
72
75
  else
73
76
  seen_names[name] = key
@@ -82,6 +85,7 @@ module Strata
82
85
  def validate_field_semantics(field, file, failures, meta)
83
86
  name = field["name"]
84
87
 
88
+ # handlers/table.rb:113 turns anything but "dimension" into a Measure.
85
89
  unless FIELD_TYPES.include?(field["type"].to_s.downcase.strip)
86
90
  failures << {
87
91
  file: file,
@@ -90,10 +94,29 @@ module Strata
90
94
  end
91
95
 
92
96
  validate_field_enums(name, field, file, failures)
93
- validate_measure_rules(name, field, file, failures)
97
+ if field["type"].to_s.downcase.strip == "dimension"
98
+ validate_no_measure_keys(name, field, file, failures)
99
+ else
100
+ validate_measure_rules(name, field, file, failures)
101
+ end
94
102
  validate_expression_semantics(name, field, file, failures, meta)
95
103
  end
96
104
 
105
+ # Written to a dimension by handlers/table.rb:119 but untyped, so
106
+ # `snapshot: ending` stores 0 and reads back as `beginning`.
107
+ MEASURE_ONLY_KEYS = %w[snapshot exclusion_type exclusions inclusions].freeze
108
+
109
+ def validate_no_measure_keys(name, field, file, failures)
110
+ present = MEASURE_ONLY_KEYS.select { field.key?(it) }
111
+ return if present.empty?
112
+
113
+ failures << {
114
+ file: file,
115
+ message: "Field '#{name}' is a dimension, so #{present.sort.join(", ")} #{(present.size == 1) ? "does" : "do"} not apply " \
116
+ "(#{(present.size == 1) ? "it is" : "they are"} measure-only; the server stores the value untyped and never reads it)"
117
+ }
118
+ end
119
+
97
120
  def validate_field_enums(name, field, file, failures)
98
121
  data_type = field["data_type"].to_s.strip
99
122
  if data_type.empty?
@@ -105,7 +128,8 @@ module Strata
105
128
  }
106
129
  end
107
130
 
108
- if field.key?("display_type") && !DISPLAY_TYPES.include?(field["display_type"].to_s)
131
+ # field.rb:16 is a nullable enum: an empty key is an absent one.
132
+ if !field["display_type"].to_s.strip.empty? && !DISPLAY_TYPES.include?(field["display_type"].to_s)
109
133
  failures << {
110
134
  file: file,
111
135
  message: "Field '#{name}' has invalid display_type '#{field["display_type"]}' (expected one of: #{DISPLAY_TYPES.join(", ")})"
@@ -119,7 +143,8 @@ module Strata
119
143
  end
120
144
 
121
145
  def validate_measure_rules(name, field, file, failures)
122
- if field.key?("snapshot") && !SNAPSHOT_TYPES.include?(field["snapshot"].to_s)
146
+ # measure.rb:6 is `enum :snapshot, [...], validate: {allow_nil: true}`.
147
+ if !field["snapshot"].to_s.strip.empty? && !SNAPSHOT_TYPES.include?(field["snapshot"].to_s)
123
148
  failures << {
124
149
  file: file,
125
150
  message: "Field '#{name}' has invalid snapshot '#{field["snapshot"]}' (expected beginning or ending)"
@@ -139,6 +164,16 @@ module Strata
139
164
  failures << {file: file, message: "Field '#{name}' exclusions should be empty when exclusion_type is exclude_all"}
140
165
  end
141
166
 
167
+ # excludable.rb:9,12 gates the rules on exclusion_type, so without it they
168
+ # are stored and never applied.
169
+ if exclusions.any? && exclusion_type.to_s.strip.empty?
170
+ failures << {
171
+ file: file,
172
+ message: "Field '#{name}' has exclusions but no 'exclusion_type', so the server stores the rules and never applies them"
173
+ }
174
+ exclusions = []
175
+ end
176
+
142
177
  exclusions.each_with_index do |rule, idx|
143
178
  unless rule.is_a?(Hash)
144
179
  failures << {file: file, message: "Field '#{name}' exclusion at index #{idx} must be a mapping"}
@@ -181,14 +216,14 @@ module Strata
181
216
  end
182
217
  end
183
218
 
184
- # ---- expressions (semantic/expression.rb, expression/references.rb) ----
219
+ # ---- expressions (semantic/expression.rb, semantic/expression/references.rb) ----
185
220
 
186
221
  def validate_expression_semantics(name, field, file, failures, meta)
187
222
  sql = expression_sql(field["expression"])
188
223
  return unless sql
189
224
 
190
225
  begin
191
- parsed = Sql::ExpressionParser.new(sql, reserved: meta[:reserved], aggregates: meta[:aggregates])
226
+ parsed = Sql::ExpressionParser.new(sql, reserved: meta[:reserved], aggregates: meta[:aggregates], quote: meta[:quote])
192
227
  rescue => e
193
228
  failures << {file: file, message: "Field '#{name}': expression could not be parsed: #{e.message}"}
194
229
  return
@@ -236,7 +271,7 @@ module Strata
236
271
  if types.nil?
237
272
  failures << {file: file, message: "Field '#{name}': [#{ref}] field was not found in the project"}
238
273
  elsif types.size > 1
239
- # references.rb:97
274
+ # references.rb:98
240
275
  failures << {
241
276
  file: file,
242
277
  message: "Field '#{name}': [#{ref}] matched multiple fields. Use @m or @d to specify the field further."
@@ -246,7 +281,7 @@ module Strata
246
281
  end
247
282
  end
248
283
 
249
- # references.rb:127-131
284
+ # references.rb:126-132
250
285
  if dimension && measure_refs.any?
251
286
  failures << {
252
287
  file: file,
@@ -286,7 +321,7 @@ module Strata
286
321
  return
287
322
  end
288
323
 
289
- # deployer/handlers/table.rb:204 + table/snappable.rb:40-43
324
+ # deployer/handlers/table.rb:204 + table/snappable.rb:38-43
290
325
  unless (project_semantic_index[:fields][snap_name] || []).include?("dimension")
291
326
  failures << {file: file, message: "Snapshot dimension '#{content["snapshot"]}' was not found in the project"}
292
327
  return
@@ -294,6 +329,8 @@ module Strata
294
329
 
295
330
  data_type = project_semantic_index[:dimension_data_types][snap_name].to_s
296
331
  unless %w[date date_time].include?(data_type)
332
+ # snappable.rb:38-43, which skip_reference_validation keeps off the
333
+ # deploy path -- snapshot_visitor then returns a wrong number silently.
297
334
  failures << {
298
335
  file: file,
299
336
  message: "Snapshot date '#{content["snapshot"]}' must be a date or date_time dimension (got '#{data_type}')"
@@ -388,11 +425,13 @@ module Strata
388
425
 
389
426
  sql = definition["sql"].to_s.downcase
390
427
  # join_def.rb:70-77
391
- if !sql.empty? && !(sql.match?(/left\./) && sql.match?(/right\./))
428
+ if !sql.empty? && !(sql.include?("left.") && sql.include?("right."))
392
429
  failures << {file: file, message: "Relationship '#{key}' join sql should be of the format left.column = right.column"}
393
430
  end
394
431
 
395
- if definition.key?("join") && !JOIN_TYPES.include?(definition["join"].to_s)
432
+ # relationships.rb:121 downcases and defaults to "inner" first.
433
+ join = definition["join"].to_s.downcase.strip
434
+ if !join.empty? && !JOIN_TYPES.include?(join)
396
435
  failures << {
397
436
  file: file,
398
437
  message: "Relationship '#{key}' has invalid join '#{definition["join"]}' (expected one of: #{JOIN_TYPES.join(", ")})"
@@ -404,19 +443,19 @@ module Strata
404
443
  end
405
444
 
406
445
  def validate_relationship_tables(key, definition, ds_key, file, failures)
407
- tables = project_semantic_index[:tables]
446
+ table_datasources = project_semantic_index[:table_datasources]
408
447
  left = definition["left"].to_s.downcase.strip
409
448
  right = definition["right"].to_s.downcase.strip
410
449
 
411
450
  [["left", left], ["right", right]].each do |side, table_name|
412
451
  next if table_name.empty?
413
452
 
414
- table = tables[table_name]
415
- if table.nil?
453
+ declared_in = table_datasources[table_name]
454
+ if declared_in.nil?
416
455
  # deployer/handlers/relationships.rb:106-107
417
456
  failures << {file: file, message: "Relationship '#{key}': #{side} table '#{definition[side]}' was not found in the project"}
418
- elsif ds_key && (table_ds = resolve_datasource_value(table["datasource"])) && table_ds != ds_key
419
- # join_def.rb:65-67
457
+ elsif ds_key && !declared_in.include?(ds_key)
458
+ # join_def.rb:65-67 — the name is taken in some other datasource only.
420
459
  failures << {
421
460
  file: file,
422
461
  message: "Relationship '#{key}': #{side} table '#{definition[side]}' is not in datasource '#{ds_key}' (tables can only join within one datasource)"
@@ -427,14 +466,18 @@ module Strata
427
466
  return if left.empty? || right.empty?
428
467
 
429
468
  # join_def.rb:79-83 — joins are bi-directional, so either order collides.
430
- pair = [left, right].sort
469
+ # Scoped by datasource: the pair identifies table records.
470
+ pair = [ds_key, *[left, right].sort]
431
471
  if (existing = seen_join_pairs[pair])
472
+ # relationships.rb:113-115 raises within a file; across files
473
+ # first_or_initialize lets glob order decide. Non-deterministic either way.
432
474
  failures << {
433
475
  file: file,
434
- message: "Relationship '#{key}': a join between '#{definition["left"]}' and '#{definition["right"]}' already exists (#{existing})"
476
+ message: "Relationship '#{key}': a join between '#{definition["left"]}' and '#{definition["right"]}' " \
477
+ "already exists (#{existing[:file]}: #{existing[:key]})"
435
478
  }
436
479
  else
437
- seen_join_pairs[pair] = "#{file}: #{key}"
480
+ seen_join_pairs[pair] = {file: file, key: key}
438
481
  end
439
482
  end
440
483
 
@@ -442,19 +485,26 @@ module Strata
442
485
  @seen_join_pairs ||= {}
443
486
  end
444
487
 
488
+ # Reported without failing the audit.
489
+ def semantic_warnings
490
+ @semantic_warnings ||= []
491
+ end
492
+
445
493
  # ---- shared infrastructure ----
446
494
 
447
495
  # Name index of every table and field declared across the project's
448
496
  # tbl.*.yml files, keyed by lowercased name (the server resolves
449
497
  # references by lower(name), and the expression parser downcases its
450
498
  # input, so lowercase keys line up on both sides).
499
+ # Fields stay keyed by name alone (uniqueness scope: [:type, :branch_id]);
500
+ # tables are per-datasource, so that index maps a name to its datasources.
451
501
  def project_semantic_index
452
502
  @project_semantic_index ||= begin
453
503
  fields = {}
454
504
  dimension_data_types = {}
455
- tables = {}
505
+ table_datasources = {}
456
506
 
457
- Dir.glob("models/**/*.yml").each do |file|
507
+ Dir.glob("models/**/*.{yml,yaml}").each do |file|
458
508
  next unless File.basename(file).start_with?("tbl.")
459
509
 
460
510
  content = begin
@@ -466,7 +516,9 @@ module Strata
466
516
 
467
517
  table_name = content["name"].to_s.downcase.strip
468
518
  unless table_name.empty?
469
- tables[table_name] = {"datasource" => content["datasource"], "file" => file}
519
+ # Unique per datasource (semantic/table.rb:32), so one name can
520
+ # belong to several.
521
+ (table_datasources[table_name] ||= []) << resolve_datasource_value(content["datasource"])
470
522
  end
471
523
 
472
524
  Array(content["fields"]).each do |f|
@@ -481,7 +533,7 @@ module Strata
481
533
  end
482
534
  end
483
535
 
484
- {fields: fields, tables: tables, dimension_data_types: dimension_data_types}
536
+ {fields: fields, table_datasources: table_datasources, dimension_data_types: dimension_data_types}
485
537
  end
486
538
  end
487
539
 
@@ -492,14 +544,20 @@ module Strata
492
544
  def adapter_meta(ds_value)
493
545
  @adapter_meta ||= {}
494
546
  key = resolve_datasource_value(ds_value)
495
- config = (key && (datasources[key] || datasources[key.to_s.downcase.strip])) || {}
547
+ config = (key && datasources[key]) || {}
496
548
  sym = config["adapter"].to_s.downcase.strip.to_sym
497
549
 
498
550
  @adapter_meta[sym] ||= if DWH.adapter?(sym)
499
551
  klass = DWH.get_adapter(sym)
500
- {reserved: klass.reserved_keywords, aggregates: klass.aggregate_functions}
552
+ {
553
+ reserved: klass.reserved_keywords,
554
+ aggregates: klass.aggregate_functions,
555
+ # references.rb:5-8 passes the adapter's own identifier quote, so a
556
+ # backtick warehouse reads `order` as a column.
557
+ quote: klass.adapter_settings[:quote]
558
+ }
501
559
  else
502
- {reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions}
560
+ {reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions, quote: Sql::ExpressionParser::ANSI_QUOTE}
503
561
  end
504
562
  end
505
563
  end
@@ -24,7 +24,7 @@ module Strata
24
24
  include SemanticAuditChecks
25
25
  include AgentMode
26
26
 
27
- REQUIRED_KEYS_FOR_TABLE_MODEL = %w[name physical_name fields datasource].freeze
27
+ REQUIRED_KEYS_FOR_TABLE_MODEL = %w[name physical_name datasource].freeze
28
28
  REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL = ["datasource"].freeze
29
29
  REQUIRED_KEYS_FOR_RELATIONSHIP_DEFINITION = %w[left right sql cardinality].freeze
30
30
  RELATIONSHIP_CARDINALITIES = %w[one_to_one one_to_many many_to_one].freeze
@@ -35,7 +35,15 @@ module Strata
35
35
  exclusions inclusions extended_blend_group synonyms expression tags
36
36
  ].freeze
37
37
  ALLOWED_EXPRESSION_KEYS = %w[sql array lookup primary_key].freeze
38
+ # What the deployer reads from a tbl file (handlers/table.rb:4, :62, :71,
39
+ # :152, :202), plus the CLI's own imports.
40
+ ALLOWED_TABLE_KEYS = %w[
41
+ name description physical_name cost metadata tags
42
+ datasource fields snapshot partition partitions imports
43
+ ].freeze
38
44
  STRAY_SCRIPT_EXTENSIONS = %w[.rb .py .sh .ipynb].freeze
45
+ # deploy_job.rb:121,162 globs models as {yml,yaml}, so both are models.
46
+ MODEL_EXTENSIONS = %w[.yml .yaml].freeze
39
47
  EXPRESSION_BOOLEAN_KEYS = %w[array lookup primary_key].freeze
40
48
 
41
49
  # Set default command so `strata audit` still works as `strata audit all`
@@ -46,8 +54,9 @@ module Strata
46
54
  results = {}
47
55
  results[:yaml] = run_check("Checking YAML syntax") { audit_yaml_syntax }
48
56
  results[:models] = run_check("Checking model definitions") { audit_models }
49
- results[:connections] = run_check("Checking data source connections") { audit_connections }
57
+ results[:connections] = run_check("Checking data source connections") { audit_connections(semantic_warnings) }
50
58
 
59
+ warn_semantic_warnings
51
60
  warn_unverifiable_datasources
52
61
  warn_stray_files
53
62
  report_results(results)
@@ -62,12 +71,14 @@ module Strata
62
71
  desc "models", "Check model definitions and schema structure"
63
72
  def models
64
73
  results = {models: run_check("Checking model definitions") { audit_models }}
74
+ warn_semantic_warnings
65
75
  report_results(results)
66
76
  end
67
77
 
68
78
  desc "connections", "Check data source connections"
69
79
  def connections
70
80
  results = {connections: run_check("Checking data source connections") { audit_connections }}
81
+ warn_semantic_warnings
71
82
  warn_unverifiable_datasources
72
83
  report_results(results)
73
84
  end
@@ -86,7 +97,7 @@ module Strata
86
97
  # A Strata project holds YAML, not scripts. Agents sometimes leave scratch
87
98
  # validators behind; warn, don't fail — a helper script is the user's call.
88
99
  def warn_stray_files
89
- strays = Dir.glob("models/**/*").select { File.file?(it) && File.extname(it) != ".yml" }
100
+ strays = Dir.glob("models/**/*").select { File.file?(it) && !MODEL_EXTENSIONS.include?(File.extname(it)) }
90
101
  strays += Dir.glob("*").select { File.file?(it) && STRAY_SCRIPT_EXTENSIONS.include?(File.extname(it)) }
91
102
  return if strays.empty?
92
103
 
@@ -94,6 +105,14 @@ module Strata
94
105
  strays.sort.each { print_warning(" #{it}") }
95
106
  end
96
107
 
108
+ # Problems the server tolerates: shown, but never failed.
109
+ def warn_semantic_warnings
110
+ return if semantic_warnings.empty?
111
+
112
+ print_warning("\n Accepted by the server, but worth a look:")
113
+ semantic_warnings.each { print_warning(" #{it[:file]}: #{it[:message]}") }
114
+ end
115
+
97
116
  # Pointing a datasource at a path the server owns is a normal setup — the sample
98
117
  # project's /data/tpcds.duckdb ships inside the Strata image — so say which
99
118
  # connections went unchecked instead of failing a correct project.
@@ -121,7 +140,7 @@ module Strata
121
140
  end
122
141
 
123
142
  def audit_yaml_syntax
124
- files = Dir.glob("models/**/*.yml") + ["datasources.yml"].select { |f| File.exist?(f) }
143
+ files = Dir.glob("models/**/*.{yml,yaml}") + ["datasources.yml"].select { |f| File.exist?(f) }
125
144
 
126
145
  failures = []
127
146
  files.each do |file|
@@ -136,7 +155,7 @@ module Strata
136
155
 
137
156
  def audit_models
138
157
  failures = audit_datasource_definitions
139
- Dir.glob("models/**/*.yml").each do |file|
158
+ Dir.glob("models/**/*.{yml,yaml}").each do |file|
140
159
  audit_imports(file, failures)
141
160
  audit_model_file(file, failures)
142
161
  end
@@ -173,19 +192,34 @@ module Strata
173
192
  end
174
193
  end
175
194
 
195
+ # handlers/table.rb:54 drops it, so an unquoted `yes:` does nothing.
176
196
  def validate_structure(content, file, failures)
177
197
  content.each_key do |k|
178
- failures << {file: file, message: "Top level key #{k} is not a string"} unless k.is_a?(String)
198
+ next if k.is_a?(String)
199
+
200
+ failures << {file: file, message: "Top level key #{k} is not a string"}
179
201
  end
180
202
  end
181
203
 
182
204
  def validate_table_model(content, file, failures)
183
205
  validate_required_keys(content, file, REQUIRED_KEYS_FOR_TABLE_MODEL, failures)
206
+ validate_table_keys(content, file, failures)
184
207
  validate_datasource_reference(content, file, failures)
185
208
  validate_table_fields(content["fields"], file, failures, adapter_meta(content["datasource"]))
186
209
  validate_table_semantics(content, file, failures)
187
210
  end
188
211
 
212
+ # Same slice: `snapshot_date:` deploys a table with no snapshot date.
213
+ def validate_table_keys(content, file, failures)
214
+ unknown_keys = content.keys.select { it.is_a?(String) }.map(&:to_s) - ALLOWED_TABLE_KEYS
215
+ return if unknown_keys.empty?
216
+
217
+ failures << {
218
+ file: file,
219
+ message: "Table has unknown top-level #{(unknown_keys.size == 1) ? "key" : "keys"}: #{unknown_keys.sort.join(", ")}"
220
+ }
221
+ end
222
+
189
223
  def validate_relationship_model(content, file, failures)
190
224
  validate_required_keys(content, file, REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL, failures)
191
225
  validate_datasource_reference(content, file, failures)
@@ -211,13 +245,14 @@ module Strata
211
245
  end
212
246
 
213
247
  def validate_table_fields(fields, file, failures, adapter_meta)
214
- unless fields.is_a?(Array)
215
- failures << {file: file, message: "'fields' must be an array"}
248
+ # handlers/table.rb:62 returns early, so a fieldless table deploys fine.
249
+ if fields.nil? || (fields.is_a?(Array) && fields.empty?)
250
+ semantic_warnings << {file: file, message: "Table has no fields, so it contributes nothing to the model yet"}
216
251
  return
217
252
  end
218
253
 
219
- if fields.empty?
220
- failures << {file: file, message: "'fields' must not be empty"}
254
+ unless fields.is_a?(Array)
255
+ failures << {file: file, message: "'fields' must be an array"}
221
256
  return
222
257
  end
223
258
 
@@ -234,6 +269,8 @@ module Strata
234
269
  def validate_field_definition(field, file, idx, failures, adapter_meta)
235
270
  field_name = field["name"] || "index #{idx}"
236
271
 
272
+ # handlers/table.rb:119 drops the rest, so on an existing field
273
+ # `datatype:` for `data_type:` silently leaves the old value.
237
274
  unknown_keys = field.keys.map(&:to_s) - ALLOWED_FIELD_KEYS
238
275
  if unknown_keys.any?
239
276
  failures << {
@@ -247,6 +284,7 @@ module Strata
247
284
 
248
285
  return unless field.key?("format")
249
286
 
287
+ # Normalized to nil and skipped (formattable.rb:10), so it never appears.
250
288
  validate_field_format(field_name, field["format"], file, failures)
251
289
  end
252
290
 
@@ -286,31 +324,40 @@ module Strata
286
324
  failures << {file: file, message: "Field '#{field_name}' expression must include non-empty 'sql'"}
287
325
  end
288
326
 
327
+ # ActiveModel::Type::Boolean casts everything outside FALSE_VALUES to
328
+ # true, so a quoted "no" stores the opposite. Bare `no` is fine.
289
329
  EXPRESSION_BOOLEAN_KEYS.each do |key|
290
330
  next unless expression.key?(key) || expression.key?(key.to_sym)
291
331
 
292
- value = expression[key] || expression[key.to_sym]
332
+ value = expression.key?(key) ? expression[key] : expression[key.to_sym]
293
333
  next if value == true || value == false
294
334
 
295
- failures << {
296
- file: file,
297
- message: "Field '#{field_name}' expression.#{key} must be true or false"
298
- }
335
+ message = "Field '#{field_name}' expression.#{key} is #{value.inspect}, not true or false"
336
+ if INVERTS_TO_TRUE.include?(value)
337
+ message += " — the server stores it as true; write it unquoted"
338
+ end
339
+
340
+ failures << {file: file, message: message}
299
341
  end
300
342
  end
301
343
 
302
- def validate_field_format(field_name, format_value, file, failures)
344
+ # Reads as false, casts to true -- absent from FALSE_VALUES.
345
+ INVERTS_TO_TRUE = ["no", "No", "NO", "n", "N", "nil", "null", "none"].freeze
346
+
347
+ def validate_field_format(field_name, format_value, file, problems)
303
348
  case format_value
349
+ when nil
350
+ problems << {file: file, message: "Field '#{field_name}' format must not be empty"}
304
351
  when String
305
352
  shortcut = format_value.strip
306
353
  if shortcut.empty?
307
- failures << {file: file, message: "Field '#{field_name}' format must not be empty"}
354
+ problems << {file: file, message: "Field '#{field_name}' format must not be empty"}
308
355
  return
309
356
  end
310
357
 
311
358
  type = shortcut.split(":", 2).first
312
359
  unless VALID_FORMAT_TYPES.include?(type)
313
- failures << {
360
+ problems << {
314
361
  file: file,
315
362
  message: "Field '#{field_name}' format shortcut has invalid type '#{type}' (expected one of: #{VALID_FORMAT_TYPES.join(", ")})"
316
363
  }
@@ -318,15 +365,15 @@ module Strata
318
365
  when Hash
319
366
  type = format_value["type"] || format_value[:type]
320
367
  if type.nil? || type.to_s.strip.empty?
321
- failures << {file: file, message: "Field '#{field_name}' format hash must include type"}
368
+ problems << {file: file, message: "Field '#{field_name}' format hash must include type"}
322
369
  elsif !VALID_FORMAT_TYPES.include?(type.to_s)
323
- failures << {
370
+ problems << {
324
371
  file: file,
325
372
  message: "Field '#{field_name}' format hash has invalid type '#{type}' (expected one of: #{VALID_FORMAT_TYPES.join(", ")})"
326
373
  }
327
374
  end
328
375
  else
329
- failures << {
376
+ problems << {
330
377
  file: file,
331
378
  message: "Field '#{field_name}' format must be a string shortcut or a hash with type"
332
379
  }
@@ -353,14 +400,19 @@ module Strata
353
400
  validate_cardinality(name, definition["cardinality"], file, failures) if definition.key?("cardinality")
354
401
  end
355
402
 
403
+ # relationships.rb:122 normalizes before the enum sees it:
404
+ # `join_def["cardinality"]&.downcase&.strip`.
356
405
  def validate_cardinality(name, cardinality, file, failures)
357
- return if RELATIONSHIP_CARDINALITIES.include?(cardinality)
406
+ return if RELATIONSHIP_CARDINALITIES.include?(cardinality.to_s.downcase.strip)
358
407
 
359
408
  failures << {file: file, message: "Relationship '#{name}' has invalid cardinality '#{cardinality}'"}
360
409
  end
361
410
 
362
- def audit_connections
411
+ # +unreachable+ takes the results: `connections` passes failures, `all`
412
+ # passes warnings -- the server never requires us to reach the warehouse.
413
+ def audit_connections(unreachable = nil)
363
414
  failures = []
415
+ unreachable ||= failures
364
416
 
365
417
  unless File.exist?("datasources.yml")
366
418
  failures << {file: "datasources.yml", message: "File not found"}
@@ -381,7 +433,7 @@ module Strata
381
433
  adapter = create_adapter(ds_key)
382
434
  adapter.test_connection(raise_exception: true)
383
435
  rescue => e
384
- failures << {
436
+ unreachable << {
385
437
  file: "datasources.yml",
386
438
  message: "Connection failed for '#{ds_key}': #{e.message}"
387
439
  }
@@ -34,7 +34,9 @@ module Strata
34
34
  end
35
35
 
36
36
  private_class_method def collect_yml_files(project_path)
37
- Dir.glob(File.join(project_path, "**", "*.yml")).select do |file_path|
37
+ # The server globs models as {yml,yaml} (deploy_job.rb:121,162), so a
38
+ # .yaml model has to ship too -- it used to be left out of the archive.
39
+ Dir.glob(File.join(project_path, "**", "*.{yml,yaml}")).select do |file_path|
38
40
  File.file?(file_path)
39
41
  end
40
42
  end
@@ -5,7 +5,19 @@ module Strata
5
5
  class ExpressionParser
6
6
  attr_reader :expression, :columns, :field_refs, :measure_refs, :dimension_refs, :functions
7
7
 
8
- def initialize(expression, reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions)
8
+ # The identifier quote for ANSI SQL and any adapter whose quote setting is
9
+ # bracket-style: [Name] is Strata's field-reference syntax, so an adapter
10
+ # that quotes with brackets (SQL Server) quotes identifiers with double
11
+ # quotes in expressions instead.
12
+ ANSI_QUOTE = '"@exp"'
13
+
14
+ # +quote+ is the adapter's identifier quote template from its DWH settings
15
+ # ("@exp" wrapped in the warehouse's quote characters). Only that quote
16
+ # marks an identifier: on a backtick warehouse (MySQL, BigQuery, ...) a
17
+ # double-quoted span is a string literal, not a column.
18
+ def initialize(expression, reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions, quote: ANSI_QUOTE)
19
+ @open, @close = self.class.quote_pair(quote)
20
+ @raw = expression
9
21
  @expression = expression.downcase
10
22
  @reserved = reserved
11
23
  @aggregates = aggregates
@@ -46,7 +58,7 @@ module Strata
46
58
  stack = [] # one entry per open paren: is it an aggregate call?
47
59
  # Bracketed references are consumed atomically FIRST, so a parenthesis inside a
48
60
  # reference name ([Month(Date)]) cannot unbalance the stack.
49
- remove_string_literals(@expression).scan(/\[([^\]]+)\]@d|\[[^\]]+\](?:@m)?|([a-z_][a-z0-9_]*)?\(|\)/) do
61
+ scrub(@expression).scan(/\[([^\]]+)\]@d|\[[^\]]+\](?:@m)?|([a-z_][a-z0-9_]*)?\(|\)/) do
50
62
  token = Regexp.last_match(0)
51
63
  if Regexp.last_match(1)
52
64
  ref = Regexp.last_match(1).strip
@@ -64,6 +76,22 @@ module Strata
64
76
  end
65
77
 
66
78
  class << self
79
+ # The opening and closing identifier quote characters an adapter's quote
80
+ # template ("@exp", `@exp`, [@exp]) wraps around a name. Bracket quoting
81
+ # is Strata's field-reference syntax, so it falls back to ANSI quotes.
82
+ def quote_pair(template)
83
+ template = template.to_s
84
+ template = ANSI_QUOTE if template.strip.empty? || template.start_with?("[")
85
+ open, close = template.split("@exp", 2)
86
+ [open, (close.nil? || close.empty?) ? open : close]
87
+ end
88
+
89
+ # String literals: single-quoted always, double-quoted too on a
90
+ # warehouse whose identifier quote is something else (MySQL, BigQuery).
91
+ def literal_regex(open)
92
+ (open == '"') ? /'(?:[^']|'')*'/ : /'(?:[^']|'')*'|"(?:[^"]|"")*"/
93
+ end
94
+
67
95
  # Can this (resolved) SQL expression legally appear in a GROUP BY? Aggregates and
68
96
  # windows evaluate over the grouped rows, so an expression carrying either cannot —
69
97
  # the binder rejects it ("GROUP BY clause cannot contain aggregates").
@@ -86,16 +114,19 @@ module Strata
86
114
  sql.gsub(/(?<!")\b#{Regexp.quote(col)}\b(?!")/i, qualified)
87
115
  end
88
116
 
89
- def parse(expression, reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions)
90
- new(expression, reserved: reserved, aggregates: aggregates)
117
+ def parse(expression, reserved: DWH.reserved_keywords, aggregates: DWH.aggregate_functions, quote: ANSI_QUOTE)
118
+ new(expression, reserved: reserved, aggregates: aggregates, quote: quote)
91
119
  end
92
120
  end
93
121
 
94
122
  private
95
123
 
96
124
  def parse
97
- # Sanitize the expression first
98
- sanitized = remove_string_literals(@expression)
125
+ # Quoted identifiers are collected from the original text -- their case
126
+ # is part of the name -- and scrubbed out of the working copy along with
127
+ # string literals, so nothing inside them reads as a function or column.
128
+ @quoted_identifiers = []
129
+ sanitized = scrub(@raw) { |ident| @quoted_identifiers << ident unless @quoted_identifiers.include?(ident) }.downcase
99
130
 
100
131
  matched_refs = []
101
132
  # Extract field references in bracketed notation — BEFORE scanning functions, so a
@@ -123,10 +154,7 @@ module Strata
123
154
  # grouping — which is what has_window_functions? reports.
124
155
  @windowed = sanitized.match?(/\bover\s*\(/)
125
156
 
126
- # Extract quoted columns
127
- @expression.scan(/"([^"]+)"/).flatten.each do |col|
128
- @columns << col
129
- end
157
+ @columns.concat(@quoted_identifiers)
130
158
 
131
159
  # Extract column references (alphanumeric with optional underscores)
132
160
  sanitized.scan(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/).flatten.each do |col|
@@ -136,8 +164,21 @@ module Strata
136
164
  @wild = true if sanitized.match?(/\w+\(\s?\*\s?\)/)
137
165
  end
138
166
 
139
- def remove_string_literals(expr)
140
- expr.gsub(/'[^']*'/, "'__str__'")
167
+ # Replaces string literals with a placeholder literal and quoted
168
+ # identifiers with a numeric placeholder (yielding each identifier's
169
+ # name), leaving an expression whose remaining words are all bare
170
+ # references, keywords, or functions. Inside a quoted identifier a
171
+ # doubled quote character is an escaped quote.
172
+ def scrub(expr)
173
+ o, c = Regexp.quote(@open), Regexp.quote(@close)
174
+ expr.gsub(/#{self.class.literal_regex(@open)}|#{o}((?:[^#{c}]|#{c}#{c})+)#{c}/) do
175
+ if Regexp.last_match(1)
176
+ yield Regexp.last_match(1) if block_given?
177
+ "0"
178
+ else
179
+ "'__str__'"
180
+ end
181
+ end
141
182
  end
142
183
 
143
184
  def remove_matched_refs(sanitized, matched_refs)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Strata
4
4
  module CLI
5
- VERSION = "0.1.21"
5
+ VERSION = "0.1.22"
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.21
4
+ version: 0.1.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajo Abraham