Repository: meltano/sdk

Issue:
feat: Support Singer Decimal as a config item
### Feature scope

Taps (catalog, state, stream maps, tests, etc.)

### Description

There have been discussions before about whether it is a best practice to emit Decimal, Integer, and Float datatypes as a string or in their native formats.

I have mentioned this in passing in this JSON dicussion https://github.com/meltano/sdk/issues/1046 . The author of msgspec strongly recommends sending numeric data as a string.

There was an informal standard called Singer Decimal where the tap would emit the Numeric data as a string (and if possible) try and turn it back into a numeric format in the target.

You may ask, why would you do this? The key reasons are :
1. Rounding issues with float datatypes
2. Inability to handle large numbers or decimal places
3. Python not handling the numeric data well
4. The tap does not have enough information in the database schema to hint the correct format. For example in Oracle it is a common practice to specify a datatype of number without supplying a precision or scale. The only accurate way to determine the correct precision and scale in the target is to sample the data. In this situation the only reasonable way to send this data is as a string, and then use a transform tool to cast the data.

For a number of database related taps we have implement a setting called `use_singer_decimal`. When this is set to true, it will send the data as a string but provides additional information about the scale and precision (if available) in the singer messages. When the target receives the message, if it has this additional singer decimal data, it can cast the data with the correct datatype. The good thing about this approach is that it is a setting, therefore based on your use case, you can emit numeric data in its native numeric format (default setting), or preferable enable the `use_singer_decimal` setting to gain more accurate numeric data. 

We have found by using this methodology, there is no loss of precision. It would be great to introduce this ability into the SDK as it will ensure accurate numeric data being sent.

Examples of enhanced database taps which support this are:

- [pipelinewise-tap-oracle](https://github.com/s7clarke10/pipelinewise-tap-oracle)
- [pipelinewise-tap-mssql](https://github.com/s7clarke11/pipelinewise-tap-mssql)
- [tap-sybase](https://github.com/s7clarke10/tap-sybase)
- [tap-db2](https://github.com/mjsqu/tap-db2)

Example enhanced target with support for decoding singer-decimal data:

- [pipelinewise-target-snowflake](https://github.com/mjsqu/pipelinewise-target-snowflake)

More detail for tap-db2 written by @mjsqu .

DECIMALs from DB2 are given format property "singer.decimal" in the schema, with type property "number". The numeric scale & precision are stored as `schema[additionalProperties][scale_precision]`, e.g. `"additionalProperties":{"scale_precision":"(15,9)"}`.

When the tap writes the RECORD message, "singer.decimal" informs the data value to be output into the message as a string
The snowflake target obtains the scale/precision from the schema message to create a column of type NUMBER(scale, precision).

Discussion:
@edgarrmondragon: So, I think there's two parts to this AFAICT.

## Support at the tap

This is arguably already support by the typing helpers via [`CustomType`](https://github.com/meltano/sdk/blob/65d9dbacb6a000c7ff2058b7b578f57fec56b725/singer_sdk/typing.py#L858):

```python
jsonschema = PropertiesList(
  Property("my_number", CustomType({"type": "string", "format": "singer.decimal"})),
)
```

A nicer wrapper could also be added.

## Support at the target

[`to_sql_type`](https://github.com/meltano/sdk/blob/65d9dbacb6a000c7ff2058b7b578f57fec56b725/singer_sdk/typing.py#L979-L1001) could be adapted to map the `singer.decimal` format to the appropriate SQL column type.@s7clarke10: Hi @edgarrmondragon,

To show an example of my usage of Singer Decimal. I have a working copying along with the usage of msgspec on the traditional singer-python frameworks used by some non-SDK taps. I am seeing some good performance from doing this.  https://github.com/s7clarke10/pipelinewise-singer-python.

For testing this I have created a branch on [pipelinewise-tap-oracle](https://github.com/s7clarke10/pipelinewise-tap-oracle/tree/feature/use_msgspec_serializer) to use msgspec.

Of note is the use of a boolean config setting use_singer_decimal in the tap. As a user of a tap, I have a choice whether I wish to have decimal and float data emitted as a string - `"use_singer_decimal": true` (to avoid precision loss), or in their native formats for backwards compatibility. It seems to work well, and produces the same results when I was using orjson.

If that setting is omitted it will out default to outputting decimals and floats in their lossy native format - important for backwards compatibility.

I should note however, there is logic in a tap around the schema definition to define the correct JSON data types when it is discovered so that the records matching the schema. 

Here is an example of output with `use_singer_decimal` set to true.

```json
(pipelinewise-tap-oracle) [ pipelinewise-tap-oracle]$ tap-oracle -c oracle_config_test.json --catalog oracle_properties_test.json
time=2023-08-17 10:12:04 name=singer level=INFO message=Selected streams: ['STEVE-STEVE_TEST'] 
time=2023-08-17 10:12:04 name=singer level=INFO message=No currently_syncing found
time=2023-08-17 10:12:04 name=singer level=INFO message=Beginning sync of stream(STEVE-STEVE_TEST) with sync method(full)
time=2023-08-17 10:12:04 name=singer level=INFO message=Stream STEVE-STEVE_TEST is using full_table replication
time=2023-08-17 10:12:04 name=singer level=INFO message=Singer Decimal Enabled! Floats and Decimals will be output as strings
time=2023-08-17 10:12:04 name=singer level=INFO message=Selected streams: ['STEVE-STEVE_TEST'] 
time=2023-08-17 10:12:04 name=singer level=INFO message=No currently_syncing found
time=2023-08-17 10:12:04 name=singer level=INFO message=Beginning sync of stream(STEVE-STEVE_TEST) with sync method(full)
time=2023-08-17 10:12:04 name=singer level=INFO message=Stream STEVE-STEVE_TEST is using full_table replication
time=2023-08-17 10:12:04 name=singer level=INFO message=Singer Decimal Enabled! Floats and Decimals will be output as strings
{"type":"SCHEMA","stream":"STEVE-STEVE_TEST","schema":{"properties":{"COL1":{"maxLength":100,"type":["null","string"]},"COL2":{"maxLength":50,"type":["null","string"]},"DECIMAL_FIELD":{"format":"singer.decimal","type":["null","string"],"additionalProperties":{"scale_precision":"(10,2)"}},"INTEGER_FIELD":{"type":["null","integer"]},"FLOAT_FIELD":{"format":"singer.decimal","type":["null","string"]}},"type":"object"},"key_properties":[]}
time=2023-08-17 10:12:04 name=singer level=INFO message=dsn: (DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=10.131.2.102)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=pndww2db.moh.govt.nz)))
{"type":"STATE","value":{"bookmarks":{"STEVE-STEVE_TEST":{"last_replication_method":"FULL_TABLE","version":1692223924745}},"currently_syncing":"STEVE-STEVE_TEST"}}
{"type":"ACTIVATE_VERSION","stream":"STEVE-STEVE_TEST","version":1692223924745}
time=2023-08-17 10:12:04 name=singer level=INFO message=select SELECT  "COL1" , "COL2" , "DECIMAL_FIELD" , "INTEGER_FIELD" , "FLOAT_FIELD" , NULL as ORA_ROWSCN
                                FROM STEVE.STEVE_TEST
{"type":"RECORD","stream":"STEVE-STEVE_TEST","record":{"COL1":"Decimal Test","COL2":"201.35","DECIMAL_FIELD":"201.35","INTEGER_FIELD":null,"FLOAT_FIELD":null},"version":1692223924745,"time_extracted":"2023-08-16T22:12:04.745338Z"}
{"type":"RECORD","stream":"STEVE-STEVE_TEST","record":{"COL1":"Integer Test","COL2":"438437439439374974943","DECIMAL_FIELD":null,"INTEGER_FIELD":438437439439374974943,"FLOAT_FIELD":null},"version":1692223924745,"time_extracted":"2023-08-16T22:12:04.745338Z"}
{"type":"RECORD","stream":"STEVE-STEVE_TEST","record":{"COL1":"Float Test","COL2":"3.14159265359","DECIMAL_FIELD":null,"INTEGER_FIELD":null,"FLOAT_FIELD":"3.14159265359"},"version":1692223924745,"time_extracted":"2023-08-16T22:12:04.745338Z"}
{"type":"RECORD","stream":"STEVE-STEVE_TEST","record":{"COL1":null,"COL2":"Hello","DECIMAL_FIELD":null,"INTEGER_FIELD":null,"FLOAT_FIELD":null},"version":1692223924745,"time_extracted":"2023-08-16T22:12:04.745338Z"}
{"type":"RECORD","stream":"STEVE-STEVE_TEST","record":{"COL1":"World","COL2":null,"DECIMAL_FIELD":null,"INTEGER_FIELD":null,"FLOAT_FIELD":null},"version":1692223924745,"time_extracted":"2023-08-16T22:12:04.745338Z"}
time=2023-08-17 10:12:04 name=singer level=INFO message=METRIC: b'{"type":"counter","metric":"record_count","value":5,"tags":{"schema":"STEVE","table":"STEVE_TEST"}}'
{"type":"ACTIVATE_VERSION","stream":"STEVE-STEVE_TEST","version":1692223924745}
{"type":"STATE","value":{"bookmarks":{"STEVE-STEVE_TEST":{"last_replication_method":"FULL_TABLE","version":1692223924745,"ORA_ROWSCN":null}},"currently_syncing":null}}
```
@stale: This has been marked as stale because it is unassigned, and has not had recent activity. It will be closed after 21 days if no further activity occurs. If this should never go stale, please add the `evergreen` label, or request that it be added.
@edgarrmondragon: I've reopened cause someone brought it up in Slack[^1], and commenting here  on minimal requirements for shipping this:

1. New typing wrapper, e.g. `SingerDecimal`
2. New capability enum member for taps and targets to communicate support for this

As for more implementation details, I'm not a fan of using `additionalProperties` for this, since it's not compliant with the JSON Schema spec:

<blockquote>

The value of the `additionalProperties` keyword is a schema that will be used to validate any properties in the [instance](https://json-schema.org/learn/glossary#instance) that are not matched by properties or `patternProperties`. Setting the `additionalProperties` schema to `false` means no additional properties will be allowed.[^2]

</blockquote>

... but I'm willing to compromise if its use is common enough in the Singer ecosystem.

[^1]: https://meltano.slack.com/archives/C069A0GE129/p1725897289536329
[^2]: https://json-schema.org/understanding-json-schema/reference/object#additionalproperties@s7clarke10: Adding the comments I added to the slack channel.

So when we transfer data from databases like SQL Server, Sybase, DB2, Oracle, we contributed to the Meltano Hub default variants of these taps to allow you to emit the numeric data e.g. decimal / float data as strings i.e. not scientific notation - this requires the singer_decimal config item to be set to true. When this is set, logic in the tap also provides additional information we require to convert it from a string back into a number in the target loader once it has been transferred without any loss in precision. This is achieved by the taps having logic built-in to also give some hints as to what the original column definition was in the original source system e.g. "additionalProperties":{"scale_precision":"(38,20)".

We have our own variant of the original pipelinewise target snowflake which has been patched and a few bugs removed - https://github.com/mjsqu/pipelinewise-target-snowflake . This variant has logic to recognize the singer decimal and make use of the additional properties. If you use this variant, it will automatically convert and store the data as a number with the correct scale and precision as per the source system.

This has been very important for us as we found during our testing that there is a loss of precision if the numeric based decimal and floats were not emitted as strings via the enablement of singer decimal. I have been meaning to raise a PR to the Meltanolabs version of target-snowflake to add our logic so it could be our preferred loader.

It is worth noting for Oracle Databases however that singer decimal is harder as Oracle doesn't really set a scale and precision. It is often set as just number and the user can store a mix of integers and decimals. If this is the case we will not emit any precision and scale and that data will be transferred as a string. It would be the responsibility of the data transform e.g. dbt to set an appropriate data type as it is too dangerous to guess what is correct. We will still transfer it as a string to avoid python and scientific notation leading rounding issues.@s7clarke10: This https://github.com/mjsqu/pipelinewise-target-snowflake/blob/e2a216c3cfa3d56fe66902e611812fe146528996/target_snowflake/db_sync.py#L99-L100 section in our variant of target-snowflake which will set the correct datatype in Snowflake for singer-decimal columns.

The actual PR is here : https://github.com/mjsqu/pipelinewise-target-snowflake/commit/558a0d6bb0ba4166f188cd1aac0c970bb9ed36a1

A couple of notable enhancements we made to the original pipelinewise version.
1. Fixing an issue with NULL's in an INT leading to the conversion of a Float datatype causing rounding - https://github.com/transferwise/pipelinewise-target-snowflake/issues/404
2. Adding the ability to set no proxy to allow internal routing within AWS avoid going out via the public network - https://github.com/mjsqu/pipelinewise-target-snowflake/pull/4
3. Ability to set a retention period for the table - https://github.com/mjsqu/pipelinewise-target-snowflake/pull/27
4. Ability to retain the column order as per the source system - https://github.com/mjsqu/pipelinewise-target-snowflake/pull/3
5. Ability to have append and truncate options for loaded data - https://github.com/mjsqu/pipelinewise-target-snowflake/pull/7@edgarrmondragon: Got a draft PR built on top on recent JSON schema mapping improvements: https://github.com/meltano/sdk/pull/2786@s7clarke10: Thank you @edgarrmondragon, I think this will be a very useful feature for people building database taps and targets to receive the singer_decimal hint.

Solution patch:
diff --git a/docs/guides/sql-tap.md b/docs/guides/sql-tap.md
index 68b171f8..906d9d6c 100644
--- a/docs/guides/sql-tap.md
+++ b/docs/guides/sql-tap.md
@@ -80,3 +80,10 @@ class ConfiguredSQLToJSONSchema(SQLToJSONSchema):
 ```
 
 Then, you can use your custom type mapping in your connector as in the previous example.
+
+
+### SQL tap support for Singer Decimal string format
+
+Starting from version `0.45.0`, the Meltano Singer SDK supports the `x-singer.decimal` format for strings. You can configure the tap to use it with the `use_singer_decimal` setting. SQL Targets that support the `x-singer.decimal` format will create an appropriate numeric column in the target database.
+
+Read more about target support for `x-singer.decimal` in the [SQL target guide](./sql-target.md#sql-target-support-for-singer-decimal-string-format).
diff --git a/docs/guides/sql-target.md b/docs/guides/sql-target.md
index c2af5c01..17f5edaa 100644
--- a/docs/guides/sql-target.md
+++ b/docs/guides/sql-target.md
@@ -109,3 +109,9 @@ plugins:
 }
 ```
 ````
+
+### SQL target support for Singer Decimal string format
+
+Starting from version `0.45.0`, the Meltano Singer SDK supports the `x-singer.decimal` format for strings. If the source tap is configured to use this format, the SDK will automatically convert the string to a `DECIMAL` type in the target database.
+
+Read more about target support for `x-singer.decimal` in the [SQL tap guide](./sql-tap.md#sql-tap-support-for-singer-decimal-string-format).
diff --git a/singer_sdk/connectors/sql.py b/singer_sdk/connectors/sql.py
index a37a6a70..ba0d3246 100644
--- a/singer_sdk/connectors/sql.py
+++ b/singer_sdk/connectors/sql.py
@@ -126,10 +126,21 @@ class SQLToJSONSchema:
     .. versionchanged:: 0.43.0
        Added the :meth:`singer_sdk.connectors.sql.SQLToJSONSchema.from_config` class
        method.
+    .. versionchanged:: 0.45.0
+       Added support for the `use_singer_decimal` option.
     """
 
+    def __init__(self, *, use_singer_decimal: bool = False) -> None:
+        """Initialize the SQL to JSON Schema converter.
+
+        Args:
+            use_singer_decimal: Whether to represent numbers as `string` with
+                the `x-singer.decimal` format instead of as `number`.
+        """
+        self.use_singer_decimal = use_singer_decimal
+
     @classmethod
-    def from_config(cls: type[SQLToJSONSchema], config: dict) -> SQLToJSONSchema:  # noqa: ARG003
+    def from_config(cls: type[SQLToJSONSchema], config: dict) -> SQLToJSONSchema:
         """Create a new instance from a configuration dictionary.
 
         Override this to instantiate this converter with values from the tap's
@@ -148,11 +159,13 @@ class SQLToJSONSchema:
 
         Args:
             config: The configuration dictionary.
+            use_singer_decimal: Whether to represent numbers as `string` with
+                the `x-singer.decimal` format instead of as `number`.
 
         Returns:
             A new instance of the class.
         """
-        return cls()
+        return cls(use_singer_decimal=config.get("use_singer_decimal", False))
 
     @functools.singledispatchmethod
     def to_jsonschema(self, column_type: sa.types.TypeEngine) -> dict:  # noqa: ARG002, D102, PLR6301
@@ -195,12 +208,14 @@ class SQLToJSONSchema:
         return th.IntegerType.type_dict  # type: ignore[no-any-return]
 
     @to_jsonschema.register
-    def float_to_jsonschema(self, column_type: sa.types.Numeric) -> dict:  # noqa: ARG002, PLR6301
+    def float_to_jsonschema(self, column_type: sa.types.Numeric) -> dict:  # noqa: ARG002
         """Return a JSON Schema representation of a generic number type.
 
         Args:
             column_type (:column_type:`Numeric`): The column type.
         """
+        if self.use_singer_decimal:
+            return th.SingerDecimalType.type_dict  # type: ignore[no-any-return]
         return th.NumberType.type_dict  # type: ignore[no-any-return]
 
     @to_jsonschema.register
@@ -278,6 +293,7 @@ class JSONSchemaToSQL:
             "hostname": lambda _: sa.types.VARCHAR(253),  # RFC 1035
             "ipv4": lambda _: sa.types.VARCHAR(15),
             "ipv6": lambda _: sa.types.VARCHAR(45),
+            "x-singer.decimal": self._handle_singer_decimal,
         }
 
         self._sql_datatype_mapping: dict[str, JSONtoSQLHandler] = {}
@@ -331,6 +347,17 @@ class JSONSchemaToSQL:
             return handler()  # type: ignore[no-any-return]
         return handler(schema)
 
+    def _handle_singer_decimal(self, schema: dict) -> sa.types.TypeEngine:  # noqa: PLR6301
+        """Handle a x-singer.decimal format.
+
+        Args:
+            schema: The JSON Schema object.
+
+        Returns:
+            The appropriate SQLAlchemy type.
+        """
+        return sa.types.DECIMAL(schema.get("precision"), schema.get("scale"))
+
     @property
     def fallback_type(self) -> type[sa.types.TypeEngine]:
         """Return the fallback type.
diff --git a/singer_sdk/helpers/capabilities.py b/singer_sdk/helpers/capabilities.py
index 450df6c6..c1a2ea94 100644
--- a/singer_sdk/helpers/capabilities.py
+++ b/singer_sdk/helpers/capabilities.py
@@ -136,6 +136,18 @@ BATCH_CONFIG = PropertiesList(
         ),
     ),
 ).to_dict()
+SQL_TAP_USE_SINGER_DECIMAL = PropertiesList(
+    Property(
+        "use_singer_decimal",
+        BooleanType(),
+        title="Use Singer Decimal",
+        description=(
+            "Whether to use use strings with `x-singer.decimal` format for "
+            "decimals in the discovered schema. "
+            "This is useful to avoid precision loss when working with large numbers."
+        ),
+    ),
+).to_dict()
 TARGET_SCHEMA_CONFIG = PropertiesList(
     Property(
         "default_target_schema",
diff --git a/singer_sdk/tap_base.py b/singer_sdk/tap_base.py
index b6777bd5..b572ad85 100644
--- a/singer_sdk/tap_base.py
+++ b/singer_sdk/tap_base.py
@@ -24,6 +24,7 @@ from singer_sdk.helpers._state import write_stream_state
 from singer_sdk.helpers._util import dump_json, read_json_file
 from singer_sdk.helpers.capabilities import (
     BATCH_CONFIG,
+    SQL_TAP_USE_SINGER_DECIMAL,
     CapabilitiesEnum,
     PluginCapabilities,
     TapCapabilities,
@@ -675,6 +676,16 @@ class SQLTap(Tap):
         self._catalog_dict: dict | None = None
         super().__init__(*args, **kwargs)
 
+    @classmethod
+    def append_builtin_config(cls: type[SQLTap], config_jsonschema: dict) -> None:
+        """Appends built-in config to `config_jsonschema` if not already set.
+
+        Args:
+            config_jsonschema: [description]
+        """
+        merge_missing_config_jsonschema(SQL_TAP_USE_SINGER_DECIMAL, config_jsonschema)
+        super().append_builtin_config(config_jsonschema)
+
     @property
     def tap_connector(self) -> SQLConnector:
         """The connector object.
diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py
index d3c62bb0..2a3a1472 100644
--- a/singer_sdk/typing.py
+++ b/singer_sdk/typing.py
@@ -448,6 +448,12 @@ class RegexType(StringType):
     string_format = "regex"
 
 
+class SingerDecimalType(StringType):
+    """Decimal type."""
+
+    string_format = "singer.decimal"
+
+
 class BooleanType(JSONTypeHelper[bool]):
     """Boolean type.
 


Test patch:
diff --git a/tests/core/test_connector_sql.py b/tests/core/test_connector_sql.py
index b32bae20..0c7fa51a 100644
--- a/tests/core/test_connector_sql.py
+++ b/tests/core/test_connector_sql.py
@@ -510,6 +510,14 @@ def test_custom_type_to_jsonschema():
     assert m.to_jsonschema(sa.types.BOOLEAN()) == {"type": ["boolean"]}
 
 
+def test_numeric_to_singer_decimal():
+    converter = SQLToJSONSchema(use_singer_decimal=True)
+    assert converter.to_jsonschema(sa.types.NUMERIC()) == {
+        "type": ["string"],
+        "format": "singer.decimal",
+    }
+
+
 class TestJSONSchemaToSQL:  # noqa: PLR0904
     @pytest.fixture
     def json_schema_to_sql(self) -> JSONSchemaToSQL:
@@ -682,7 +690,7 @@ class TestJSONSchemaToSQL:  # noqa: PLR0904
         assert isinstance(result, sa.types.VARCHAR)
 
     def test_custom_fallback(self):
-        json_schema_to_sql = JSONSchemaToSQL()
+        json_schema_to_sql = JSONSchemaToSQL(max_varchar_length=None)
         json_schema_to_sql.fallback_type = sa.types.CHAR
         jsonschema_type = {"cannot": "compute"}
         result = json_schema_to_sql.to_sql_type(jsonschema_type)
@@ -696,7 +704,7 @@ class TestJSONSchemaToSQL:  # noqa: PLR0904
 
                 return super().handle_raw_string(schema)
 
-        json_schema_to_sql = CustomJSONSchemaToSQL()
+        json_schema_to_sql = CustomJSONSchemaToSQL(max_varchar_length=None)
 
         vanilla = {"type": ["string"]}
         result = json_schema_to_sql.to_sql_type(vanilla)
@@ -717,6 +725,52 @@ class TestJSONSchemaToSQL:  # noqa: PLR0904
         result = json_schema_to_sql.to_sql_type(image_type)
         assert isinstance(result, sa.types.LargeBinary)
 
+    def test_singer_decimal(self):
+        json_schema_to_sql = JSONSchemaToSQL()
+        jsonschema_type = {"type": ["string"], "format": "x-singer.decimal"}
+        result = json_schema_to_sql.to_sql_type(jsonschema_type)
+        assert isinstance(result, sa.types.DECIMAL)
+
+    def test_singer_decimal_with_precision_scale(self):
+        json_schema_to_sql = JSONSchemaToSQL()
+        precision, scale = 12, 3
+        jsonschema_type = {
+            "type": ["string"],
+            "format": "x-singer.decimal",
+            "precision": precision,
+            "scale": scale,
+        }
+        result = json_schema_to_sql.to_sql_type(jsonschema_type)
+        assert isinstance(result, sa.types.DECIMAL)
+        assert result.precision == precision
+        assert result.scale == scale
+
+    def test_handle_singer_decimal_missing_precision(self):
+        json_schema_to_sql = JSONSchemaToSQL(max_varchar_length=None)
+        schema = {
+            "type": ["string"],
+            "format": "x-singer.decimal",
+            # 'precision' is missing
+            "scale": 2,
+        }
+        result = json_schema_to_sql.to_sql_type(schema)
+        assert isinstance(result, sa.types.DECIMAL)
+        assert result.precision is None
+        assert result.scale == 2
+
+    def test_handle_singer_decimal_missing_scale(self):
+        json_schema_to_sql = JSONSchemaToSQL(max_varchar_length=None)
+        schema = {
+            "type": ["string"],
+            "format": "x-singer.decimal",
+            "precision": 10,
+            # 'scale' is missing
+        }
+        result = json_schema_to_sql.to_sql_type(schema)
+        assert isinstance(result, sa.types.DECIMAL)
+        assert result.precision == 10
+        assert result.scale is None
+
     def test_annotation_sql_datatype(self):
         json_schema_to_sql = JSONSchemaToSQL()
         json_schema_to_sql.register_sql_datatype_handler("json", sa.types.JSON)