|
97 | 97 | ) |
98 | 98 | _MID_RE = re.compile(r"\bMID\s*\(", re.IGNORECASE) |
99 | 99 | _FIND_RE = re.compile(r"\bFIND\s*\(", re.IGNORECASE) |
| 100 | +_SIMPLE_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
100 | 101 |
|
101 | 102 | # Simple function renames (Tableau name -> SQL name) |
102 | 103 | _SIMPLE_RENAMES: list[tuple[re.Pattern, str]] = [ |
@@ -202,7 +203,7 @@ def _replace_func_balanced(text: str, func_re: re.Pattern, template: str) -> str |
202 | 203 |
|
203 | 204 |
|
204 | 205 | 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. |
206 | 207 |
|
207 | 208 | Handles Tableau's qualified names: [table].[column] -> column |
208 | 209 | Skips brackets inside string literals (single or double quoted). |
@@ -250,7 +251,7 @@ def _replace_field_refs(formula: str) -> str: |
250 | 251 | else: |
251 | 252 | i = end + 1 |
252 | 253 |
|
253 | | - result.append(field_name) |
| 254 | + result.append(_quote_identifier_if_needed(_normalize_column_name(field_name))) |
254 | 255 | continue |
255 | 256 |
|
256 | 257 | result.append(c) |
@@ -333,12 +334,12 @@ def _translate_formula(formula: str | None) -> tuple[str | None, bool]: |
333 | 334 | # Strip // comments before translation (they can contain IF/THEN keywords) |
334 | 335 | result = _COMMENT_RE.sub("", formula).strip() |
335 | 336 |
|
336 | | - # Replace [Field] references with bare column names (string-literal-aware) |
337 | | - result = _replace_field_refs(result) |
338 | | - |
339 | 337 | # Convert Tableau double-quoted string literals to SQL single quotes |
340 | 338 | result = _convert_double_quotes(result) |
341 | 339 |
|
| 340 | + # Replace [Field] references with quoted column names (string-literal-aware) |
| 341 | + result = _replace_field_refs(result) |
| 342 | + |
342 | 343 | # ZN(x) -> COALESCE(x, 0) |
343 | 344 | result = _replace_func_balanced(result, _ZN_RE, "COALESCE({arg}, 0)") |
344 | 345 |
|
@@ -599,6 +600,17 @@ def _quote_sql_identifier(identifier: str) -> str: |
599 | 600 | return '"' + identifier.replace('"', '""') + '"' |
600 | 601 |
|
601 | 602 |
|
| 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 | + |
602 | 614 | def _quote_column_reference(column_name: str) -> str: |
603 | 615 | """Quote a possibly-qualified column reference.""" |
604 | 616 | parts = _strip_brackets(column_name).split(".") |
@@ -839,9 +851,8 @@ def _build_dimension( |
839 | 851 | dim_type = _DATATYPE_MAP.get(datatype or "", "categorical") |
840 | 852 | granularity = _DATATYPE_GRANULARITY.get(datatype or "") |
841 | 853 |
|
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) |
845 | 856 |
|
846 | 857 | return Dimension( |
847 | 858 | name=name, |
@@ -876,9 +887,8 @@ def _build_metric( |
876 | 887 | return Metric(name=name, agg="count", sql=None, label=caption, public=not hidden) |
877 | 888 |
|
878 | 889 | # For metrics without a formula, sql defaults to the column name. |
879 | | - # Quote names with spaces/special chars so they produce valid SQL. |
880 | 890 | 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) |
882 | 892 |
|
883 | 893 | if agg_lower in _PASSTHROUGH_AGGS or not is_translatable: |
884 | 894 | # Passthrough or untranslatable: make derived metric |
@@ -974,9 +984,7 @@ def _import_orphan_metadata_columns( |
974 | 984 | agg_lower = aggregation.lower() if aggregation else "" |
975 | 985 |
|
976 | 986 | # 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) |
980 | 988 |
|
981 | 989 | # Role inference based on type and aggregation |
982 | 990 | 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]: |
1451 | 1459 |
|
1452 | 1460 | if members and level_col: |
1453 | 1461 | 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})" |
1455 | 1463 | segments.append(Segment(name=group_name, sql=sql)) |
1456 | 1464 |
|
1457 | 1465 | return segments |
0 commit comments