@@ -203,61 +203,153 @@ def test_plain_identifier_bind_names_are_also_backticked(self):
203203 assert ":`id`" in sql
204204 assert ":`name`" in sql
205205
206- def test_space_and_dot_in_column_name_are_backticked (self ):
206+
207+ def test_leading_digit_column_is_backticked (self ):
208+ """Databricks bind names cannot start with a digit bare."""
209+ metadata = MetaData ()
210+ table = Table ("t" , metadata , Column ("1col" , String ()))
211+ compiled = self ._compile_insert (table , {"1col" : "x" })
212+ assert ":`1col`" in str (compiled )
213+
214+ def test_many_special_characters_in_column_names (self ):
215+ """Column names containing characters that Delta allows (hyphens,
216+ slashes, question marks, hash, plus, star, at, dollar, amp, pipe,
217+ lt/gt) should render as valid backtick-quoted bind markers. We
218+ intentionally exclude characters Delta rejects at DDL time
219+ (space, parens, comma, equals) — those never land in a real
220+ Databricks table, so never reach the bind-name path.
221+ """
222+ # Each of these survives a CREATE TABLE in Delta (verified empirically)
223+ # and appears verbatim inside the backtick-quoted bind name — the
224+ # default SQLAlchemy escape map does not translate any of them.
225+ pass_through = [
226+ "col-hyphen" ,
227+ "col/slash" ,
228+ "col?question" ,
229+ "col#hash" ,
230+ "col+plus" ,
231+ "col*star" ,
232+ "col@at" ,
233+ "col$dollar" ,
234+ "col&" ,
235+ "col|pipe" ,
236+ "col<lt>gt" ,
237+ ]
238+ metadata = MetaData ()
239+ columns = [Column (n , String ()) for n in pass_through ]
240+ table = Table ("t" , metadata , * columns )
241+ values = {n : f"v-{ i } " for i , n in enumerate (pass_through )}
242+ compiled = self ._compile_insert (table , values )
243+ sql = str (compiled )
244+ params = compiled .construct_params ()
245+ for n in pass_through :
246+ assert f":`{ n } `" in sql , f"bind marker missing for { n !r} "
247+ assert params [n ] == values [n ]
248+
249+ def test_sqlalchemy_escape_map_chars_still_work (self ):
250+ """SQLAlchemy's default ``bindname_escape_characters`` translates
251+ a few chars (``.`` → ``_``, ``[`` → ``_``, ``]`` → ``_``, ``:`` →
252+ ``C``, ``%`` → ``P``) before our backtick wrapping applies. That's
253+ fine: the translated bind name is still backtick-quoted, and
254+ ``escaped_bind_names`` translates the params dict key to match.
255+ Verified end-to-end against a live warehouse.
256+ """
207257 metadata = MetaData ()
208258 table = Table (
209259 "t" ,
210260 metadata ,
211- Column ("col with space" , String ()),
212261 Column ("col.with.dot" , String ()),
262+ Column ("col[bracket]" , String ()),
263+ Column ("col:colon" , String ()),
264+ Column ("col%percent" , String ()),
213265 )
214266 compiled = self ._compile_insert (
215- table , {"col with space" : "s" , "col.with.dot" : "d" }
267+ table ,
268+ {
269+ "col.with.dot" : "d" ,
270+ "col[bracket]" : "b" ,
271+ "col:colon" : "c" ,
272+ "col%percent" : "p" ,
273+ },
216274 )
217275 sql = str (compiled )
218- assert ":`col with space`" in sql
219- assert ":`col.with.dot`" in sql
220-
276+ # The bind name is translated by the escape map, then backticked
277+ assert ":`col_with_dot`" in sql
278+ assert ":`col_bracket_`" in sql
279+ assert ":`colCcolon`" in sql
280+ assert ":`colPpercent`" in sql
281+
282+ # The driver receives translated keys (escaped_bind_names tells
283+ # construct_params how to rewrite the incoming dict).
221284 params = compiled .construct_params ()
222- assert params ["col with space " ] == "s "
223- assert params ["col.with.dot " ] == "d "
285+ assert params ["col_with_dot " ] == "d "
286+ assert params ["colCcolon " ] == "c "
224287
225- def test_quote_bind_params_can_be_disabled (self ):
226- """Setting ``quote_bind_params=False`` on the dialect reverts to
227- stock SQLAlchemy bind-name rendering (the pre-fix behavior) .
288+ def test_unicode_column_names (self ):
289+ """Databricks allows arbitrary Unicode inside backtick-quoted
290+ identifiers. Bind parameter quoting must handle Unicode names too .
228291 """
229- from databricks .sqlalchemy .base import DatabricksDialect
230-
231- dialect = DatabricksDialect ()
232- dialect .paramstyle = "named"
233- dialect .quote_bind_params = False
292+ names = ["prénom" , "姓名" , "Straße" ]
293+ metadata = MetaData ()
294+ table = Table ("t" , metadata , * (Column (n , String ()) for n in names ))
295+ values = {n : f"v{ i } " for i , n in enumerate (names )}
296+ compiled = self ._compile_insert (table , values )
297+ sql = str (compiled )
298+ for n in names :
299+ assert f":`{ n } `" in sql
300+ params = compiled .construct_params ()
301+ for n in names :
302+ assert params [n ] == values [n ]
234303
304+ def test_sql_reserved_word_as_column_name (self ):
305+ """Reserved words used as column names must work as bind params too."""
235306 metadata = MetaData ()
236- table = Table ("t" , metadata , Column ("id " , String ()))
237- compiled = insert ( table ). values ({ "id " : "1" }). compile ( dialect = dialect )
307+ table = Table ("t" , metadata , Column ("select" , String ()), Column ( "from " , String ()))
308+ compiled = self . _compile_insert ( table , { "select " : "s" , "from" : "f" } )
238309 sql = str (compiled )
239- assert ":id " in sql
240- assert ":`id`" not in sql
310+ assert ":`select` " in sql
311+ assert ":`from`" in sql
241312
242- def test_url_query_string_disables_quoting (self ):
243- """The URL query parameter ``?quote_bind_params=false`` turns the
244- flag off on the dialect .
313+ def test_where_clause_with_hyphenated_column (self ):
314+ """The quoting must also apply when the hyphenated column appears in
315+ a WHERE clause (SELECT / UPDATE / DELETE all share this path) .
245316 """
246- from sqlalchemy import create_engine
247-
248- engine = create_engine (
249- "databricks://token:****@****?http_path=****&catalog=****"
250- "&schema=****"e_bind_params=false"
251- )
252- # create_engine lazy-initializes; force the dialect to process the URL
253- engine .dialect .create_connect_args (engine .url )
254- assert engine .dialect .quote_bind_params is False
317+ from sqlalchemy import select
255318
256- def test_url_query_string_defaults_to_quoting (self ):
257- from sqlalchemy import create_engine
319+ metadata = MetaData ()
320+ table = Table ("t" , metadata , Column ("col-name" , String ()))
321+ stmt = select (table ).where (table .c ["col-name" ] == "x" )
322+ compiled = stmt .compile (bind = self .engine )
323+ # SQLAlchemy anonymizes the bind as ``<column>_<n>`` — the hyphen
324+ # survives into the bind name, so it must still be backtick-quoted.
325+ assert ":`col-name_1`" in str (compiled )
326+
327+ def test_multivalues_insert_disambiguates_with_backticked_markers (self ):
328+ """Multi-row INSERT generates per-row suffixed bind names. Each
329+ suffixed name must still render backtick-quoted correctly.
330+ """
331+ metadata = MetaData ()
332+ table = Table ("t" , metadata , Column ("col-name" , String ()))
333+ stmt = insert (table ).values ([{"col-name" : "a" }, {"col-name" : "b" }])
334+ compiled = stmt .compile (bind = self .engine )
335+ sql = str (compiled )
336+ # SQLAlchemy emits e.g. `col-name_m0`, `col-name_m1` for row-level params
337+ assert ":`col-name_m0`" in sql
338+ assert ":`col-name_m1`" in sql
339+
340+ def test_in_clause_with_hyphenated_column_falls_through_to_postcompile (self ):
341+ """IN clauses use ``post_compile`` params which our override skips
342+ (the rendered ``__[POSTCOMPILE_...]`` marker is not a bind name).
343+ The anonymized bind SQLAlchemy assigns to the IN parameter does
344+ still get backticked because it contains a hyphen (``col_name_1``
345+ would be fine, but the column name slug can leak hyphens).
346+ """
347+ from sqlalchemy import select
258348
259- engine = create_engine (
260- "databricks://token:****@****?http_path=****&catalog=****&schema=****"
261- )
262- engine .dialect .create_connect_args (engine .url )
263- assert engine .dialect .quote_bind_params is True
349+ metadata = MetaData ()
350+ table = Table ("t" , metadata , Column ("col-name" , String ()))
351+ stmt = select (table ).where (table .c ["col-name" ].in_ (["a" , "b" ]))
352+ compiled = stmt .compile (bind = self .engine )
353+ # The POSTCOMPILE marker goes through super() — just make sure we
354+ # didn't crash and the SQL is well-formed.
355+ assert "POSTCOMPILE" in str (compiled ) or "IN (" in str (compiled )
0 commit comments