|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +# pyright: reportPrivateUsage=false, reportAny=false, reportExplicitAny=false, reportUnannotatedClassAttribute=false, reportUnusedCallResult=false |
| 4 | + |
| 5 | +from types import SimpleNamespace |
| 6 | +from typing import Any, cast |
| 7 | + |
| 8 | +import pytest |
| 9 | +from sqlalchemy import Column, Integer, MetaData, String, Table, bindparam, create_engine, select |
| 10 | +from sqlalchemy.exc import CompileError, SAWarning |
| 11 | +from sqlalchemy.sql.base import ColumnCollection |
| 12 | +from sqlalchemy.sql.ddl import ExecutableDDLElement |
| 13 | + |
| 14 | +from sqlalchemy_excel.compiler import ExcelCompiler |
| 15 | +from sqlalchemy_excel.ddl import ExcelDDLCompiler |
| 16 | +from sqlalchemy_excel.dialect import ExcelDialect, _after_create, _after_drop |
| 17 | +from sqlalchemy_excel.dml import OnConflictClause, OnConflictDoNothing, OnConflictDoUpdate, insert |
| 18 | + |
| 19 | + |
| 20 | +@pytest.fixture |
| 21 | +def users_table() -> Table: |
| 22 | + metadata = MetaData() |
| 23 | + return Table( |
| 24 | + "users", |
| 25 | + metadata, |
| 26 | + Column("id", Integer, primary_key=True), |
| 27 | + Column("name", String), |
| 28 | + Column("age", Integer), |
| 29 | + ) |
| 30 | + |
| 31 | + |
| 32 | +class AddColumnNoColumn(ExecutableDDLElement): |
| 33 | + __visit_name__ = "add_column" |
| 34 | + |
| 35 | + def __init__(self, table_name: str) -> None: |
| 36 | + self.table_name = table_name |
| 37 | + self.schema = None |
| 38 | + |
| 39 | + |
| 40 | +class DropColumnFromColumnObj(ExecutableDDLElement): |
| 41 | + __visit_name__ = "drop_column" |
| 42 | + |
| 43 | + def __init__(self, table_name: str, column: Any) -> None: |
| 44 | + self.table_name = table_name |
| 45 | + self.column = column |
| 46 | + self.schema = None |
| 47 | + |
| 48 | + |
| 49 | +class RenameColumnAltNames(ExecutableDDLElement): |
| 50 | + __visit_name__ = "rename_column" |
| 51 | + |
| 52 | + def __init__(self, table_name: str, old_name: str, new_name: str) -> None: |
| 53 | + self.table_name = table_name |
| 54 | + self.old_name = old_name |
| 55 | + self.new_name = new_name |
| 56 | + self.schema = None |
| 57 | + |
| 58 | + |
| 59 | +def test_compiler_is_true_onclause_none_path() -> None: |
| 60 | + assert ExcelCompiler._is_true_onclause(None) is False |
| 61 | + |
| 62 | + |
| 63 | +def test_compiler_validate_join_tree_requires_on_clause() -> None: |
| 64 | + bad_join = SimpleNamespace(left=object(), right=object(), onclause=None, full=False, isouter=False) |
| 65 | + with pytest.raises(CompileError, match="requires an ON clause"): |
| 66 | + ExcelCompiler._validate_join_tree(cast(Any, bad_join)) |
| 67 | + |
| 68 | + |
| 69 | +def test_compiler_validate_join_tree_recurses_into_right_join() -> None: |
| 70 | + metadata = MetaData() |
| 71 | + users = Table("users", metadata, Column("id", Integer, primary_key=True)) |
| 72 | + orders = Table("orders", metadata, Column("user_id", Integer)) |
| 73 | + items = Table("items", metadata, Column("order_user_id", Integer)) |
| 74 | + |
| 75 | + right_join = orders.join(items, orders.c.user_id == items.c.order_user_id) |
| 76 | + outer_join = users.join(right_join, users.c.id == users.c.id) |
| 77 | + with pytest.raises(CompileError, match="different join sources"): |
| 78 | + ExcelCompiler._validate_join_tree(cast(Any, outer_join)) |
| 79 | + |
| 80 | + |
| 81 | +def test_compiler_count_empty_args_rejected(tmp_xlsx: str, users_table: Table) -> None: |
| 82 | + from sqlalchemy.sql.functions import Function |
| 83 | + |
| 84 | + engine = create_engine(f"excel:///{tmp_xlsx}") |
| 85 | + stmt = select(cast(Any, Function("sum"))).select_from(users_table) |
| 86 | + with pytest.raises(CompileError, match="expression arguments"): |
| 87 | + stmt.compile(dialect=engine.dialect) |
| 88 | + engine.dispose() |
| 89 | + |
| 90 | + |
| 91 | +def test_compiler_visit_subquery_rejects_when_join_context(tmp_xlsx: str) -> None: |
| 92 | + engine = create_engine(f"excel:///{tmp_xlsx}") |
| 93 | + metadata = MetaData() |
| 94 | + users = Table("users", metadata, Column("id", Integer, primary_key=True)) |
| 95 | + orders = Table("orders", metadata, Column("user_id", Integer)) |
| 96 | + sub = select(orders.c.user_id).subquery() |
| 97 | + |
| 98 | + compiler_inst = cast(Any, select(users).compile(dialect=engine.dialect)) |
| 99 | + compiler_inst._in_in_clause = True |
| 100 | + compiler_inst._has_join = True |
| 101 | + with pytest.raises(CompileError, match="subqueries with JOIN"): |
| 102 | + compiler_inst.visit_subquery(sub) |
| 103 | + engine.dispose() |
| 104 | + |
| 105 | + |
| 106 | +def test_compiler_quote_aware_helpers_cover_escaped_quotes() -> None: |
| 107 | + sql = "('a''b')" |
| 108 | + depth = ExcelCompiler._update_depth_quote_aware(sql, 0, len(sql), 0) |
| 109 | + assert depth == 0 |
| 110 | + assert ExcelCompiler._has_top_level_compound_op("SELECT 'UNION''ALL' FROM t") is False |
| 111 | + |
| 112 | + |
| 113 | +def test_compiler_strip_compound_parens_with_quoted_segments() -> None: |
| 114 | + sql = "'x''y' (SELECT 'a''b' AS s FROM t1 ORDER BY s) UNION SELECT id FROM t2" |
| 115 | + out = ExcelCompiler._strip_compound_branch_parens(sql) |
| 116 | + assert "ORDER BY s" in out |
| 117 | + assert "'x''y'" in out |
| 118 | + |
| 119 | + |
| 120 | +def test_compiler_strip_order_and_limit_helpers_with_quoted_and_embedded_keywords() -> None: |
| 121 | + stripped = ExcelCompiler._strip_top_level_order_by( |
| 122 | + "SELECT id FROM t ORDER BY id, 'LIMIT' LIMITED 5 OFFSET 2" |
| 123 | + ) |
| 124 | + assert stripped.endswith("OFFSET 2") |
| 125 | + |
| 126 | + assert ( |
| 127 | + ExcelCompiler._has_top_level_limit_offset( |
| 128 | + "SELECT 'LIMIT' AS x FROM t WHERE name = 'OFFSET'" |
| 129 | + ) |
| 130 | + is False |
| 131 | + ) |
| 132 | + |
| 133 | + |
| 134 | +def test_compiler_on_conflict_do_nothing_without_target_text(tmp_xlsx: str, users_table: Table) -> None: |
| 135 | + engine = create_engine(f"excel:///{tmp_xlsx}") |
| 136 | + compiler_inst = cast(Any, select(users_table).compile(dialect=engine.dialect)) |
| 137 | + clause = OnConflictDoNothing(index_elements=None) |
| 138 | + assert compiler_inst.visit_on_conflict_do_nothing(clause) == "ON CONFLICT DO NOTHING" |
| 139 | + engine.dispose() |
| 140 | + |
| 141 | + |
| 142 | +def test_compiler_upsert_bindparam_null_type_and_extra_key_warns( |
| 143 | + tmp_xlsx: str, |
| 144 | + users_table: Table, |
| 145 | +) -> None: |
| 146 | + engine = create_engine(f"excel:///{tmp_xlsx}") |
| 147 | + stmt = insert(users_table).values(id=1, name="a", age=1).on_conflict_do_update( |
| 148 | + index_elements=["id"], |
| 149 | + set_={"age": bindparam("new_age"), "extra_col": 10}, |
| 150 | + ) |
| 151 | + with pytest.warns(SAWarning, match="Additional column names"): |
| 152 | + sql = str(stmt.compile(dialect=engine.dialect)) |
| 153 | + assert "extra_col" in sql |
| 154 | + engine.dispose() |
| 155 | + |
| 156 | + |
| 157 | +def test_ddl_compiler_error_paths_and_alternative_name_sources(tmp_xlsx: str) -> None: |
| 158 | + engine = create_engine(f"excel:///{tmp_xlsx}") |
| 159 | + compiler = ExcelDDLCompiler(engine.dialect, cast(Any, None)) |
| 160 | + |
| 161 | + with pytest.raises(CompileError, match="does not support schemas"): |
| 162 | + compiler._format_alter_table_name(SimpleNamespace(schema="s", table_name="users")) |
| 163 | + with pytest.raises(CompileError, match="requires a table name"): |
| 164 | + compiler._format_alter_table_name(SimpleNamespace(schema=None, table_name=None, table=None)) |
| 165 | + with pytest.raises(CompileError, match="requires a column name"): |
| 166 | + compiler._format_alter_column_name(None) |
| 167 | + with pytest.raises(CompileError, match="requires a column"): |
| 168 | + AddColumnNoColumn("users").compile(dialect=engine.dialect) |
| 169 | + |
| 170 | + dropped = str( |
| 171 | + DropColumnFromColumnObj("users", SimpleNamespace(name="age")).compile( |
| 172 | + dialect=engine.dialect |
| 173 | + ) |
| 174 | + ) |
| 175 | + assert dropped == "ALTER TABLE users DROP COLUMN age" |
| 176 | + |
| 177 | + renamed = str( |
| 178 | + RenameColumnAltNames("users", "old_col", "new_col").compile( |
| 179 | + dialect=engine.dialect |
| 180 | + ) |
| 181 | + ) |
| 182 | + assert renamed == "ALTER TABLE users RENAME COLUMN old_col TO new_col" |
| 183 | + engine.dispose() |
| 184 | + |
| 185 | + |
| 186 | +def test_dml_clause_none_targets_and_invalid_set_inputs() -> None: |
| 187 | + clause = OnConflictClause(index_elements=None) |
| 188 | + assert clause.inferred_target_elements is None |
| 189 | + assert clause.inferred_target_whereclause is None |
| 190 | + |
| 191 | + with pytest.raises(ValueError, match="must not be empty"): |
| 192 | + OnConflictDoUpdate(index_elements=["id"], set_=ColumnCollection()) |
| 193 | + |
| 194 | + |
| 195 | +def test_dialect_event_hooks_return_early_for_non_excel(users_table: Table) -> None: |
| 196 | + fake_conn = SimpleNamespace(dialect=SimpleNamespace(name="sqlite")) |
| 197 | + _after_create(users_table, fake_conn) |
| 198 | + _after_drop(users_table, fake_conn) |
| 199 | + |
| 200 | + |
| 201 | +def test_dialect_sync_alter_table_short_statement_returns_early() -> None: |
| 202 | + dialect = ExcelDialect() |
| 203 | + cursor = SimpleNamespace(connection=object()) |
| 204 | + dialect._sync_alter_table_metadata(cursor, "ALTER TABLE users") |
| 205 | + |
| 206 | + |
| 207 | +def test_dialect_sync_alter_table_add_float_maps_to_real(monkeypatch: pytest.MonkeyPatch) -> None: |
| 208 | + writes: list[list[dict[str, Any]]] = [] |
| 209 | + |
| 210 | + def read_table_metadata(_conn: Any, _table: str) -> list[dict[str, Any]]: |
| 211 | + return [{"name": "id", "type_name": "INTEGER", "nullable": False, "primary_key": True}] |
| 212 | + |
| 213 | + def get_columns(_conn: Any, _table: str) -> list[dict[str, Any]]: |
| 214 | + return [{"name": "id", "type": "INTEGER"}, {"name": "amount", "type": "FLOAT"}] |
| 215 | + |
| 216 | + def write_table_metadata(_conn: Any, _table: str, columns: list[dict[str, Any]]) -> None: |
| 217 | + writes.append(columns) |
| 218 | + |
| 219 | + import excel_dbapi |
| 220 | + |
| 221 | + monkeypatch.setattr(excel_dbapi, "read_table_metadata", read_table_metadata) |
| 222 | + monkeypatch.setattr(excel_dbapi, "get_columns", get_columns) |
| 223 | + monkeypatch.setattr(excel_dbapi, "write_table_metadata", write_table_metadata) |
| 224 | + |
| 225 | + dialect = ExcelDialect() |
| 226 | + cursor = SimpleNamespace(connection=object()) |
| 227 | + dialect._sync_alter_table_metadata(cursor, "ALTER TABLE users ADD COLUMN amount FLOAT") |
| 228 | + |
| 229 | + amount = next(col for col in writes[-1] if col["name"] == "amount") |
| 230 | + assert amount["type_name"] == "REAL" |
| 231 | + |
| 232 | + |
| 233 | +def test_dialect_sync_alter_table_rename_unknown_column_keeps_maps_stable( |
| 234 | + monkeypatch: pytest.MonkeyPatch, |
| 235 | +) -> None: |
| 236 | + writes: list[list[dict[str, Any]]] = [] |
| 237 | + |
| 238 | + def read_table_metadata(_conn: Any, _table: str) -> list[dict[str, Any]]: |
| 239 | + return [] |
| 240 | + |
| 241 | + def get_columns(_conn: Any, _table: str) -> list[dict[str, Any]]: |
| 242 | + return [{"name": "new_name", "type": "TEXT"}] |
| 243 | + |
| 244 | + def write_table_metadata(_conn: Any, _table: str, columns: list[dict[str, Any]]) -> None: |
| 245 | + writes.append(columns) |
| 246 | + |
| 247 | + import excel_dbapi |
| 248 | + |
| 249 | + monkeypatch.setattr(excel_dbapi, "read_table_metadata", read_table_metadata) |
| 250 | + monkeypatch.setattr(excel_dbapi, "get_columns", get_columns) |
| 251 | + monkeypatch.setattr(excel_dbapi, "write_table_metadata", write_table_metadata) |
| 252 | + |
| 253 | + dialect = ExcelDialect() |
| 254 | + cursor = SimpleNamespace(connection=object()) |
| 255 | + dialect._sync_alter_table_metadata( |
| 256 | + cursor, |
| 257 | + "ALTER TABLE users RENAME COLUMN old_name TO new_name", |
| 258 | + ) |
| 259 | + |
| 260 | + assert writes[-1] == [{"name": "new_name", "type_name": "TEXT", "nullable": True, "primary_key": False}] |
0 commit comments