Skip to content

Commit 0ddf836

Browse files
committed
Fix double-quote escaping, object-graph compound joins, nested IF
- _convert_double_quotes now escapes apostrophes inside Tableau double-quoted strings ("O'Reilly" -> 'O''Reilly') and preserves escaped double quotes ("" -> literal ") instead of converting them to single-quote escapes. - _ObjectGraphJoin stores full column_pairs list instead of single pair, so composite-key joins from object-graph relationships emit all ON predicates. - Nested IF/THEN/ELSE/END blocks are translated recursively.
1 parent b72f50d commit 0ddf836

2 files changed

Lines changed: 46 additions & 31 deletions

File tree

sidemantic/adapters/tableau.py

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -293,18 +293,23 @@ def _convert_double_quotes(text: str) -> str:
293293
i += 1
294294
elif c == '"':
295295
# Double-quoted string: convert to single quotes
296+
# Escape any apostrophes inside, and preserve escaped "" as literal "
296297
result.append("'")
297298
i += 1
298299
while i < len(text):
299300
if text[i] == '"':
300301
if i + 1 < len(text) and text[i + 1] == '"':
301-
# Escaped double quote -> escaped single quote
302-
result.append("''")
302+
# Escaped double quote "" -> literal " in single-quoted string
303+
result.append('"')
303304
i += 2
304305
else:
305306
result.append("'")
306307
i += 1
307308
break
309+
elif text[i] == "'":
310+
# Apostrophe inside string: escape for SQL single-quoted literal
311+
result.append("''")
312+
i += 1
308313
else:
309314
result.append(text[i])
310315
i += 1
@@ -723,8 +728,7 @@ class _ObjectGraphJoin:
723728

724729
first_table: str
725730
second_table: str
726-
first_field: str
727-
second_field: str
731+
column_pairs: list[tuple[str, str]] # [(first_field, second_field), ...]
728732

729733

730734
@dataclass
@@ -1250,20 +1254,22 @@ def _parse_object_graph(self, ds_elem: ET.Element) -> _ObjectGraphInfo:
12501254
second_table = obj_map.get(second_ep.get("object-id", ""), "") if second_ep is not None else ""
12511255

12521256
if first_table and second_table and pairs:
1253-
left_col, right_col = pairs[0]
1254-
left_field = left_col.rsplit(".", 1)[-1] if "." in left_col else left_col
1255-
right_field = right_col.rsplit(".", 1)[-1] if "." in right_col else right_col
1257+
field_pairs = [
1258+
(
1259+
lc.rsplit(".", 1)[-1] if "." in lc else lc,
1260+
rc.rsplit(".", 1)[-1] if "." in rc else rc,
1261+
)
1262+
for lc, rc in pairs
1263+
]
12561264
joins.append(
12571265
_ObjectGraphJoin(
12581266
first_table=first_table,
12591267
second_table=second_table,
1260-
first_field=left_field,
1261-
second_field=right_field,
1268+
column_pairs=field_pairs,
12621269
)
12631270
)
1264-
# Extract just the column names (strip table qualifiers)
1265-
fk = left_field
1266-
pk = right_field
1271+
# Use first pair for Relationship fk/pk
1272+
fk, pk = field_pairs[0]
12671273
relationships.append(
12681274
Relationship(
12691275
name=second_table,
@@ -1316,9 +1322,8 @@ def _build_collection_sql(
13161322
join_clauses.append(
13171323
self._build_collection_join_clause(
13181324
join.first_table,
1319-
join.first_field,
13201325
join.second_table,
1321-
join.second_field,
1326+
join.column_pairs,
13221327
table_map,
13231328
alias_by_table,
13241329
field_sources,
@@ -1328,12 +1333,13 @@ def _build_collection_sql(
13281333
remaining.remove(join)
13291334
progressed = True
13301335
elif join.second_table in connected and join.first_table not in connected:
1336+
# Reverse the column pairs for the swapped direction
1337+
reversed_pairs = [(rc, lc) for lc, rc in join.column_pairs]
13311338
join_clauses.append(
13321339
self._build_collection_join_clause(
13331340
join.second_table,
1334-
join.second_field,
13351341
join.first_table,
1336-
join.first_field,
1342+
reversed_pairs,
13371343
table_map,
13381344
alias_by_table,
13391345
field_sources,
@@ -1373,30 +1379,23 @@ def _build_collection_sql(
13731379
def _build_collection_join_clause(
13741380
self,
13751381
connected_table: str,
1376-
connected_field: str,
13771382
joining_table: str,
1378-
joining_field: str,
1383+
column_pairs: list[tuple[str, str]],
13791384
table_map: dict[str, str],
13801385
alias_by_table: dict[str, str],
13811386
field_sources: dict[str, tuple[str, str]],
13821387
) -> str:
13831388
"""Build one LEFT JOIN clause for a logical-layer collection."""
13841389
joining_table_qualified = table_map[joining_table]
1385-
left_expr = self._collection_field_sql(
1386-
connected_table,
1387-
connected_field,
1388-
alias_by_table,
1389-
field_sources,
1390-
)
1391-
right_expr = self._collection_field_sql(
1392-
joining_table,
1393-
joining_field,
1394-
alias_by_table,
1395-
field_sources,
1396-
)
1390+
on_parts = []
1391+
for left_field, right_field in column_pairs:
1392+
left_expr = self._collection_field_sql(connected_table, left_field, alias_by_table, field_sources)
1393+
right_expr = self._collection_field_sql(joining_table, right_field, alias_by_table, field_sources)
1394+
on_parts.append(f"{left_expr} = {right_expr}")
1395+
on_clause = " AND ".join(on_parts)
13971396
return (
13981397
f"LEFT JOIN {_quote_table_reference(joining_table_qualified)} AS {alias_by_table[joining_table]} "
1399-
f"ON {left_expr} = {right_expr}"
1398+
f"ON {on_clause}"
14001399
)
14011400

14021401
def _collection_field_sql(

tests/adapters/tableau/test_formula.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,22 @@ def test_double_quoted_strings():
227227
assert '"Not Selected"' not in sql
228228

229229

230+
def test_double_quoted_with_apostrophe():
231+
"""Apostrophe inside double-quoted string is escaped for SQL."""
232+
sql, ok = _translate_formula('IF [x] THEN "O\'Reilly" ELSE "none" END')
233+
assert ok
234+
assert "O''Reilly" in sql
235+
236+
237+
def test_double_quoted_escaped_double():
238+
"""Escaped double quote inside double-quoted string preserved as literal double-quote."""
239+
# Tableau: "He said ""Hi""" (escaped "" means literal ")
240+
formula = 'IF [x] THEN "said ""Hi""" ELSE "no" END'
241+
sql, ok = _translate_formula(formula)
242+
assert ok
243+
assert '"Hi"' in sql
244+
245+
230246
def test_comment_stripped():
231247
"""// comments are stripped before translation."""
232248
sql, ok = _translate_formula("// Don't notify if alert fails\n[status]")

0 commit comments

Comments
 (0)