Repository: saleor/saleor

Issue:
Replace the custom Decimal scalar type with Float
Currently `graphene` uses a custom scalar type to represent an implementation detail of using a fixed-precision implementation for floating-point arithmetic.

This means the client (typically not written in Python) now has to deal with conversion to and from string. It also makes it hard to enforce strict type checking in TypeScript as there is no easy way to statically check whether a value is a valid decimal literal for Python.

I propose that we overload the builtin types to use `decimal.Decimal` in backend code and `Float!` in the GraphQL API.


Discussion:

Resolving patch:
diff --git a/saleor/graphql/core/types/common.py b/saleor/graphql/core/types/common.py
--- a/saleor/graphql/core/types/common.py
+++ b/saleor/graphql/core/types/common.py
@@ -1,37 +1,24 @@
 import decimal
 
 import graphene
-from graphene.types import Scalar
 from graphene_django import DjangoObjectType
-from graphql.language import ast
 
 from ....core import weight
 from ..connection import CountableConnection
 
 
-# FIXME: Decimal scalar implementation has been taken from Graphene repo.
-# Remove it when a new version including this implementation is released.
-class Decimal(Scalar):
+class Decimal(graphene.Float):
+    """Custom Decimal implementation.
+    Returns Decimal as a float in the API,
+    parses float to the Decimal on the way back.
     """
-    The `Decimal` scalar type represents a python Decimal.
-    """
-
-    @staticmethod
-    def serialize(dec):
-        if isinstance(dec, str):
-            dec = decimal.Decimal(dec)
-        assert isinstance(dec, decimal.Decimal), 'Received not compatible Decimal "{}"'.format(
-            repr(dec))
-        return str(dec)
-
-    @classmethod
-    def parse_literal(cls, node):
-        if isinstance(node, ast.StringValue):
-            return cls.parse_value(node.value)
 
     @staticmethod
     def parse_value(value):
         try:
+            # Converting the float to str before parsing it to Decimal is
+            # necessary to keep the decimal places as typed
+            value = str(value)
             return decimal.Decimal(value)
         except decimal.DecimalException:
             return None
diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py
--- a/saleor/graphql/order/types.py
+++ b/saleor/graphql/order/types.py
@@ -5,7 +5,6 @@
 from ..account.types import User
 from ..core.types.common import CountableDjangoObjectType
 from ..core.types.money import Money, TaxedMoney
-from decimal import Decimal
 
 OrderEventsEnum = graphene.Enum.from_enum(OrderEvents)
 
@@ -41,7 +40,7 @@ def resolve_email_type(self, info):
 
     def resolve_amount(self, info):
         amount = self.parameters.get('amount', None)
-        return Decimal(amount) if amount else None
+        return float(amount) if amount else None
 
     def resolve_quantity(self, info):
         quantity = self.parameters.get('quantity', None)


Test patch:
diff --git a/tests/api/test_core.py b/tests/api/test_core.py
--- a/tests/api/test_core.py
+++ b/tests/api/test_core.py
@@ -77,7 +77,7 @@ def test_mutation_returns_error_field_in_camel_case(admin_api_client, variant):
     """
     variables = json.dumps({
         'id': graphene.Node.to_global_id('ProductVariant', variant.id),
-        'cost': '12.1234'})
+        'cost': 12.1234})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
     content = get_graphql_content(response)
diff --git a/tests/api/test_discount.py b/tests/api/test_discount.py
--- a/tests/api/test_discount.py
+++ b/tests/api/test_discount.py
@@ -132,8 +132,8 @@ def test_create_voucher(user_api_client, admin_api_client):
         'type': VoucherTypeEnum.VALUE.name,
         'code': 'testcode123',
         'discountValueType': DiscountValueTypeEnum.FIXED.name,
-        'discountValue': '10.12',
-        'minAmountSpent': '1.12'})
+        'discountValue': 10.12,
+        'minAmountSpent': 1.12})
     response = user_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
     assert_no_permission(response)
@@ -144,7 +144,7 @@ def test_create_voucher(user_api_client, admin_api_client):
     assert 'errors' not in content
     data = content['data']['voucherCreate']['voucher']
     assert data['type'] == VoucherType.VALUE.upper()
-    assert data['minAmountSpent']['amount'] == float('1.12')
+    assert data['minAmountSpent']['amount'] == 1.12
     assert data['name'] == 'test voucher'
     assert data['code'] == 'testcode123'
     assert data['discountValueType'] == DiscountValueType.FIXED.upper()
diff --git a/tests/api/test_order.py b/tests/api/test_order.py
--- a/tests/api/test_order.py
+++ b/tests/api/test_order.py
@@ -439,7 +439,7 @@ def test_order_capture(admin_api_client, payment_preauth, admin_user):
         }
     """
     order_id = graphene.Node.to_global_id('Order', order.id)
-    amount = str(payment_preauth.get_total_price().gross.amount)
+    amount = float(payment_preauth.get_total_price().gross.amount)
     variables = json.dumps({'id': order_id, 'amount': amount})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
@@ -461,7 +461,7 @@ def test_order_capture(admin_api_client, payment_preauth, admin_user):
         'email_type': OrderEventsEmails.PAYMENT.value}
     assert event_captured.type == OrderEvents.PAYMENT_CAPTURED.value
     assert event_captured.user == admin_user
-    assert event_captured.parameters == {'amount': '80.00'}
+    assert event_captured.parameters == {'amount': str(amount)}
 
 
 def test_paid_order_mark_as_paid(
@@ -559,7 +559,7 @@ def test_order_refund(admin_api_client, payment_confirmed):
         }
     """
     order_id = graphene.Node.to_global_id('Order', order.id)
-    amount = str(payment_confirmed.get_total_price().gross.amount)
+    amount = float(payment_confirmed.get_total_price().gross.amount)
     variables = json.dumps({'id': order_id, 'amount': amount})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
@@ -571,7 +571,7 @@ def test_order_refund(admin_api_client, payment_confirmed):
     assert data['isPaid'] == False
 
     order_event = order.events.last()
-    assert order_event.parameters['amount'] == amount
+    assert order_event.parameters['amount'] == str(amount)
     assert order_event.type == OrderEvents.PAYMENT_REFUNDED.value
 
 
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -383,7 +383,7 @@ def test_create_product(
     product_isPublished = True
     product_chargeTaxes = True
     product_taxRate = 'STANDARD'
-    product_price = "22.33"
+    product_price = 22.33
 
     # Default attribute defined in product_type fixture
     color_attr = product_type.product_attributes.get(name='Color')
diff --git a/tests/api/test_shipping_method.py b/tests/api/test_shipping_method.py
--- a/tests/api/test_shipping_method.py
+++ b/tests/api/test_shipping_method.py
@@ -198,14 +198,14 @@ def test_delete_shipping_zone(admin_api_client, shipping_zone):
 @pytest.mark.parametrize(
     'min_price, max_price, expected_min_price, expected_max_price',
     (
-        ('10', '15', {'amount': float(10)}, {'amount': float(15)}),
-        ('10', None, {'amount': float(10)}, None)))
+        (10.32, 15.43, {'amount': 10.32}, {'amount': 15.43}),
+        (10.33, None, {'amount': 10.33}, None)))
 def test_create_shipping_method(
         admin_api_client, shipping_zone, min_price, max_price,
         expected_min_price, expected_max_price, price_based_shipping_query):
     query = price_based_shipping_query
     name = 'DHL'
-    price = '12.34'
+    price = 12.34
     shipping_zone_id = graphene.Node.to_global_id(
         'ShippingZone', shipping_zone.pk)
     variables = json.dumps({
@@ -228,9 +228,9 @@ def test_create_shipping_method(
 @pytest.mark.parametrize(
     'min_weight, max_weight, expected_min_weight, expected_max_weight',
     (
-        ('10', '15', {'value': 10, 'unit': 'kg'},
-         {'value': 15, 'unit': 'kg'}),
-        ('10', None, {'value': 10, 'unit': 'kg'}, None)))
+        (10.32, 15.64, {'value': 10.32, 'unit': 'kg'},
+         {'value': 15.64, 'unit': 'kg'}),
+        (10.92, None, {'value': 10.92, 'unit': 'kg'}, None)))
 def test_create_weight_based_shipping_method(
         shipping_zone, admin_api_client, min_weight, max_weight,
         expected_min_weight, expected_max_weight, weight_based_shipping_query):
@@ -238,7 +238,7 @@ def test_create_weight_based_shipping_method(
     shipping_zone_id = graphene.Node.to_global_id(
         'ShippingZone', shipping_zone.pk)
     variables = json.dumps({
-        'shippingZone': shipping_zone_id, 'name': 'DHL', 'price': '12.34',
+        'shippingZone': shipping_zone_id, 'name': 'DHL', 'price': 12.34,
         'minimumOrderWeight': min_weight, 'maximumOrderWeight': max_weight,
         'type': ShippingMethodTypeEnum.WEIGHT_BASED.name})
     response = admin_api_client.post(
@@ -253,11 +253,11 @@ def test_create_weight_based_shipping_method(
 @pytest.mark.parametrize(
     'min_weight, max_weight, expected_error',
     (
-        (None, 15, {
+        (None, 15.11, {
             'field': 'minimumOrderWeight',
             'message': 'Minimum order weight is required for'
                        ' Weight Based shipping.'}),
-        (20, 15, {
+        (20.21, 15.11, {
             'field': 'maximumOrderWeight',
             'message': 'Maximum order weight should be larger than the minimum.'  # noqa
         })))
@@ -268,7 +268,7 @@ def test_create_weight_shipping_method_errors(
     shipping_zone_id = graphene.Node.to_global_id(
         'ShippingZone', shipping_zone.pk)
     variables = json.dumps({
-        'shippingZone': shipping_zone_id, 'name': 'DHL', 'price': '12.34',
+        'shippingZone': shipping_zone_id, 'name': 'DHL', 'price': 12.34,
         'minimumOrderWeight': min_weight, 'maximumOrderWeight': max_weight,
         'type': ShippingMethodTypeEnum.WEIGHT_BASED.name})
     response = admin_api_client.post(
@@ -282,11 +282,11 @@ def test_create_weight_shipping_method_errors(
 @pytest.mark.parametrize(
     'min_price, max_price, expected_error',
     (
-        (None, 15, {
+        (None, 15.11, {
             'field': 'minimumOrderPrice',
             'message': 'Minimum order price is required'
                        ' for Price Based shipping.'}),
-        (20, 15, {
+        (20.21, 15.11, {
             'field': 'maximumOrderPrice',
             'message': 'Maximum order price should be larger than the minimum.'
         })))
@@ -297,7 +297,7 @@ def test_create_price_shipping_method_errors(
     shipping_zone_id = graphene.Node.to_global_id(
         'ShippingZone', shipping_zone.pk)
     variables = json.dumps({
-        'shippingZone': shipping_zone_id, 'name': 'DHL', 'price': '12.34',
+        'shippingZone': shipping_zone_id, 'name': 'DHL', 'price': 12.34,
         'minimumOrderPrice': min_price, 'maximumOrderPrice': max_price,
         'type': ShippingMethodTypeEnum.PRICE_BASED.name})
     response = admin_api_client.post(
@@ -334,7 +334,7 @@ def test_update_shipping_method(admin_api_client, shipping_zone):
     }
     """
     shipping_method = shipping_zone.shipping_methods.first()
-    price = '12.34'
+    price = 12.34
     assert not str(shipping_method.price) == price
     shipping_zone_id = graphene.Node.to_global_id(
         'ShippingZone', shipping_zone.pk)
@@ -345,7 +345,7 @@ def test_update_shipping_method(admin_api_client, shipping_zone):
             'shippingZone': shipping_zone_id,
             'price': price,
             'id': shipping_method_id,
-            'minimumOrderPrice': '12.00',
+            'minimumOrderPrice': 12.00,
             'type': ShippingMethodTypeEnum.PRICE_BASED.name})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
diff --git a/tests/api/test_variant.py b/tests/api/test_variant.py
--- a/tests/api/test_variant.py
+++ b/tests/api/test_variant.py
@@ -117,9 +117,10 @@ def test_create_variant(admin_api_client, product, product_type):
     """
     product_id = graphene.Node.to_global_id('Product', product.pk)
     sku = "1"
-    price_override = 1
-    cost_price = 3
+    price_override = 1.32
+    cost_price = 3.22
     quantity = 10
+    weight = 10.22
     variant_slug = product_type.variant_attributes.first().slug
     variant_value = 'test-value'
 
@@ -129,7 +130,7 @@ def test_create_variant(admin_api_client, product, product_type):
         'quantity': quantity,
         'costPrice': cost_price,
         'priceOverride': price_override,
-        'weight': '10',
+        'weight': weight,
         'attributes': [
             {'slug': variant_slug, 'value': variant_value}],
         'trackInventory': True})
@@ -146,7 +147,7 @@ def test_create_variant(admin_api_client, product, product_type):
     assert data['attributes'][0]['attribute']['slug'] == variant_slug
     assert data['attributes'][0]['value']['slug'] == variant_value
     assert data['weight']['unit'] == 'kg'
-    assert data['weight']['value'] == float(10)
+    assert data['weight']['value'] == weight
 
 
 def test_update_product_variant(admin_api_client, product):
@@ -182,7 +183,7 @@ def test_update_product_variant(admin_api_client, product):
     variant = product.variants.first()
     variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
     sku = "test sku"
-    cost_price = 3
+    cost_price = 3.3
     quantity = 123
 
     variables = json.dumps({