From a3a20a2df1822915c849a898bccf9d01ddf22a0d Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 2 Jul 2025 18:50:34 -0300 Subject: [PATCH 1/7] Updates _render_string_type signature. Fix #78. --- sqlalchemy_firebird/base.py | 60 +++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/sqlalchemy_firebird/base.py b/sqlalchemy_firebird/base.py index f74399e..d80c05a 100644 --- a/sqlalchemy_firebird/base.py +++ b/sqlalchemy_firebird/base.py @@ -3,7 +3,7 @@ from packaging import version -from typing import List +from typing import Any, List from typing import Optional from sqlalchemy import __version__ as SQLALCHEMY_VERSION @@ -377,19 +377,18 @@ def visit_boolean(self, type_, **kw): def visit_datetime(self, type_, **kw): return self.visit_TIMESTAMP(type_, **kw) - def _render_string_type(self, type_, name, length_override=None): + def _render_string_type( + self, + name: str, + length: Optional[int], + collation: Optional[str], + charset: Optional[str], + ) -> str: firebird_3_or_lower = ( self.dialect.server_version_info and self.dialect.server_version_info < (4,) ) - length = coalesce( - length_override, - getattr(type_, "length", None), - ) - charset = getattr(type_, "charset", None) - collation = getattr(type_, "collation", None) - if name in ["BINARY", "VARBINARY", "NCHAR", "NVARCHAR"]: charset = None collation = None @@ -432,11 +431,30 @@ def _render_string_type(self, type_, name, length_override=None): return text - def visit_BINARY(self, type_, **kw): - return self._render_string_type(type_, "BINARY") + def visit_CHAR(self, type_: fb_types.FBCHAR, **kw: Any) -> str: + return self._render_string_type( + "CHAR", + type_.length, + type_.collation, + getattr(type_, "charset", None), + ) + + def visit_NCHAR(self, type_: fb_types.FBNCHAR, **kw: Any) -> str: + return self._render_string_type("NCHAR", type_.length, type_.collation) - def visit_VARBINARY(self, type_, **kw): - return self._render_string_type(type_, "VARBINARY") + def visit_VARCHAR(self, type_: fb_types.FBVARCHAR, **kw: Any) -> str: + return self._render_string_type( + "VARCHAR", + type_.length, + type_.collation, + getattr(type_, "charset", None), + ) + + def visit_BINARY(self, type_: fb_types.FBBINARY, **kw) -> str: + return self._render_string_type("BINARY", type_.length) + + def visit_VARBINARY(self, type_: fb_types.FBVARBINARY, **kw) -> str: + return self._render_string_type("VARBINARY", type_.length) def visit_TEXT(self, type_, **kw): return self.visit_BLOB(type_, override_subtype=1, **kw) @@ -490,9 +508,11 @@ def visit_TIMESTAMP(self, type_, **kw): return super().visit_TIMESTAMP(type_, **kw) return "TIMESTAMP%s %s" % ( - "(%d)" % type_.precision - if getattr(type_, "precision", None) is not None - else "", + ( + "(%d)" % type_.precision + if getattr(type_, "precision", None) is not None + else "" + ), (type_.timezone and "WITH" or "WITHOUT") + " TIME ZONE", ) @@ -501,9 +521,11 @@ def visit_TIME(self, type_, **kw): return super().visit_TIME(type_, **kw) return "TIME%s %s" % ( - "(%d)" % type_.precision - if getattr(type_, "precision", None) is not None - else "", + ( + "(%d)" % type_.precision + if getattr(type_, "precision", None) is not None + else "" + ), (type_.timezone and "WITH" or "WITHOUT") + " TIME ZONE", ) From 667d52a14d7627654a38a49738a36dc8583a7566 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 2 Jul 2025 17:19:33 -0300 Subject: [PATCH 2/7] Updates URLs to latest Firebird versions. --- sqlalchemy_firebird/infrastructure.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sqlalchemy_firebird/infrastructure.py b/sqlalchemy_firebird/infrastructure.py index 3ab0e66..db2dc85 100644 --- a/sqlalchemy_firebird/infrastructure.py +++ b/sqlalchemy_firebird/infrastructure.py @@ -21,15 +21,15 @@ # if os_name == "nt": - FB50_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v5.0.0-RC2/Firebird-5.0.0.1304-RC2-windows-x64.zip" - FB40_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v4.0.4/Firebird-4.0.4.3010-0-x64.zip" - FB30_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v3.0.11/Firebird-3.0.11.33703-0_x64.zip" + FB50_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v5.0.2/Firebird-5.0.2.1613-0-windows-x64.zip" + FB40_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v4.0.5/Firebird-4.0.5.3140-0-x64.zip" + FB30_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v3.0.12/Firebird-3.0.12.33787-0-x64.zip" FB25_URL = "https://github.com/FirebirdSQL/firebird/releases/download/R2_5_9/Firebird-2.5.9.27139-0_x64_embed.zip" FB25_EXTRA_URL = "https://github.com/FirebirdSQL/firebird/releases/download/R2_5_9/Firebird-2.5.9.27139-0_x64.zip" else: - FB50_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v5.0.0-RC2/Firebird-5.0.0.1304-RC2-linux-x64.tar.gz" - FB40_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v4.0.4/Firebird-4.0.4.3010-0.amd64.tar.gz" - FB30_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v3.0.11/Firebird-3.0.11.33703-0.amd64.tar.gz" + FB50_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v5.0.2/Firebird-5.0.2.1613-0-linux-arm64.tar.gz" + FB40_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v4.0.5/Firebird-4.0.5.3140-0.amd64.tar.gz" + FB30_URL = "https://github.com/FirebirdSQL/firebird/releases/download/v3.0.12/Firebird-3.0.12.33787-0.amd64.tar.gz" FB25_URL = "https://github.com/FirebirdSQL/firebird/releases/download/R2_5_9/FirebirdCS-2.5.9.27139-0.amd64.tar.gz" TEMP_PATH = gettempdir() From 3b9f3090dbdc1c54a7e8c15be496428c1de465d0 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 2 Jul 2025 22:13:17 -0300 Subject: [PATCH 3/7] Fix ReflectedDomain. --- sqlalchemy_firebird/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sqlalchemy_firebird/base.py b/sqlalchemy_firebird/base.py index d80c05a..6bcef45 100644 --- a/sqlalchemy_firebird/base.py +++ b/sqlalchemy_firebird/base.py @@ -3,7 +3,7 @@ from packaging import version -from typing import Any, List +from typing import Any, List, TypedDict from typing import Optional from sqlalchemy import __version__ as SQLALCHEMY_VERSION @@ -380,9 +380,9 @@ def visit_datetime(self, type_, **kw): def _render_string_type( self, name: str, - length: Optional[int], - collation: Optional[str], - charset: Optional[str], + length: Optional[int]=None, + collation: Optional[str]=None, + charset: Optional[str]=None, ) -> str: firebird_3_or_lower = ( self.dialect.server_version_info @@ -550,7 +550,7 @@ def fire_sequence(self, seq, type_): ) -class ReflectedDomain(util.typing.TypedDict): +class ReflectedDomain(TypedDict): """Represents a reflected domain.""" name: str From 95e394efaa4aac21af0e0f49ef9cdbfa0f27ac84 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 2 Jul 2025 22:25:56 -0300 Subject: [PATCH 4/7] Fix data types. --- sqlalchemy_firebird/base.py | 4 +- sqlalchemy_firebird/types.py | 146 ++++++++++++++++++++++++----------- 2 files changed, 103 insertions(+), 47 deletions(-) diff --git a/sqlalchemy_firebird/base.py b/sqlalchemy_firebird/base.py index 6bcef45..66e221d 100644 --- a/sqlalchemy_firebird/base.py +++ b/sqlalchemy_firebird/base.py @@ -623,7 +623,7 @@ class FBDialect(default.DefaultDialect): colspecs = { sa_types.String: fb_types._FBString, - sa_types.Numeric: fb_types._FBNumeric, + sa_types.Numeric: fb_types.FBNUMERIC, sa_types.Float: fb_types.FBFLOAT, sa_types.Double: fb_types.FBDOUBLE_PRECISION, sa_types.Date: fb_types.FBDATE, @@ -875,7 +875,7 @@ def get_columns( # noqa: C901 charset=row.character_set_name, collation=row.collation_name, ) - elif issubclass(colclass, fb_types._FBNumeric): + elif colclass in (fb_types.FBFLOAT, fb_types.FBDOUBLE_PRECISION, fb_types.FBDECFLOAT): # FLOAT, DOUBLE PRECISION or DECFLOAT coltype = colclass(row.field_precision) elif issubclass(colclass, fb_types._FBInteger): diff --git a/sqlalchemy_firebird/types.py b/sqlalchemy_firebird/types.py index 195b41d..9729364 100644 --- a/sqlalchemy_firebird/types.py +++ b/sqlalchemy_firebird/types.py @@ -15,103 +15,157 @@ class _FBString(sqltypes.String): render_bind_cast = True - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, collation) + def __init__(self, charset: Optional[str] = None, **kw: Any): self.charset = charset + # Only pass parameters that the parent String class accepts + string_kwargs = {} + if 'length' in kw: + string_kwargs['length'] = kw['length'] + if 'collation' in kw: + string_kwargs['collation'] = kw['collation'] + super().__init__(**string_kwargs) -class FBCHAR(_FBString): +class FBCHAR(_FBString, sqltypes.CHAR): __visit_name__ = "CHAR" - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, charset, collation) + def __init__(self, length: Optional[int] = None, **kwargs: Any): + super().__init__(length=length, **kwargs) class FBBINARY(FBCHAR): __visit_name__ = "BINARY" # Synonym for CHAR(n) CHARACTER SET OCTETS - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, BINARY_CHARSET) + def __init__(self, length: Optional[int] = None, **kwargs: Any): + kwargs["charset"] = BINARY_CHARSET + super().__init__(length=length, **kwargs) -class FBNCHAR(FBCHAR): +class FBNCHAR(FBCHAR, sqltypes.NCHAR): __visit_name__ = "NCHAR" # Synonym for CHAR(n) CHARACTER SET ISO8859_1 - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, NATIONAL_CHARSET) + def __init__(self, length: Optional[int] = None, **kwargs: Any): + kwargs["charset"] = NATIONAL_CHARSET + super().__init__(length=length, **kwargs) -class FBVARCHAR(_FBString): +class FBVARCHAR(_FBString, sqltypes.VARCHAR): __visit_name__ = "VARCHAR" - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, charset, collation) + def __init__(self, length: Optional[int] = None, **kwargs: Any): + super().__init__(length=length, **kwargs) class FBVARBINARY(FBVARCHAR): __visit_name__ = "VARBINARY" # Synonym for VARCHAR(n) CHARACTER SET OCTETS - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, BINARY_CHARSET) + def __init__(self, length: Optional[int] = None, **kwargs: Any): + kwargs["charset"] = BINARY_CHARSET + super().__init__(length=length, **kwargs) -class FBNVARCHAR(FBVARCHAR): +class FBNVARCHAR(FBVARCHAR, sqltypes.NVARCHAR): __visit_name__ = "NVARCHAR" # Synonym for VARCHAR(n) CHARACTER SET ISO8859_1 - def __init__(self, length=None, charset=None, collation=None): - super().__init__(length, NATIONAL_CHARSET) + def __init__(self, length: Optional[int] = None, **kwargs: Any): + kwargs["charset"] = NATIONAL_CHARSET + super().__init__(length=length, **kwargs) -class _FBNumeric(sqltypes.Numeric): +class FBFLOAT(sqltypes.FLOAT): + __visit_name__ = "FLOAT" render_bind_cast = True + def __init__(self, precision=None, **kwargs): + # FLOAT doesn't accept 'scale' parameter, filter it out + float_kwargs = {k: v for k, v in kwargs.items() if k != 'scale'} + # Set precision if provided + if precision is not None: + float_kwargs['precision'] = precision + # Provide defaults for required parameters + float_kwargs.setdefault("precision", None) + float_kwargs.setdefault("decimal_return_scale", None) + float_kwargs.setdefault("asdecimal", False) + super().__init__(**float_kwargs) + def bind_processor(self, dialect): return None # Dialect supports_native_decimal = True (no processor needed) -class FBFLOAT(_FBNumeric, sqltypes.FLOAT): - __visit_name__ = "FLOAT" +class FBDOUBLE_PRECISION(sqltypes.DOUBLE_PRECISION): + __visit_name__ = "DOUBLE_PRECISION" + render_bind_cast = True + def __init__(self, precision=None, **kwargs): + # DOUBLE_PRECISION doesn't accept 'scale' parameter, filter it out + float_kwargs = {k: v for k, v in kwargs.items() if k != 'scale'} + # Set precision if provided + if precision is not None: + float_kwargs['precision'] = precision + # Provide defaults for required parameters + float_kwargs.setdefault("precision", None) + float_kwargs.setdefault("decimal_return_scale", None) + float_kwargs.setdefault("asdecimal", False) + super().__init__(**float_kwargs) -class FBDOUBLE_PRECISION(_FBNumeric, sqltypes.DOUBLE_PRECISION): - __visit_name__ = "DOUBLE_PRECISION" + def bind_processor(self, dialect): + return None # Dialect supports_native_decimal = True (no processor needed) -class FBDECFLOAT(_FBNumeric): +class FBDECFLOAT(sqltypes.Numeric): __visit_name__ = "DECFLOAT" + render_bind_cast = True + + def __init__(self, precision=None, **kwargs): + # DECFLOAT (Numeric) accepts all parameters + if precision is not None: + kwargs['precision'] = precision + kwargs.setdefault("precision", None) + kwargs.setdefault("scale", None) + kwargs.setdefault("decimal_return_scale", None) + kwargs.setdefault("asdecimal", False) + super().__init__(**kwargs) + + def bind_processor(self, dialect): + return None # Dialect supports_native_decimal = True (no processor needed) class FBREAL(FBFLOAT): __visit_name__ = "REAL" - # Synonym for FLOAT - def __init__(self, precision=None, scale=None): - super().__init__(None, None) - -class _FBFixedPoint(_FBNumeric): - def __init__( - self, - precision=None, - scale=None, - decimal_return_scale=None, - asdecimal=None, - ): - super().__init__( - precision, scale, decimal_return_scale, asdecimal=True - ) +class FBDECIMAL(sqltypes.DECIMAL): + __visit_name__ = "DECIMAL" + render_bind_cast = True + def __init__(self, **kwargs: Any): + kwargs["asdecimal"] = True + kwargs.setdefault("precision", None) + kwargs.setdefault("scale", None) + kwargs.setdefault("decimal_return_scale", None) + super().__init__(**kwargs) -class FBDECIMAL(_FBFixedPoint): - __visit_name__ = "DECIMAL" + def bind_processor(self, dialect): + return None # Dialect supports_native_decimal = True (no processor needed) -class FBNUMERIC(_FBFixedPoint): +class FBNUMERIC(sqltypes.NUMERIC): __visit_name__ = "NUMERIC" + render_bind_cast = True + + def __init__(self, **kwargs: Any): + kwargs.setdefault("asdecimal", True) + kwargs.setdefault("precision", None) + kwargs.setdefault("scale", None) + kwargs.setdefault("decimal_return_scale", None) + super().__init__(**kwargs) + + def bind_processor(self, dialect): + return None # Dialect supports_native_decimal = True (no processor needed) class FBDATE(sqltypes.DATE): @@ -191,10 +245,12 @@ def __init__( super().__init__(1, segment_size, charset, collation) -class _FBNumericInterval(_FBNumeric): +class _FBNumericInterval(FBNUMERIC): # NUMERIC(18,9) -- Used for _FBInterval storage - def __init__(self): - super().__init__(precision=18, scale=9) + def __init__(self, **kwargs: Any): + kwargs["precision"] = 18 + kwargs["scale"] = 9 + super().__init__(**kwargs) class _FBInterval(sqltypes.Interval): From e34f21f1f18b8c1fea91b17abbfa460b8df05107 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Thu, 3 Jul 2025 00:02:30 -0300 Subject: [PATCH 5/7] Remove DeprecatedCompoundSelectTest. --- test/test_suite.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/test/test_suite.py b/test/test_suite.py index a868aec..2e53076 100644 --- a/test/test_suite.py +++ b/test/test_suite.py @@ -14,7 +14,6 @@ ComponentReflectionTest as _ComponentReflectionTest, ComponentReflectionTestExtra as _ComponentReflectionTestExtra, CompoundSelectTest as _CompoundSelectTest, - DeprecatedCompoundSelectTest as _DeprecatedCompoundSelectTest, IdentityColumnTest as _IdentityColumnTest, IdentityReflectionTest as _IdentityReflectionTest, StringTest as _StringTest, @@ -267,20 +266,6 @@ def test_plain_union(self): super().test_plain_union() -class DeprecatedCompoundSelectTest(_DeprecatedCompoundSelectTest): - @pytest.mark.skip(reason="Firebird does not support ORDER BY alias") - def test_distinct_selectable_in_unions(self): - super().test_distinct_selectable_in_unions() - - @pytest.mark.skip(reason="Firebird does not support ORDER BY alias") - def test_limit_offset_aliased_selectable_in_unions(self): - super().test_limit_offset_aliased_selectable_in_unions() - - @pytest.mark.skip(reason="Firebird does not support ORDER BY alias") - def test_plain_union(self): - super().test_plain_union() - - class IdentityColumnTest(_IdentityColumnTest): @testing.requires.firebird_4_or_higher def test_select_all(self, connection): From e546db36ba853b250b29dc17b1afc9e3364689b8 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Thu, 3 Jul 2025 01:40:49 -0300 Subject: [PATCH 6/7] Fix test incompatibilities with Firebird 3. --- test/test_suite.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/test/test_suite.py b/test/test_suite.py index 2e53076..d7c2f51 100644 --- a/test/test_suite.py +++ b/test/test_suite.py @@ -6,10 +6,11 @@ from packaging import version from sqlalchemy import __version__ as SQLALCHEMY_VERSION from sqlalchemy import Index -from sqlalchemy.testing import is_false +from sqlalchemy.testing import config, is_false from sqlalchemy.testing.suite import * # noqa: F401, F403 from sqlalchemy.testing.suite import ( + BizarroCharacterTest as _BizarroCharacterTest, CTETest as _CTETest, ComponentReflectionTest as _ComponentReflectionTest, ComponentReflectionTestExtra as _ComponentReflectionTestExtra, @@ -20,9 +21,18 @@ InsertBehaviorTest as _InsertBehaviorTest, RowCountTest as _RowCountTest, SimpleUpdateDeleteTest as _SimpleUpdateDeleteTest, + TempTableElementsTest as _TempTableElementsTest, + WindowFunctionTest as _WindowFunctionTest ) +@pytest.mark.skipif( + config.db.dialect.server_version_info < (4,), + reason="These tests rely on correct identity semantics, which were only fixed starting from Firebird 4.0." +) +class BizarroCharacterTest(_BizarroCharacterTest): + pass + @pytest.mark.skip( reason="These tests fails in Firebird because a DELETE FROM with self-referencing FK raises integrity errors." ) @@ -425,3 +435,27 @@ def test_update_returning(self, connection, criteria): @testing.requires.delete_returning def test_delete_returning(self, connection, criteria): super().test_delete_returning(connection, criteria) + +class TempTableElementsTest(_TempTableElementsTest): + @testing.requires.identity_columns + def test_reflect_identity(self, tablename, connection, metadata): + Table( + tablename, + metadata, + Column("id", Integer, Identity(), primary_key=True), + ) + metadata.create_all(connection) + insp = inspect(connection) + + # Fix this test to work with Firebird 3 identity semantics. + firebird_4_or_higher = config.db.dialect.server_version_info >= (4, 0) + + expected = 1 if firebird_4_or_higher else 0 + eq_(insp.get_columns(tablename)[0]["identity"]["start"], expected) + + +class WindowFunctionTest(_WindowFunctionTest): + # This test requires window functions not available in Firebird 3. + @testing.requires.firebird_4_or_higher + def test_window_rows_between_w_caching(self, connection): + super().test_window_rows_between_w_caching(connection) From d5a6d5440ad0297bcf5dae6016a172ba84d96292 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Thu, 3 Jul 2025 22:51:27 -0300 Subject: [PATCH 7/7] Removes dependency of SQLAlchemy `_render_string_type` base method. --- sqlalchemy_firebird/base.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/sqlalchemy_firebird/base.py b/sqlalchemy_firebird/base.py index 66e221d..783f3b0 100644 --- a/sqlalchemy_firebird/base.py +++ b/sqlalchemy_firebird/base.py @@ -377,7 +377,7 @@ def visit_boolean(self, type_, **kw): def visit_datetime(self, type_, **kw): return self.visit_TIMESTAMP(type_, **kw) - def _render_string_type( + def _render_firebird_string_type( self, name: str, length: Optional[int]=None, @@ -432,7 +432,7 @@ def _render_string_type( return text def visit_CHAR(self, type_: fb_types.FBCHAR, **kw: Any) -> str: - return self._render_string_type( + return self._render_firebird_string_type( "CHAR", type_.length, type_.collation, @@ -440,21 +440,24 @@ def visit_CHAR(self, type_: fb_types.FBCHAR, **kw: Any) -> str: ) def visit_NCHAR(self, type_: fb_types.FBNCHAR, **kw: Any) -> str: - return self._render_string_type("NCHAR", type_.length, type_.collation) + return self._render_firebird_string_type("NCHAR", type_.length, type_.collation) def visit_VARCHAR(self, type_: fb_types.FBVARCHAR, **kw: Any) -> str: - return self._render_string_type( + return self._render_firebird_string_type( "VARCHAR", type_.length, type_.collation, getattr(type_, "charset", None), ) + + def visit_NVARCHAR(self, type_: fb_types.FBNCHAR, **kw: Any) -> str: + return self._render_firebird_string_type("NVARCHAR", type_.length, type_.collation) def visit_BINARY(self, type_: fb_types.FBBINARY, **kw) -> str: - return self._render_string_type("BINARY", type_.length) + return self._render_firebird_string_type("BINARY", type_.length) def visit_VARBINARY(self, type_: fb_types.FBVARBINARY, **kw) -> str: - return self._render_string_type("VARBINARY", type_.length) + return self._render_firebird_string_type("VARBINARY", type_.length) def visit_TEXT(self, type_, **kw): return self.visit_BLOB(type_, override_subtype=1, **kw)