Skip to content

Commit d075f7d

Browse files
committed
fix: address Oracle review findings for UPSERT compiler
- Fix column qualification: only qualify excluded.* in SET clause, not target-table columns (visit_column checks is_upsert_set + table name) - Remove _has_join toggle from visit_on_conflict_do_update - Reject unsupported where/index_where params with ValueError - Require index_elements (excel-dbapi parser needs ON CONFLICT (...)) - Fix ColumnCollection set_ to map column→column (not column→string) - Remove dead code: index_where/where compilation paths - Add 6 regression tests for all edge cases
1 parent bd893c7 commit d075f7d

3 files changed

Lines changed: 141 additions & 84 deletions

File tree

src/sqlalchemy_excel/compiler.py

Lines changed: 60 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -280,15 +280,15 @@ def visit_column(
280280
ambiguous_table_name_map: MutableMapping[str, str] | None = None,
281281
**kwargs: Any,
282282
) -> str:
283-
"""Override to conditionally include table prefix in column references.
284-
285-
For single-table queries: SELECT id, name FROM users
286-
For JOIN queries: SELECT users.id, orders.amount FROM users JOIN orders ...
287-
"""
283+
if kwargs.get("is_upsert_set"):
284+
table_name = getattr(getattr(column, "table", None), "name", None)
285+
use_table = table_name == "excluded"
286+
else:
287+
use_table = self._has_join
288288
return super().visit_column(
289289
column,
290290
add_to_result_map=add_to_result_map,
291-
include_table=self._has_join,
291+
include_table=use_table,
292292
result_map_targets=result_map_targets,
293293
ambiguous_table_name_map=ambiguous_table_name_map,
294294
**kwargs,
@@ -910,17 +910,6 @@ def _on_conflict_target(self, clause: Any, **kw: Any) -> str:
910910
)
911911
for c in clause.inferred_target_elements
912912
)
913-
if clause.inferred_target_whereclause is not None:
914-
whereclause_kw = dict(kw)
915-
whereclause_kw.update(
916-
include_table=False,
917-
use_schema=False,
918-
literal_execute=True,
919-
)
920-
target_text += " WHERE %s" % self.process(
921-
clause.inferred_target_whereclause,
922-
**whereclause_kw,
923-
)
924913
else:
925914
target_text = ""
926915

@@ -943,81 +932,69 @@ def visit_on_conflict_do_update(self, on_conflict: Any, **kw: Any) -> str:
943932

944933
set_parameters = dict(clause.update_values_to_set)
945934

946-
original_has_join = self._has_join
947-
self._has_join = True
948-
try:
949-
insert_statement = cast(Any, self.stack[-1]["selectable"])
950-
cols = insert_statement.table.c
951-
set_kw = dict(kw)
952-
set_kw.update(use_schema=False)
953-
for c in cols:
954-
col_key = c.key
955-
956-
if col_key in set_parameters:
957-
value = set_parameters.pop(col_key)
958-
elif c in set_parameters:
959-
value = set_parameters.pop(c)
960-
else:
961-
continue
935+
insert_statement = cast(Any, self.stack[-1]["selectable"])
936+
cols = insert_statement.table.c
937+
set_kw = dict(kw)
938+
set_kw.update(include_table=False, use_schema=False)
939+
for c in cols:
940+
col_key = c.key
941+
942+
if col_key in set_parameters:
943+
value = set_parameters.pop(col_key)
944+
elif c in set_parameters:
945+
value = set_parameters.pop(c)
946+
else:
947+
continue
962948

963-
if coercions._is_literal(value):
964-
value = elements.BindParameter(None, value, type_=c.type)
949+
if coercions._is_literal(value):
950+
value = elements.BindParameter(None, value, type_=c.type)
965951

966-
else:
967-
if isinstance(value, elements.BindParameter) and value.type._isnull:
968-
value = value._clone()
969-
value.type = c.type
970-
value_text = self.process(
971-
value.self_group(),
972-
is_upsert_set=True,
973-
**set_kw,
974-
)
952+
else:
953+
if isinstance(value, elements.BindParameter) and value.type._isnull:
954+
value = value._clone()
955+
value.type = c.type
956+
value_text = self.process(
957+
value.self_group(),
958+
is_upsert_set=True,
959+
**set_kw,
960+
)
975961

976-
key_text = self.preparer.quote(c.name)
977-
action_set_ops.append("%s = %s" % (key_text, value_text))
962+
key_text = self.preparer.quote(c.name)
963+
action_set_ops.append("%s = %s" % (key_text, value_text))
978964

979-
if set_parameters:
980-
from sqlalchemy import util as sa_util
965+
if set_parameters:
966+
from sqlalchemy import util as sa_util
981967

982-
table_name = getattr(
983-
getattr(self.current_executable, "table", None),
984-
"name",
985-
"<unknown>",
986-
)
968+
table_name = getattr(
969+
getattr(self.current_executable, "table", None),
970+
"name",
971+
"<unknown>",
972+
)
987973

988-
sa_util.warn(
989-
"Additional column names not matching "
990-
"any column keys in table '%s': %s"
991-
% (
992-
table_name,
993-
(", ".join("'%s'" % c for c in set_parameters)),
994-
)
974+
sa_util.warn(
975+
"Additional column names not matching "
976+
"any column keys in table '%s': %s"
977+
% (
978+
table_name,
979+
(", ".join("'%s'" % c for c in set_parameters)),
995980
)
996-
for k, v in set_parameters.items():
997-
key_text = (
998-
self.preparer.quote(k)
999-
if isinstance(k, str)
1000-
else self.process(k, **set_kw)
1001-
)
1002-
value_text = self.process(
1003-
coercions.expect(roles.ExpressionElementRole, v),
1004-
is_upsert_set=True,
1005-
**set_kw,
1006-
)
1007-
action_set_ops.append("%s = %s" % (key_text, value_text))
1008-
1009-
action_text = ", ".join(action_set_ops)
1010-
if clause.update_whereclause is not None:
1011-
where_kw = dict(kw)
1012-
where_kw.update(include_table=True, use_schema=False)
1013-
action_text += " WHERE %s" % self.process(
1014-
clause.update_whereclause,
1015-
**where_kw,
981+
)
982+
for k, v in set_parameters.items():
983+
key_text = (
984+
self.preparer.quote(k)
985+
if isinstance(k, str)
986+
else self.process(k, **set_kw)
987+
)
988+
value_text = self.process(
989+
coercions.expect(roles.ExpressionElementRole, v),
990+
is_upsert_set=True,
991+
**set_kw,
1016992
)
993+
action_set_ops.append("%s = %s" % (key_text, value_text))
1017994

1018-
return "ON CONFLICT %s DO UPDATE SET %s" % (target_text, action_text)
1019-
finally:
1020-
self._has_join = original_has_join
995+
action_text = ", ".join(action_set_ops)
996+
997+
return "ON CONFLICT %s DO UPDATE SET %s" % (target_text, action_text)
1021998

1022999
def visit_over(
10231000
self,

src/sqlalchemy_excel/dml.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ def on_conflict_do_update(
6969
where: _OnConflictWhereT = None,
7070
) -> Self:
7171
"""Specifies a DO UPDATE SET action for ON CONFLICT clause."""
72+
if not index_elements:
73+
raise ValueError(
74+
"Excel dialect requires index_elements for ON CONFLICT clause"
75+
)
76+
if index_where is not None:
77+
raise ValueError("Excel dialect does not support index_where in ON CONFLICT")
78+
if where is not None:
79+
raise ValueError(
80+
"Excel dialect does not support WHERE clause in ON CONFLICT DO UPDATE"
81+
)
7282
self._post_values_clause = OnConflictDoUpdate(
7383
index_elements, index_where, set_, where
7484
)
@@ -82,6 +92,12 @@ def on_conflict_do_nothing(
8292
index_where: _OnConflictIndexWhereT = None,
8393
) -> Self:
8494
"""Specifies a DO NOTHING action for ON CONFLICT clause."""
95+
if not index_elements:
96+
raise ValueError(
97+
"Excel dialect requires index_elements for ON CONFLICT clause"
98+
)
99+
if index_where is not None:
100+
raise ValueError("Excel dialect does not support index_where in ON CONFLICT")
85101
self._post_values_clause = OnConflictDoNothing(index_elements, index_where)
86102
return self
87103

@@ -135,7 +151,7 @@ def __init__(
135151
raise ValueError("set parameter dictionary must not be empty")
136152
set_dict = set_
137153
elif isinstance(set_, ColumnCollection):
138-
set_dict = cast(_OnConflictSetDictT, dict(set_))
154+
set_dict = {column: column for column in set_}
139155
if not set_dict:
140156
raise ValueError("set parameter dictionary must not be empty")
141157
else:

tests/test_upsert.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,35 @@ def test_excluded_reference(self, populated_engine, items_table):
124124
rows = conn.execute(select(items_table).order_by(items_table.c.id)).all()
125125
assert rows == [(1, "Renamed", 30, "A")]
126126

127+
def test_expression_set_value(self, populated_engine, items_table):
128+
with populated_engine.begin() as conn:
129+
conn.execute(insert(items_table).values(id=1, name="Alice", age=30, code="A"))
130+
131+
stmt = insert(items_table).values(id=1, name="New", age=99, code="B")
132+
upsert_stmt = stmt.on_conflict_do_update(
133+
index_elements=["id"],
134+
set_={"age": items_table.c.age + 1},
135+
)
136+
compiled = " ".join(str(upsert_stmt.compile(dialect=populated_engine.dialect)).split())
137+
assert "items.age" not in compiled
138+
assert " SET age = (age + " in compiled
139+
140+
with populated_engine.begin() as conn:
141+
conn.execute(upsert_stmt)
142+
143+
with populated_engine.connect() as conn:
144+
rows = conn.execute(select(items_table).order_by(items_table.c.id)).all()
145+
assert rows == [(1, "Alice", 31, "A")]
146+
147+
def test_set_with_table_columns(self, populated_engine, items_table):
148+
upsert_stmt = insert(items_table).on_conflict_do_update(
149+
index_elements=["id"],
150+
set_=items_table.c,
151+
)
152+
compiled = " ".join(str(upsert_stmt.compile(dialect=populated_engine.dialect)).split())
153+
assert "items." not in compiled
154+
assert " SET id = id, name = name, age = age, code = code" in compiled
155+
127156
def test_multi_column_conflict_target(self, populated_engine, composite_items_table):
128157
with populated_engine.begin() as conn:
129158
conn.execute(
@@ -192,3 +221,38 @@ def test_double_on_conflict_raises(items_table):
192221

193222
with pytest.raises(InvalidRequestError, match="already has an ON CONFLICT clause"):
194223
stmt.on_conflict_do_update(index_elements=["id"], set_={"age": 40})
224+
225+
226+
def test_where_rejected(items_table):
227+
stmt = insert(items_table).values(id=1, name="Alice", age=30, code="A")
228+
229+
with pytest.raises(ValueError, match="does not support WHERE clause"):
230+
stmt.on_conflict_do_update(
231+
index_elements=["id"],
232+
set_={"age": 1},
233+
where=items_table.c.age > 10,
234+
)
235+
236+
237+
def test_index_where_rejected(items_table):
238+
stmt = insert(items_table).values(id=1, name="Alice", age=30, code="A")
239+
240+
with pytest.raises(ValueError, match="does not support index_where"):
241+
stmt.on_conflict_do_nothing(
242+
index_elements=["id"],
243+
index_where=items_table.c.id > 0,
244+
)
245+
246+
247+
def test_missing_index_elements_do_nothing(items_table):
248+
stmt = insert(items_table).values(id=1, name="Alice", age=30, code="A")
249+
250+
with pytest.raises(ValueError, match="requires index_elements"):
251+
stmt.on_conflict_do_nothing()
252+
253+
254+
def test_missing_index_elements_do_update(items_table):
255+
stmt = insert(items_table).values(id=1, name="Alice", age=30, code="A")
256+
257+
with pytest.raises(ValueError, match="requires index_elements"):
258+
stmt.on_conflict_do_update(set_={"age": 1})

0 commit comments

Comments
 (0)