Skip to content

Commit 3705bf1

Browse files
committed
Quote Tableau field refs and raw identifiers
1 parent 0353d27 commit 3705bf1

3 files changed

Lines changed: 51 additions & 15 deletions

File tree

sidemantic/adapters/tableau.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
)
9898
_MID_RE = re.compile(r"\bMID\s*\(", re.IGNORECASE)
9999
_FIND_RE = re.compile(r"\bFIND\s*\(", re.IGNORECASE)
100+
_SIMPLE_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
100101

101102
# Simple function renames (Tableau name -> SQL name)
102103
_SIMPLE_RENAMES: list[tuple[re.Pattern, str]] = [
@@ -202,7 +203,7 @@ def _replace_func_balanced(text: str, func_re: re.Pattern, template: str) -> str
202203

203204

204205
def _replace_field_refs(formula: str) -> str:
205-
"""Replace [FieldName] references with bare column names, skipping string literals.
206+
"""Replace [FieldName] references with quoted column names, skipping string literals.
206207
207208
Handles Tableau's qualified names: [table].[column] -> column
208209
Skips brackets inside string literals (single or double quoted).
@@ -250,7 +251,7 @@ def _replace_field_refs(formula: str) -> str:
250251
else:
251252
i = end + 1
252253

253-
result.append(field_name)
254+
result.append(_quote_identifier_if_needed(_normalize_column_name(field_name)))
254255
continue
255256

256257
result.append(c)
@@ -333,12 +334,12 @@ def _translate_formula(formula: str | None) -> tuple[str | None, bool]:
333334
# Strip // comments before translation (they can contain IF/THEN keywords)
334335
result = _COMMENT_RE.sub("", formula).strip()
335336

336-
# Replace [Field] references with bare column names (string-literal-aware)
337-
result = _replace_field_refs(result)
338-
339337
# Convert Tableau double-quoted string literals to SQL single quotes
340338
result = _convert_double_quotes(result)
341339

340+
# Replace [Field] references with quoted column names (string-literal-aware)
341+
result = _replace_field_refs(result)
342+
342343
# ZN(x) -> COALESCE(x, 0)
343344
result = _replace_func_balanced(result, _ZN_RE, "COALESCE({arg}, 0)")
344345

@@ -599,6 +600,17 @@ def _quote_sql_identifier(identifier: str) -> str:
599600
return '"' + identifier.replace('"', '""') + '"'
600601

601602

603+
def _quote_identifier_if_needed(identifier: str) -> str:
604+
"""Quote a raw Tableau field name when it is not a simple SQL identifier."""
605+
if identifier.startswith('"') and identifier.endswith('"'):
606+
return identifier
607+
if _SIMPLE_SQL_IDENTIFIER_RE.match(identifier):
608+
return identifier
609+
if "." in identifier:
610+
return _quote_column_reference(identifier)
611+
return _quote_sql_identifier(identifier)
612+
613+
602614
def _quote_column_reference(column_name: str) -> str:
603615
"""Quote a possibly-qualified column reference."""
604616
parts = _strip_brackets(column_name).split(".")
@@ -839,9 +851,8 @@ def _build_dimension(
839851
dim_type = _DATATYPE_MAP.get(datatype or "", "categorical")
840852
granularity = _DATATYPE_GRANULARITY.get(datatype or "")
841853

842-
# Quote names with spaces/special chars so they produce valid SQL
843-
if sql is None and (" " in name or "-" in name):
844-
sql = f'"{name}"'
854+
if sql is None:
855+
sql = _quote_identifier_if_needed(name)
845856

846857
return Dimension(
847858
name=name,
@@ -876,9 +887,8 @@ def _build_metric(
876887
return Metric(name=name, agg="count", sql=None, label=caption, public=not hidden)
877888

878889
# For metrics without a formula, sql defaults to the column name.
879-
# Quote names with spaces/special chars so they produce valid SQL.
880890
if sql is None and not formula:
881-
sql = f'"{name}"' if " " in name or "-" in name else name
891+
sql = _quote_identifier_if_needed(name)
882892

883893
if agg_lower in _PASSTHROUGH_AGGS or not is_translatable:
884894
# Passthrough or untranslatable: make derived metric
@@ -974,9 +984,7 @@ def _import_orphan_metadata_columns(
974984
agg_lower = aggregation.lower() if aggregation else ""
975985

976986
# Use remote_alias as the SQL expression (actual DB column name)
977-
sql = remote_alias or col_name
978-
if " " in sql or "-" in sql:
979-
sql = f'"{sql}"'
987+
sql = _quote_identifier_if_needed(remote_alias or col_name)
980988

981989
# Role inference based on type and aggregation
982990
is_measure = agg_lower in measure_aggs and local_type in ("real", "integer")
@@ -1451,7 +1459,7 @@ def _parse_groups_as_segments(self, ds_elem: ET.Element) -> list[Segment]:
14511459

14521460
if members and level_col:
14531461
quoted_members = ", ".join(f"'{m}'" for m in members)
1454-
sql = f"{level_col} IN ({quoted_members})"
1462+
sql = f"{_quote_identifier_if_needed(level_col)} IN ({quoted_members})"
14551463
segments.append(Segment(name=group_name, sql=sql))
14561464

14571465
return segments

tests/adapters/tableau/test_formula.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,20 @@ def test_qualified_field_ref():
175175
assert sql == "amount + 1"
176176

177177

178+
def test_field_ref_with_spaces_quoted():
179+
"""Field refs with spaces are quoted as identifiers."""
180+
sql, ok = _translate_formula("[Extracts Incremented At]")
181+
assert ok
182+
assert sql == '"Extracts Incremented At"'
183+
184+
185+
def test_parameter_field_ref_with_spaces_quoted():
186+
"""Qualified field refs keep the leaf name and quote it when needed."""
187+
sql, ok = _translate_formula("[Parameters].[Parameter 1]")
188+
assert ok
189+
assert sql == '"Parameter 1"'
190+
191+
178192
def test_countd_nested():
179193
"""COUNTD with nested expression."""
180194
sql, ok = _translate_formula("COUNTD(IF [status] = 'active' THEN [user_id] END)")
@@ -206,9 +220,11 @@ def test_double_quoted_strings():
206220
"""Double-quoted string literals converted to single quotes."""
207221
sql, ok = _translate_formula('IF [x] THEN "Selected" ELSE "Not Selected" END')
208222
assert ok
223+
assert "x" in sql
209224
assert "'Selected'" in sql
210225
assert "'Not Selected'" in sql
211-
assert '"' not in sql
226+
assert '"Selected"' not in sql
227+
assert '"Not Selected"' not in sql
212228

213229

214230
def test_comment_stripped():

tests/adapters/tableau/test_parsing.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,18 @@ def test_hidden_fields(adapter):
220220
assert price.public is True
221221

222222

223+
def test_raw_dimension_names_with_special_chars_are_quoted(adapter):
224+
"""Raw Tableau dimension names fall back to valid quoted SQL identifiers."""
225+
dimension = adapter._build_dimension("Country/Region", "string", None, None, False, None)
226+
assert dimension.sql == '"Country/Region"'
227+
228+
229+
def test_raw_metric_names_with_special_chars_are_quoted(adapter):
230+
"""Raw Tableau metric names fall back to valid quoted SQL identifiers."""
231+
metric = adapter._build_metric("Profit Ratio (%)", "sum", None, None, False, True, None, None)
232+
assert metric.sql == '"Profit Ratio (%)"'
233+
234+
223235
# =============================================================================
224236
# MULTI-TABLE JOIN TESTS
225237
# =============================================================================

0 commit comments

Comments
 (0)