From 54ddcf2c589da76593bbe50b2c93fe3ad79c4ef6 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 26 Sep 2026 02:58:23 +0000 Subject: [PATCH 1/2] Keep Float result conversion with the ibm_db driver The ibm_db dialect maps Numeric to _IBM_Numeric_ibm_db. Float subclasses Numeric and has no entry of its own, so Float columns are adapted to _IBM_Numeric_ibm_db, whose result processor returns the DBAPI value unchanged when asdecimal is true. Float(asdecimal=True) therefore returns float instead of Decimal. Map Float to itself so SQLAlchemy's Float result handling applies. Signed-off-by: Amin Ghadersohi --- ibm_db_sa/ibm_db.py | 6 +++- test/test_float.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 test/test_float.py diff --git a/ibm_db_sa/ibm_db.py b/ibm_db_sa/ibm_db.py index 9ddb9ae..22af71c 100644 --- a/ibm_db_sa/ibm_db.py +++ b/ibm_db_sa/ibm_db.py @@ -128,7 +128,11 @@ class DB2Dialect_ibm_db(DB2Dialect): colspecs = util.update_copy( DB2Dialect.colspecs, { - sa_types.Numeric: _IBM_Numeric_ibm_db + sa_types.Numeric: _IBM_Numeric_ibm_db, + # Float subclasses Numeric; without its own entry it would be + # adapted to _IBM_Numeric_ibm_db, which ignores Float's + # asdecimal result conversion. + sa_types.Float: sa_types.Float, } ) diff --git a/test/test_float.py b/test/test_float.py new file mode 100644 index 0000000..d0ae4cb --- /dev/null +++ b/test/test_float.py @@ -0,0 +1,72 @@ +"""Float result conversion with the ibm_db DBAPI.""" + +from decimal import Decimal + +from sqlalchemy import Column, Float, Integer, REAL, Table, select +from sqlalchemy.testing import fixtures +from sqlalchemy.testing.assertions import eq_ + +from ibm_db_sa.ibm_db import DB2Dialect_ibm_db + + +VALUES = (1.25, -2.5, 0.0, None) + + +def _expected(value, asdecimal): + if value is None or not asdecimal: + return value + return Decimal(str(value)) + + +class TestFloatResults(fixtures.TestBase): + def _check(self, type_, asdecimal): + dialect = DB2Dialect_ibm_db() + impl = type_.dialect_impl(dialect) + processor = impl.result_processor(dialect, None) + for value in VALUES: + result = processor(value) if processor else value + expected = _expected(value, asdecimal) + eq_(result, expected) + eq_(type(result), type(expected)) + + def test_float_asdecimal(self): + self._check(Float(asdecimal=True), True) + + def test_real_asdecimal(self): + self._check(REAL(asdecimal=True), True) + + def test_float_default(self): + self._check(Float(), False) + + +class TestFloatRoundTrip(fixtures.TestBase): + __only_on__ = "ibm_db_sa+ibm_db_sa" + __backend__ = True + + def _round_trip(self, metadata, connection, asdecimal): + table = Table( + "float_results", + metadata, + Column("id", Integer, primary_key=True, autoincrement=False), + Column("amount", Float(asdecimal=asdecimal)), + ) + table.create(connection) + connection.execute( + table.insert(), + [{"id": i, "amount": value} for i, value in enumerate(VALUES)], + ) + actual = ( + connection.execute(select(table.c.amount).order_by(table.c.id)) + .scalars() + .all() + ) + expected = [_expected(value, asdecimal) for value in VALUES] + eq_(actual, expected) + for value, result in zip(expected, actual): + eq_(type(result), type(value)) + + def test_float_asdecimal_round_trip(self, metadata, connection): + self._round_trip(metadata, connection, True) + + def test_float_round_trip(self, metadata, connection): + self._round_trip(metadata, connection, False) From 2e6f40360ea34851c3140d4048bea19f0c45c5d9 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 26 Sep 2026 04:30:41 +0000 Subject: [PATCH 2/2] Declare DOUBLE as a Float type ibm_db_sa's DOUBLE subclassed Numeric, so reflected DOUBLE columns declared asdecimal=True and python_type Decimal, although DB2 DOUBLE is binary floating point and the ibm_db DBAPI returns float for it. Base DOUBLE on Float (asdecimal=False, python_type float). Returned values do not change; DDL still renders DOUBLE. Signed-off-by: Amin Ghadersohi --- ibm_db_sa/base.py | 4 +++- test/test_float.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/ibm_db_sa/base.py b/ibm_db_sa/base.py index b0169ce..27bf088 100644 --- a/ibm_db_sa/base.py +++ b/ibm_db_sa/base.py @@ -224,7 +224,9 @@ class BOOLEAN(sa_types.Boolean): __visit_name__ = 'BOOLEAN' -class DOUBLE(sa_types.Numeric): +# DOUBLE is a binary floating-point type and the DBAPI returns float for it, +# so declare it as a Float (asdecimal=False), not a Numeric. +class DOUBLE(sa_types.Float): __visit_name__ = 'DOUBLE' diff --git a/test/test_float.py b/test/test_float.py index d0ae4cb..f2cf0b1 100644 --- a/test/test_float.py +++ b/test/test_float.py @@ -2,10 +2,11 @@ from decimal import Decimal -from sqlalchemy import Column, Float, Integer, REAL, Table, select +from sqlalchemy import Column, Float, Integer, MetaData, REAL, Table, inspect, select from sqlalchemy.testing import fixtures from sqlalchemy.testing.assertions import eq_ +from ibm_db_sa.base import DOUBLE from ibm_db_sa.ibm_db import DB2Dialect_ibm_db @@ -38,6 +39,14 @@ def test_real_asdecimal(self): def test_float_default(self): self._check(Float(), False) + def test_double_is_float(self): + type_ = DOUBLE() + eq_(isinstance(type_, Float), True) + eq_(type_.asdecimal, False) + eq_(type_.python_type, float) + eq_(str(type_.compile(dialect=DB2Dialect_ibm_db())), "DOUBLE") + self._check(type_, False) + class TestFloatRoundTrip(fixtures.TestBase): __only_on__ = "ibm_db_sa+ibm_db_sa" @@ -70,3 +79,36 @@ def test_float_asdecimal_round_trip(self, metadata, connection): def test_float_round_trip(self, metadata, connection): self._round_trip(metadata, connection, False) + + def test_reflected_double(self, metadata, connection): + table = Table( + "float_reflect", + metadata, + Column("id", Integer, primary_key=True, autoincrement=False), + Column("amount", DOUBLE()), + ) + table.create(connection) + connection.execute( + table.insert(), + [{"id": i, "amount": value} for i, value in enumerate(VALUES)], + ) + inspector = inspect(connection) + schema = connection.dialect.normalize_name( + connection.exec_driver_sql("VALUES CURRENT SCHEMA").scalar().strip() + ) + columns = inspector.get_columns("float_reflect", schema=schema) + type_ = next(c["type"] for c in columns if c["name"] == "amount") + eq_(isinstance(type_, DOUBLE), True) + eq_(type_.asdecimal, False) + eq_(type_.python_type, float) + reflected = Table( + "float_reflect", MetaData(), schema=schema, autoload_with=connection + ) + actual = ( + connection.execute(select(reflected.c.amount).order_by(reflected.c.id)) + .scalars() + .all() + ) + eq_(actual, list(VALUES)) + for value, result in zip(VALUES, actual): + eq_(type(result), type(value))