]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- The precision used when coercing a returned floating point value to
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 23 Nov 2013 01:04:19 +0000 (20:04 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 23 Nov 2013 01:04:19 +0000 (20:04 -0500)
Python ``Decimal`` via string is now configurable.  The
flag ``decimal_return_scale`` is now supported by all :class:`.Numeric`
and :class:`.Float` types, which will ensure this many digits are taken
from the native floating point value when it is converted to string.
If not present, the type will make use of the value of ``.scale``, if
the type supports this setting and it is non-None.  Otherwise the original
default length of 10 is used. [ticket:2867]

12 files changed:
doc/build/changelog/changelog_09.rst
doc/build/changelog/migration_09.rst
lib/sqlalchemy/dialects/mysql/base.py
lib/sqlalchemy/dialects/oracle/cx_oracle.py
lib/sqlalchemy/dialects/postgresql/pg8000.py
lib/sqlalchemy/dialects/postgresql/psycopg2.py
lib/sqlalchemy/processors.py
lib/sqlalchemy/sql/sqltypes.py
lib/sqlalchemy/testing/requirements.py
lib/sqlalchemy/testing/suite/test_types.py
test/dialect/mysql/test_types.py
test/requirements.py

index ae6206b2fd43419e33230d805df846d2eea4641f..cb37cb990ad3febfa149b542e882477de2e418e5 100644 (file)
 .. changelog::
     :version: 0.9.0b2
 
+    .. change::
+        :tags: feature, sql
+        :tickets: 2867
+
+        The precision used when coercing a returned floating point value to
+        Python ``Decimal`` via string is now configurable.  The
+        flag ``decimal_return_scale`` is now supported by all :class:`.Numeric`
+        and :class:`.Float` types, which will ensure this many digits are taken
+        from the native floating point value when it is converted to string.
+        If not present, the type will make use of the value of ``.scale``, if
+        the type supports this setting and it is non-None.  Otherwise the original
+        default length of 10 is used.
+
+        .. seealso::
+
+            :ref:`feature_2867`
+
     .. change::
         :tags: bug, schema
         :tickets: 2868
index 52af7882498b4e6ba614b37bf65d5a495fa048ed..4e9112f871aaad3a8d25d654be6688fecbe5f869 100644 (file)
@@ -877,6 +877,50 @@ rendering::
 
 :ticket:`722`
 
+.. _feature_2867:
+
+Floating Point String-Conversion Precision Configurable for Native Floating Point Types
+---------------------------------------------------------------------------------------
+
+The conversion which SQLAlchemy does whenever a DBAPI returns a Python
+floating point type which is to be converted into a Python ``Decimal()``
+necessarily involves an intermediary step which converts the floating point
+value to a string.  The scale used for this string conversion was previously
+hardcoded to 10, and is now configurable.  The setting is available on
+both the :class:`.Numeric` as well as the :class:`.Float`
+type, as well as all SQL- and dialect-specific descendant types, using the
+parameter ``decimal_return_scale``.    If the type supports a ``.scale`` parameter,
+as is the case with :class:`.Numeric` and some float types such as
+:class:`.mysql.DOUBLE`, the value of ``.scale`` is used as the default
+for ``.decimal_return_scale`` if it is not otherwise specified.   If both
+``.scale`` and ``.decimal_return_scale`` are absent, then the default of
+10 takes place.  E.g.::
+
+    from sqlalchemy.dialects.mysql import DOUBLE
+    import decimal
+
+    data = Table('data', metadata,
+        Column('double_value',
+                    mysql.DOUBLE(decimal_return_scale=12, asdecimal=True))
+    )
+
+    conn.execute(
+        data.insert(),
+        double_value=45.768392065789,
+    )
+    result = conn.scalar(select([data.c.double_value]))
+
+    # previously, this would typically be Decimal("45.7683920658"),
+    # e.g. trimmed to 10 decimal places
+
+    # now we get 12, as requested, as MySQL can support this
+    # much precision for DOUBLE
+    assert result == decimal.Decimal("45.768392065789")
+
+
+:ticket:`2867`
+
+
 .. _change_2824:
 
 Column Bundles for ORM queries
index 6883be5af631d167e737290301e7cda6bd8a9924..6ffc1319a68fdcd4b003b0412e5755c937c46ce9 100644 (file)
@@ -398,7 +398,8 @@ class _FloatType(_NumericType, sqltypes.Float):
             raise exc.ArgumentError(
                 "You must specify both precision and scale or omit "
                 "both altogether.")
-
+        if scale is not None:
+            kw.setdefault('decimal_return_scale', scale)
         super(_FloatType, self).__init__(precision=precision, asdecimal=asdecimal, **kw)
         self.scale = scale
 
@@ -490,6 +491,14 @@ class DOUBLE(_FloatType):
     def __init__(self, precision=None, scale=None, asdecimal=True, **kw):
         """Construct a DOUBLE.
 
+        .. note::
+
+            The :class:`.DOUBLE` type by default converts from float
+            to Decimal, using a truncation that defaults to 10 digits.  Specify
+            either ``scale=n`` or ``decimal_return_scale=n`` in order to change
+            this scale, or ``asdecimal=False`` to return values directly as
+            Python floating points.
+
         :param precision: Total digits in this number.  If scale and precision
           are both None, values are stored to limits allowed by the server.
 
@@ -515,6 +524,14 @@ class REAL(_FloatType, sqltypes.REAL):
     def __init__(self, precision=None, scale=None, asdecimal=True, **kw):
         """Construct a REAL.
 
+        .. note::
+
+            The :class:`.REAL` type by default converts from float
+            to Decimal, using a truncation that defaults to 10 digits.  Specify
+            either ``scale=n`` or ``decimal_return_scale=n`` in order to change
+            this scale, or ``asdecimal=False`` to return values directly as
+            Python floating points.
+
         :param precision: Total digits in this number.  If scale and precision
           are both None, values are stored to limits allowed by the server.
 
index d59aab8f7a9fea0f34e974795c9861904f59edc2..0c6d257dc28a26a148437634cbce0bea1bd60766 100644 (file)
@@ -232,10 +232,7 @@ class _OracleNumeric(sqltypes.Numeric):
 
         if dialect.supports_native_decimal:
             if self.asdecimal:
-                if self.scale is None:
-                    fstring = "%.10f"
-                else:
-                    fstring = "%%.%df" % self.scale
+                fstring = "%%.%df" % self.decimal_return_scale
 
                 def to_decimal(value):
                     if value is None:
index 0e503746c77a5cfd792812a11fce93e8ffdb57f2..cd9c545f30cad86ea4ab6e9a11e85fe0085eba16 100644 (file)
@@ -39,7 +39,8 @@ class _PGNumeric(sqltypes.Numeric):
     def result_processor(self, dialect, coltype):
         if self.asdecimal:
             if coltype in _FLOAT_TYPES:
-                return processors.to_decimal_processor_factory(decimal.Decimal)
+                return processors.to_decimal_processor_factory(
+                                    decimal.Decimal, self.decimal_return_scale)
             elif coltype in _DECIMAL_TYPES or coltype in _INT_TYPES:
                 # pg8000 returns Decimal natively for 1700
                 return None
index 02eda094e297993f4cfc73c28d83c9e8c1ec6063..9995a1f5a2702447c186eeea9b21e6010574fb1c 100644 (file)
@@ -191,7 +191,8 @@ class _PGNumeric(sqltypes.Numeric):
     def result_processor(self, dialect, coltype):
         if self.asdecimal:
             if coltype in _FLOAT_TYPES:
-                return processors.to_decimal_processor_factory(decimal.Decimal)
+                return processors.to_decimal_processor_factory(
+                                decimal.Decimal, self.decimal_return_scale)
             elif coltype in _DECIMAL_TYPES or coltype in _INT_TYPES:
                 # pg8000 returns Decimal natively for 1700
                 return None
index bf95d146b815f1dd30b6e1769e30fa5cfce367db..f51bdfdee8b94da1616ccdaf22abce2d36d72abd 100644 (file)
@@ -66,7 +66,7 @@ def py_fallback():
                 return decoder(value, errors)[0]
         return process
 
-    def to_decimal_processor_factory(target_class, scale=10):
+    def to_decimal_processor_factory(target_class, scale):
         fstring = "%%.%df" % scale
 
         def process(value):
@@ -119,7 +119,7 @@ try:
         else:
             return UnicodeResultProcessor(encoding).process
 
-    def to_decimal_processor_factory(target_class, scale=10):
+    def to_decimal_processor_factory(target_class, scale):
         # Note that the scale argument is not taken into account for integer
         # values in the C implementation while it is in the Python one.
         # For example, the Python implementation might return
index 8f22ae81c29a06525623635d997c67794fc8f351..7cf5a6dca920a0026ed58b3f4b8dca330e06e78e 100644 (file)
@@ -409,6 +409,7 @@ class BigInteger(Integer):
     __visit_name__ = 'big_integer'
 
 
+
 class Numeric(_DateAffinity, TypeEngine):
     """A type for fixed precision numbers.
 
@@ -453,7 +454,10 @@ class Numeric(_DateAffinity, TypeEngine):
 
     __visit_name__ = 'numeric'
 
-    def __init__(self, precision=None, scale=None, asdecimal=True):
+    _default_decimal_return_scale = 10
+
+    def __init__(self, precision=None, scale=None,
+                  decimal_return_scale=None, asdecimal=True):
         """
         Construct a Numeric.
 
@@ -468,6 +472,18 @@ class Numeric(_DateAffinity, TypeEngine):
           datatypes - the Numeric type will ensure that return values
           are one or the other across DBAPIs consistently.
 
+        :param decimal_return_scale: Default scale to use when converting
+         from floats to Python decimals.  Floating point values will typically
+         be much longer due to decimal inaccuracy, and most floating point
+         database types don't have a notion of "scale", so by default the
+         float type looks for the first ten decimal places when converting.
+         Specfiying this value will override that length.  Types which
+         do include an explicit ".scale" value, such as the base :class:`.Numeric`
+         as well as the MySQL float types, will use the value of ".scale"
+         as the default for decimal_return_scale, if not otherwise specified.
+
+         .. versionadded:: 0.9.0
+
         When using the ``Numeric`` type, care should be taken to ensure
         that the asdecimal setting is apppropriate for the DBAPI in use -
         when Numeric applies a conversion from Decimal->float or float->
@@ -487,6 +503,10 @@ class Numeric(_DateAffinity, TypeEngine):
         """
         self.precision = precision
         self.scale = scale
+        self.decimal_return_scale = decimal_return_scale \
+                                    if decimal_return_scale is not None \
+                                    else self.scale if self.scale is not None \
+                                    else self._default_decimal_return_scale
         self.asdecimal = asdecimal
 
     def get_dbapi_type(self, dbapi):
@@ -525,12 +545,10 @@ class Numeric(_DateAffinity, TypeEngine):
                           'storage.' % (dialect.name, dialect.driver))
 
                 # we're a "numeric", DBAPI returns floats, convert.
-                if self.scale is not None:
-                    return processors.to_decimal_processor_factory(
-                                decimal.Decimal, self.scale)
-                else:
-                    return processors.to_decimal_processor_factory(
-                                decimal.Decimal)
+                return processors.to_decimal_processor_factory(
+                            decimal.Decimal,
+                            self.scale if self.scale is not None
+                            else self._default_decimal_return_scale)
         else:
             if dialect.supports_native_decimal:
                 return processors.to_float
@@ -576,7 +594,8 @@ class Float(Numeric):
 
     scale = None
 
-    def __init__(self, precision=None, asdecimal=False, **kwargs):
+    def __init__(self, precision=None, asdecimal=False,
+                        decimal_return_scale=None, **kwargs):
         """
         Construct a Float.
 
@@ -587,6 +606,17 @@ class Float(Numeric):
           defaults to ``False``.   Note that setting this flag to ``True``
           results in floating point conversion.
 
+        :param decimal_return_scale: Default scale to use when converting
+         from floats to Python decimals.  Floating point values will typically
+         be much longer due to decimal inaccuracy, and most floating point
+         database types don't have a notion of "scale", so by default the
+         float type looks for the first ten decimal places when converting.
+         Specfiying this value will override that length.  Note that the
+         MySQL float types, which do include "scale", will use "scale"
+         as the default for decimal_return_scale, if not otherwise specified.
+
+         .. versionadded:: 0.9.0
+
         :param \**kwargs: deprecated.  Additional arguments here are ignored
          by the default :class:`.Float` type.  For database specific
          floats that support additional arguments, see that dialect's
@@ -596,13 +626,17 @@ class Float(Numeric):
         """
         self.precision = precision
         self.asdecimal = asdecimal
+        self.decimal_return_scale = decimal_return_scale \
+                                    if decimal_return_scale is not None \
+                                    else self._default_decimal_return_scale
         if kwargs:
             util.warn_deprecated("Additional keyword arguments "
                                 "passed to Float ignored.")
 
     def result_processor(self, dialect, coltype):
         if self.asdecimal:
-            return processors.to_decimal_processor_factory(decimal.Decimal)
+            return processors.to_decimal_processor_factory(
+                                    decimal.Decimal, self.decimal_return_scale)
         else:
             return None
 
index 408c3705efd8ed06cc0fccb43735b231e4f1dedd..e48fa2c0043acd8949cc675aeb0d3d47220e4614 100644 (file)
@@ -393,6 +393,14 @@ class SuiteRequirements(Requirements):
 
         return exclusions.closed()
 
+    @property
+    def precision_generic_float_type(self):
+        """target backend will return native floating point numbers with at
+        least seven decimal places when using the generic Float type.
+
+        """
+        return exclusions.open()
+
     @property
     def floats_to_four_decimals(self):
         """target backend can return a floating-point number with four
index 3eb105ba3ccfe7e63f5fa6e1d868354d5b68b65e..b147f891a7dc8ae6e59bccee88954eba1c97d56e 100644 (file)
@@ -356,6 +356,16 @@ class NumericTest(_LiteralRoundTripFixture, fixtures.TestBase):
             filter_=lambda n: n is not None and round(n, 5) or None
         )
 
+
+    @testing.requires.precision_generic_float_type
+    def test_float_custom_scale(self):
+        self._do_test(
+            Float(None, decimal_return_scale=7, asdecimal=True),
+            [15.7563827, decimal.Decimal("15.7563827")],
+            [decimal.Decimal("15.7563827"),],
+            check_scale=True
+        )
+
     def test_numeric_as_decimal(self):
         self._do_test(
             Numeric(precision=8, scale=4),
index ec7b699269c98a1c1d5fe40cdbb7214986583ae0..014d29d691bac07475efbdeb9a7ab28e8e960dc3 100644 (file)
@@ -10,7 +10,7 @@ from sqlalchemy.testing import fixtures, AssertsCompiledSQL, AssertsExecutionRes
 from sqlalchemy import testing
 from sqlalchemy.testing.engines import utf8_engine
 import datetime
-
+import decimal
 
 class TypesTest(fixtures.TestBase, AssertsExecutionResults, AssertsCompiledSQL):
     "Test MySQL column types"
@@ -147,6 +147,23 @@ class TypesTest(fixtures.TestBase, AssertsExecutionResults, AssertsCompiledSQL):
                 res
             )
 
+    @testing.provide_metadata
+    def test_precision_float_roundtrip(self):
+        t = Table('t', self.metadata,
+                    Column('scale_value', mysql.DOUBLE(precision=15, scale=12, asdecimal=True)),
+                    Column('unscale_value', mysql.DOUBLE(decimal_return_scale=12, asdecimal=True))
+            )
+        t.create(testing.db)
+        testing.db.execute(
+            t.insert(), scale_value=45.768392065789,
+            unscale_value=45.768392065789
+        )
+        result = testing.db.scalar(select([t.c.scale_value]))
+        eq_(result, decimal.Decimal("45.768392065789"))
+
+        result = testing.db.scalar(select([t.c.unscale_value]))
+        eq_(result, decimal.Decimal("45.768392065789"))
+
     @testing.exclude('mysql', '<', (4, 1, 1), 'no charset support')
     def test_charset(self):
         """Exercise CHARACTER SET and COLLATE-ish options on string types."""
index 4ed0a92897dbe26754eddb56786e5969e15f2601..b6fca06edc54bbfee161c5135c8f27cf5965c14f 100644 (file)
@@ -540,6 +540,13 @@ class DefaultRequirements(SuiteRequirements):
                 ]
                 )
 
+    @property
+    def precision_generic_float_type(self):
+        """target backend will return native floating point numbers with at
+        least seven decimal places when using the generic Float type."""
+
+        return fails_if('mysql', 'mysql FLOAT type only returns 4 decimals')
+
     @property
     def floats_to_four_decimals(self):
         return fails_if("mysql+oursql", "Floating point error")