diff --git a/pybigquery/_helpers.py b/pybigquery/_helpers.py index a93e0c54..2b1648ed 100644 --- a/pybigquery/_helpers.py +++ b/pybigquery/_helpers.py @@ -69,9 +69,6 @@ def substitute_re_method(r, flags=0, repl=None): r = re.compile(r, flags) - if isinstance(repl, str): - return lambda self, s: r.sub(repl, s) - @functools.wraps(repl) def sub(self, s, *args, **kw): def repl_(m): @@ -80,3 +77,8 @@ def repl_(m): return r.sub(repl_, s) return sub + + +def substitute_string_re_method(r, *, repl, flags=0): + r = re.compile(r, flags) + return lambda self, s: r.sub(repl, s) diff --git a/pybigquery/sqlalchemy_bigquery.py b/pybigquery/sqlalchemy_bigquery.py index 7ef2d725..fb00b553 100644 --- a/pybigquery/sqlalchemy_bigquery.py +++ b/pybigquery/sqlalchemy_bigquery.py @@ -122,8 +122,8 @@ def format_label(self, label, name=None): "BYTES": types.BINARY, "TIME": types.TIME, "RECORD": types.JSON, - "NUMERIC": types.DECIMAL, - "BIGNUMERIC": types.DECIMAL, + "NUMERIC": types.Numeric, + "BIGNUMERIC": types.Numeric, } STRING = _type_map["STRING"] @@ -158,23 +158,33 @@ def get_insert_default(self, column): # pragma: NO COVER elif isinstance(column.type, String): return str(uuid.uuid4()) - __remove_type_from_empty_in = _helpers.substitute_re_method( - r" IN UNNEST\(\[ (" - r"(?:NULL|\(NULL(?:, NULL)+\))\)" - r" (?:AND|OR) \(1 !?= 1" - r")" - r"(?:[:][A-Z0-9]+)?" - r" \]\)", - re.IGNORECASE, - r" IN(\1)", + __remove_type_from_empty_in = _helpers.substitute_string_re_method( + r""" + \sIN\sUNNEST\(\[\s # ' IN UNNEST([ ' + ( + (?:NULL|\(NULL(?:,\sNULL)+\))\) # '(NULL)' or '((NULL, NULL, ...))' + \s(?:AND|OR)\s\(1\s!?=\s1 # ' and 1 != 1' or ' or 1 = 1' + ) + (?:[:][A-Z0-9]+)? # Maybe ':TYPE' (e.g. ':INT64') + \s\]\) # Close: ' ])' + """, + flags=re.IGNORECASE | re.VERBOSE, + repl=r" IN(\1)", ) @_helpers.substitute_re_method( - r" IN UNNEST\(\[ " - r"(%\([^)]+_\d+\)s(?:, %\([^)]+_\d+\)s)*)?" # Placeholders. See below. - r":([A-Z0-9]+)" # Type - r" \]\)", - re.IGNORECASE, + r""" + \sIN\sUNNEST\(\[\s # ' IN UNNEST([ ' + ( # Placeholders. See below. + %\([^)]+_\d+\)s # Placeholder '%(foo_1)s' + (?:,\s # 0 or more placeholders + %\([^)]+_\d+\)s + )* + )? + :([A-Z0-9]+) # Type ':TYPE' (e.g. ':INT64') + \s\]\) # Close: ' ])' + """, + flags=re.IGNORECASE | re.VERBOSE, ) def __distribute_types_to_expanded_placeholders(self, m): # If we have an in parameter, it sometimes gets expaned to 0 or more @@ -282,10 +292,20 @@ def group_by_clause(self, select, **kw): "EXPANDING" if __sqlalchemy_version_info < (1, 4) else "POSTCOMPILE" ) - __in_expanding_bind = _helpers.substitute_re_method( - fr" IN \((\[" fr"{__expandng_text}" fr"_[^\]]+\](:[A-Z0-9]+)?)\)$", - re.IGNORECASE, - r" IN UNNEST([ \1 ])", + __in_expanding_bind = _helpers.substitute_string_re_method( + fr""" + \sIN\s\( # ' IN (' + ( + \[ # Expanding placeholder + {__expandng_text} # e.g. [EXPANDING_foo_1] + _[^\]]+ # + \] + (:[A-Z0-9]+)? # type marker (e.g. ':INT64' + ) + \)$ # close w ending ) + """, + flags=re.IGNORECASE | re.VERBOSE, + repl=r" IN UNNEST([ \1 ])", ) def visit_in_op_binary(self, binary, operator_, **kw): @@ -360,6 +380,18 @@ def visit_notendswith_op_binary(self, binary, operator, **kw): __expanded_param = re.compile(fr"\(\[" fr"{__expandng_text}" fr"_[^\]]+\]\)$").match + __remove_type_parameter = _helpers.substitute_string_re_method( + r""" + (STRING|BYTES|NUMERIC|BIGNUMERIC) # Base type + \( # Dimensions e.g. '(42)', '(4, 2)': + \s*\d+\s* # First dimension + (?:,\s*\d+\s*)* # Remaining dimensions + \) + """, + repl=r"\1", + flags=re.VERBOSE | re.IGNORECASE, + ) + def visit_bindparam( self, bindparam, @@ -397,6 +429,7 @@ def visit_bindparam( if bq_type[-1] == ">" and bq_type.startswith("ARRAY<"): # Values get arrayified at a lower level. bq_type = bq_type[6:-1] + bq_type = self.__remove_type_parameter(bq_type) assert_(param != "%s", f"Unexpected param: {param}") @@ -429,6 +462,10 @@ def visit_FLOAT(self, type_, **kw): visit_REAL = visit_FLOAT def visit_STRING(self, type_, **kw): + if (type_.length is not None) and isinstance( + kw.get("type_expression"), Column + ): # column def + return f"STRING({type_.length})" return "STRING" visit_CHAR = visit_NCHAR = visit_STRING @@ -438,17 +475,29 @@ def visit_ARRAY(self, type_, **kw): return "ARRAY<{}>".format(self.process(type_.item_type, **kw)) def visit_BINARY(self, type_, **kw): + if type_.length is not None: + return f"BYTES({type_.length})" return "BYTES" visit_VARBINARY = visit_BINARY def visit_NUMERIC(self, type_, **kw): - if (type_.precision is not None and type_.precision > 38) or ( - type_.scale is not None and type_.scale > 9 - ): - return "BIGNUMERIC" + if (type_.precision is not None) and isinstance( + kw.get("type_expression"), Column + ): # column def + if type_.scale is not None: + suffix = f"({type_.precision}, {type_.scale})" + else: + suffix = f"({type_.precision})" else: - return "NUMERIC" + suffix = "" + + return ( + "BIGNUMERIC" + if (type_.precision is not None and type_.precision > 38) + or (type_.scale is not None and type_.scale > 9) + else "NUMERIC" + ) + suffix visit_DECIMAL = visit_NUMERIC @@ -800,18 +849,16 @@ def _get_columns_helper(self, columns, cur_columns): """ results = [] for col in columns: - results += [ - SchemaField( - name=".".join(col.name for col in cur_columns + [col]), - field_type=col.field_type, - mode=col.mode, - description=col.description, - fields=col.fields, - ) - ] + results += [col] if col.field_type == "RECORD": cur_columns.append(col) - results += self._get_columns_helper(col.fields, cur_columns) + fields = [ + SchemaField.from_api_repr( + dict(f.to_api_repr(), name=f"{col.name}.{f.name}") + ) + for f in col.fields + ] + results += self._get_columns_helper(fields, cur_columns) cur_columns.pop() return results @@ -829,6 +876,11 @@ def get_columns(self, connection, table_name, schema=None, **kw): ) coltype = types.NullType + if col.field_type.endswith("NUMERIC"): + coltype = coltype(precision=col.precision, scale=col.scale) + elif col.field_type == "STRING" or col.field_type == "BYTES": + coltype = coltype(col.max_length) + result.append( { "name": col.name, @@ -836,6 +888,9 @@ def get_columns(self, connection, table_name, schema=None, **kw): "nullable": col.mode == "NULLABLE" or col.mode == "REPEATED", "comment": col.description, "default": None, + "precision": col.precision, + "scale": col.scale, + "max_length": col.max_length, } ) diff --git a/setup.py b/setup.py index cfe139a3..7e4be55f 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,7 @@ def readme(): install_requires=[ "google-api-core>=1.23.0", # Work-around bug in cloud core deps. "google-auth>=1.24.0,<2.0dev", # Work around pip wack. - "google-cloud-bigquery>=2.16.1", + "google-cloud-bigquery>=2.17.0", "sqlalchemy>=1.2.0,<1.5.0dev", "future", ], diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt index b975c9ea..03281e21 100644 --- a/testing/constraints-3.6.txt +++ b/testing/constraints-3.6.txt @@ -6,5 +6,5 @@ # e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", sqlalchemy==1.2.0 google-auth==1.24.0 -google-cloud-bigquery==2.16.1 +google-cloud-bigquery==2.17.0 google-api-core==1.23.0 diff --git a/tests/sqlalchemy_dialect_compliance/test_dialect_compliance.py b/tests/sqlalchemy_dialect_compliance/test_dialect_compliance.py index e03e0b22..5b8fc89d 100644 --- a/tests/sqlalchemy_dialect_compliance/test_dialect_compliance.py +++ b/tests/sqlalchemy_dialect_compliance/test_dialect_compliance.py @@ -71,7 +71,6 @@ def literal(value): else: from sqlalchemy.testing.suite import ( - ComponentReflectionTestExtra as _ComponentReflectionTestExtra, FetchLimitOffsetTest as _FetchLimitOffsetTest, RowCountTest as _RowCountTest, ) @@ -107,13 +106,6 @@ def test_limit_render_multiple_times(self, connection): # over the backquotes that we add everywhere. XXX Why do we do that? del PostCompileParamsTest - class ComponentReflectionTestExtra(_ComponentReflectionTestExtra): - @pytest.mark.skip("BQ types don't have parameters like precision and length") - def test_numeric_reflection(self): - pass - - test_varchar_reflection = test_numeric_reflection - class TimestampMicrosecondsTest(_TimestampMicrosecondsTest): data = datetime.datetime(2012, 10, 15, 12, 57, 18, 396, tzinfo=pytz.UTC) diff --git a/tests/system/test_sqlalchemy_bigquery.py b/tests/system/test_sqlalchemy_bigquery.py index 48a1ef19..da863dc0 100644 --- a/tests/system/test_sqlalchemy_bigquery.py +++ b/tests/system/test_sqlalchemy_bigquery.py @@ -92,7 +92,7 @@ {"name": "timestamp", "type": types.TIMESTAMP(), "nullable": True, "default": None}, {"name": "string", "type": types.String(), "nullable": True, "default": None}, {"name": "float", "type": types.Float(), "nullable": True, "default": None}, - {"name": "numeric", "type": types.DECIMAL(), "nullable": True, "default": None}, + {"name": "numeric", "type": types.Numeric(), "nullable": True, "default": None}, {"name": "boolean", "type": types.Boolean(), "nullable": True, "default": None}, {"name": "date", "type": types.DATE(), "nullable": True, "default": None}, {"name": "datetime", "type": types.DATETIME(), "nullable": True, "default": None}, diff --git a/tests/unit/fauxdbi.py b/tests/unit/fauxdbi.py index 56652cd5..de835753 100644 --- a/tests/unit/fauxdbi.py +++ b/tests/unit/fauxdbi.py @@ -30,7 +30,7 @@ import google.cloud.bigquery.table import google.cloud.bigquery.dbapi.cursor -from pybigquery._helpers import substitute_re_method +from pybigquery._helpers import substitute_re_method, substitute_string_re_method class Connection: @@ -87,7 +87,7 @@ def arraysize(self, v): datetime.time, ) - @substitute_re_method(r"%\((\w+)\)s", re.IGNORECASE) + @substitute_re_method(r"%\((\w+)\)s", flags=re.IGNORECASE) def __pyformat_to_qmark(self, m, parameters, ordered_parameters): name = m.group(1) value = parameters[name] @@ -111,8 +111,12 @@ def __update_comment(self, table, col, comment): ).match @substitute_re_method( - r"(?P`(?P\w+)`\s+\w+|\))" r"\s+options\((?P[^)]+)\)", - re.IGNORECASE, + r""" + (?P + `(?P\w+)`\s+\w+|\)) # Column def or ) + \s+options\((?P[^)]+)\) # ' options(...)' + """, + flags=re.IGNORECASE | re.VERBOSE, ) def __handle_column_comments(self, m, table_name): col = m.group("col") or "" @@ -131,9 +135,13 @@ def __handle_comments( self, operation, alter_table=re.compile( - r"\s*ALTER\s+TABLE\s+`(?P\w+)`\s+" - r"SET\s+OPTIONS\(description=(?P[^)]+)\)", - re.IGNORECASE, + r""" + # 'ALTER TABLE foo ': + \s*ALTER\s+TABLE\s+`(?P
\w+)`\s+ + # 'SET OPTIONS(description='foo comment')': + SET\s+OPTIONS\(description=(?P[^)]+)\) + """, + re.IGNORECASE | re.VERBOSE, ).match, ): m = self.__create_table(operation) @@ -150,7 +158,12 @@ def __handle_comments( return operation @substitute_re_method( - r"(?<=[(,])" r"\s*`\w+`\s+\w+<\w+>\s*" r"(?=[,)])", re.IGNORECASE + r""" + (?<=[(,]) # Preceeded by ( or , + \s*`\w+`\s+ARRAY<\w+>\s* # 'foo ARRAY' + (?=[,)]) # Followed by , or ) + """, + flags=re.IGNORECASE | re.VERBOSE, ) def __normalize_array_types(self, m): return m.group(0).replace("<", "_").replace(">", "_") @@ -186,19 +199,30 @@ def __parse_dateish(type_, value): else: raise AssertionError(type_) # pragma: NO COVER - __normalize_bq_datish = substitute_re_method( - r"(?<=[[(,])\s*" - r"(?Pdate(?:time)?|time(?:stamp)?) (?P'[^']+')" - r"\s*(?=[]),])", - re.IGNORECASE, - r"parse_datish('\1', \2)", + __normalize_bq_datish = substitute_string_re_method( + r""" + (?<=[[(,]) # Preceeded by ( or , + \s* + (?Pdate(?:time)?|time(?:stamp)?) # Type, like 'TIME' + \s + (?P'[^']+') # a string date/time literal + \s* + (?=[]),]) # Followed by , or ) + """, + flags=re.IGNORECASE | re.VERBOSE, + repl=r"parse_datish('\1', \2)", ) def __handle_problematic_literal_inserts( self, operation, literal_insert_values=re.compile( - r"\s*(insert\s+into\s+.+\s+values\s*)" r"(\([^)]+\))" r"\s*$", re.IGNORECASE + r""" + \s*(insert\s+into\s+.+\s+values\s*) # insert into ... values + (\([^)]+\)) # (...) + \s*$ # Then end w maybe spaces + """, + re.IGNORECASE | re.VERBOSE, ).match, need_to_be_pickled_literal=_need_to_be_pickled + (bytes,), ): @@ -234,8 +258,10 @@ def __handle_problematic_literal_inserts( else: return operation - __handle_unnest = substitute_re_method( - r"UNNEST\(\[ ([^\]]+)? \]\)", re.IGNORECASE, r"(\1)" + __handle_unnest = substitute_string_re_method( + r"UNNEST\(\[ ([^\]]+)? \]\)", # UNNEST([ ... ]) + flags=re.IGNORECASE, + repl=r"(\1)", ) def __handle_true_false(self, operation): @@ -329,6 +355,30 @@ def _row_dict(row, cursor): result = {d[0]: value for d, value in zip(cursor.description, row)} return result + __string_types = "STRING", "BYTES" + + @substitute_re_method( + r""" + (STRING|BYTES|NUMERIC|BIGNUMERIC) + \s* + \( # Dimensions e.g. (42) or (4, 2) + \s*(\d+)\s* + (?:,\s*(\d+)\s*)? + \) + """, + flags=re.VERBOSE, + ) + def __parse_type_parameters(self, m, parameters): + name, precision, scale = m.groups() + if scale is not None: + parameters.update(precision=precision, scale=scale) + elif name in self.__string_types: + parameters.update(max_length=precision) + else: + parameters.update(precision=precision) + + return name + def _get_field( self, type, @@ -348,12 +398,16 @@ def _get_field( if not mode: mode = "REQUIRED" if notnull else "NULLABLE" + parameters = {} + type_ = self.__parse_type_parameters(type, parameters) + field = google.cloud.bigquery.schema.SchemaField( name=name, - field_type=type, + field_type=type_, mode=mode, description=description, fields=tuple(self._get_field(**f) for f in fields), + **parameters, ) return field diff --git a/tests/unit/test_catalog_functions.py b/tests/unit/test_catalog_functions.py index 0bbfad75..a17e9cf8 100644 --- a/tests/unit/test_catalog_functions.py +++ b/tests/unit/test_catalog_functions.py @@ -159,35 +159,51 @@ def test_get_table_comment(faux_conn): @pytest.mark.parametrize( - "btype,atype", + "btype,atype,extra", [ - ("STRING", sqlalchemy.types.String), - ("BYTES", sqlalchemy.types.BINARY), - ("INT64", sqlalchemy.types.Integer), - ("FLOAT64", sqlalchemy.types.Float), - ("NUMERIC", sqlalchemy.types.DECIMAL), - ("BIGNUMERIC", sqlalchemy.types.DECIMAL), - ("BOOL", sqlalchemy.types.Boolean), - ("TIMESTAMP", sqlalchemy.types.TIMESTAMP), - ("DATE", sqlalchemy.types.DATE), - ("TIME", sqlalchemy.types.TIME), - ("DATETIME", sqlalchemy.types.DATETIME), - ("THURSDAY", sqlalchemy.types.NullType), + ("STRING", sqlalchemy.types.String(), ()), + ("STRING(42)", sqlalchemy.types.String(42), dict(max_length=42)), + ("BYTES", sqlalchemy.types.BINARY(), ()), + ("BYTES(42)", sqlalchemy.types.BINARY(42), dict(max_length=42)), + ("INT64", sqlalchemy.types.Integer, ()), + ("FLOAT64", sqlalchemy.types.Float, ()), + ("NUMERIC", sqlalchemy.types.NUMERIC(), ()), + ("NUMERIC(4)", sqlalchemy.types.NUMERIC(4), dict(precision=4)), + ("NUMERIC(4, 2)", sqlalchemy.types.NUMERIC(4, 2), dict(precision=4, scale=2)), + ("BIGNUMERIC", sqlalchemy.types.NUMERIC(), ()), + ("BIGNUMERIC(42)", sqlalchemy.types.NUMERIC(42), dict(precision=42)), + ( + "BIGNUMERIC(42, 2)", + sqlalchemy.types.NUMERIC(42, 2), + dict(precision=42, scale=2), + ), + ("BOOL", sqlalchemy.types.Boolean, ()), + ("TIMESTAMP", sqlalchemy.types.TIMESTAMP, ()), + ("DATE", sqlalchemy.types.DATE, ()), + ("TIME", sqlalchemy.types.TIME, ()), + ("DATETIME", sqlalchemy.types.DATETIME, ()), + ("THURSDAY", sqlalchemy.types.NullType, ()), ], ) -def test_get_table_columns(faux_conn, btype, atype): +def test_get_table_columns(faux_conn, btype, atype, extra): cursor = faux_conn.connection.cursor() cursor.execute(f"create table foo (x {btype})") - assert faux_conn.dialect.get_columns(faux_conn, "foo") == [ + [col] = faux_conn.dialect.get_columns(faux_conn, "foo") + col["type"] = str(col["type"]) + assert col == dict( { "comment": None, "default": None, + "max_length": None, "name": "x", "nullable": True, - "type": atype, - } - ] + "type": str(atype), + "precision": None, + "scale": None, + }, + **(extra or {}), + ) def test_get_table_columns_special_cases(faux_conn): @@ -206,13 +222,24 @@ def test_get_table_columns_special_cases(faux_conn): assert isinstance(stype, sqlalchemy.types.ARRAY) assert isinstance(stype.item_type, sqlalchemy.types.String) assert actual == [ - {"comment": "a fine column", "default": None, "name": "s", "nullable": True}, + { + "comment": "a fine column", + "default": None, + "name": "s", + "nullable": True, + "max_length": None, + "precision": None, + "scale": None, + }, { "comment": None, "default": None, "name": "n", "nullable": False, "type": sqlalchemy.types.Integer, + "max_length": None, + "precision": None, + "scale": None, }, { "comment": None, @@ -220,6 +247,9 @@ def test_get_table_columns_special_cases(faux_conn): "name": "r", "nullable": True, "type": sqlalchemy.types.JSON, + "max_length": None, + "precision": None, + "scale": None, }, { "comment": None, @@ -227,6 +257,9 @@ def test_get_table_columns_special_cases(faux_conn): "name": "r.i", "nullable": True, "type": sqlalchemy.types.Integer, + "max_length": None, + "precision": None, + "scale": None, }, { "comment": None, @@ -234,6 +267,9 @@ def test_get_table_columns_special_cases(faux_conn): "name": "r.f", "nullable": True, "type": sqlalchemy.types.Float, + "max_length": None, + "precision": None, + "scale": None, }, ] diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 93965318..2166284f 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -138,10 +138,12 @@ def mock_default_credentials(*args, **kwargs): assert bqclient.project == "connection-url-project" -def test_substitute_re_string(module_under_test): +def test_substitute_string_re(module_under_test): import re - foo_to_baz = module_under_test.substitute_re_method("foo", re.IGNORECASE, "baz") + foo_to_baz = module_under_test.substitute_string_re_method( + "foo", flags=re.IGNORECASE, repl="baz" + ) assert ( foo_to_baz(object(), "some foo and FOO is good") == "some baz and baz is good" ) diff --git a/tests/unit/test_select.py b/tests/unit/test_select.py index 45872a81..db2e68c8 100644 --- a/tests/unit/test_select.py +++ b/tests/unit/test_select.py @@ -67,13 +67,13 @@ def dtrepr(v): (sqlalchemy.REAL, 1.42, "FLOAT64", repr), (sqlalchemy.FLOAT, 0.42, "FLOAT64", repr), (sqlalchemy.NUMERIC, Decimal(4.25), "NUMERIC", str), - (sqlalchemy.NUMERIC(39), Decimal(4.25), "BIGNUMERIC", str), - (sqlalchemy.NUMERIC(30, 10), Decimal(4.25), "BIGNUMERIC", str), - (sqlalchemy.NUMERIC(39, 10), Decimal(4.25), "BIGNUMERIC", str), + (sqlalchemy.NUMERIC(39), Decimal(4.25), "BIGNUMERIC(39)", str), + (sqlalchemy.NUMERIC(30, 10), Decimal(4.25), "BIGNUMERIC(30, 10)", str), + (sqlalchemy.NUMERIC(39, 10), Decimal(4.25), "BIGNUMERIC(39, 10)", str), (sqlalchemy.DECIMAL, Decimal(0.25), "NUMERIC", str), - (sqlalchemy.DECIMAL(39), Decimal(4.25), "BIGNUMERIC", str), - (sqlalchemy.DECIMAL(30, 10), Decimal(4.25), "BIGNUMERIC", str), - (sqlalchemy.DECIMAL(39, 10), Decimal(4.25), "BIGNUMERIC", str), + (sqlalchemy.DECIMAL(39), Decimal(4.25), "BIGNUMERIC(39)", str), + (sqlalchemy.DECIMAL(30, 10), Decimal(4.25), "BIGNUMERIC(30, 10)", str), + (sqlalchemy.DECIMAL(39, 10), Decimal(4.25), "BIGNUMERIC(39, 10)", str), (sqlalchemy.INTEGER, 434343, "INT64", repr), (sqlalchemy.INT, 444444, "INT64", repr), (sqlalchemy.SMALLINT, 43, "INT64", repr), @@ -96,10 +96,13 @@ def dtrepr(v): (sqlalchemy.TEXT, "myTEXT", "STRING", repr), (sqlalchemy.VARCHAR, "myVARCHAR", "STRING", repr), (sqlalchemy.NVARCHAR, "myNVARCHAR", "STRING", repr), + (sqlalchemy.VARCHAR(42), "myVARCHAR", "STRING(42)", repr), + (sqlalchemy.NVARCHAR(42), "myNVARCHAR", "STRING(42)", repr), (sqlalchemy.CHAR, "myCHAR", "STRING", repr), (sqlalchemy.NCHAR, "myNCHAR", "STRING", repr), (sqlalchemy.BINARY, b"myBINARY", "BYTES", repr), (sqlalchemy.VARBINARY, b"myVARBINARY", "BYTES", repr), + (sqlalchemy.VARBINARY(42), b"myVARBINARY", "BYTES(42)", repr), (sqlalchemy.BOOLEAN, False, "BOOL", "false"), (sqlalchemy.ARRAY(sqlalchemy.Integer), [1, 2, 3], "ARRAY", repr), ( @@ -127,8 +130,10 @@ def test_typed_parameters(faux_conn, type_, val, btype, vrep): if btype.startswith("ARRAY<"): btype = btype[6:-1] + ptype = btype[: btype.index("(")] if "(" in btype else btype + assert faux_conn.test_data["execute"][-1] == ( - f"INSERT INTO `t` (`{col_name}`) VALUES (%({col_name}:{btype})s)", + f"INSERT INTO `t` (`{col_name}`) VALUES (%({col_name}:{ptype})s)", {col_name: val}, )