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 +4 -4
- data/CHANGELOG.md +33 -0
- data/lib/strata/cli/generators/templates/table.table_name.yml +1 -1
- data/lib/strata/cli/helpers/datasource_helper.rb +3 -2
- data/lib/strata/cli/helpers/semantic_audit_checks.rb +87 -29
- data/lib/strata/cli/sub_commands/audit.rb +76 -24
- data/lib/strata/cli/utils/archive.rb +3 -1
- data/lib/strata/cli/utils/sql_expression_parser.rb +53 -12
- data/lib/strata/cli/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55ce87ef5e374c4b0a1c60a5e23081596108a353bc543272a7aa2b749fd56930
|
|
4
|
+
data.tar.gz: 27bc1dab19e8dbc6efb1256b25c36389a182157dad90bd9fdfd14f9c73f648af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
157
|
-
|
|
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 —
|
|
38
|
-
MEASURE_SQL_FORMAT = /\A.*\w+\(.*\).*\z/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
if
|
|
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 && (
|
|
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
|
|
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"]}'
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 &&
|
|
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
|
-
{
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
failures << {file: file, message: "'fields' must
|
|
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]
|
|
332
|
+
value = expression.key?(key) ? expression[key] : expression[key.to_sym]
|
|
293
333
|
next if value == true || value == false
|
|
294
334
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
message
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
+
problems << {file: file, message: "Field '#{field_name}' format hash must include type"}
|
|
322
369
|
elsif !VALID_FORMAT_TYPES.include?(type.to_s)
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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)
|
data/lib/strata/cli/version.rb
CHANGED