diff --git a/singer_sdk/connectors/sql.py b/singer_sdk/connectors/sql.py index 5e580f6fb..0f369f7e1 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 `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 @@ def from_config(cls, config): Args: config: The configuration dictionary. + use_singer_decimal: Whether to represent numbers as `string` with + the `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 @@ def integer_to_jsonschema(self, column_type: sa.types.Integer) -> dict: # noqa: 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 @@ def __init__(self, *, max_varchar_length: int | None = None) -> None: "hostname": lambda _: sa.types.VARCHAR(253), # RFC 1035 "ipv4": lambda _: sa.types.VARCHAR(15), "ipv6": lambda _: sa.types.VARCHAR(45), + "singer.decimal": self._handle_singer_decimal, } self._sql_datatype_mapping: dict[str, JSONtoSQLHandler] = {} @@ -331,6 +347,17 @@ def _invoke_handler( # noqa: PLR6301 return handler() # type: ignore[no-any-return] return handler(schema) + def _handle_singer_decimal(self, schema: dict) -> sa.types.TypeEngine: # noqa: PLR6301 + """Handle a 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/typing.py b/singer_sdk/typing.py index 74eb7623e..3a1c119c3 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. diff --git a/tests/core/test_connector_sql.py b/tests/core/test_connector_sql.py index b32bae207..446971a35 100644 --- a/tests/core/test_connector_sql.py +++ b/tests/core/test_connector_sql.py @@ -510,6 +510,14 @@ def my_type_to_jsonschema(self, column_type) -> dict: # noqa: ARG002 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 @@ def test_unknown_format(self, json_schema_to_sql: JSONSchemaToSQL): 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 @@ def handle_raw_string(self, schema): 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,12 @@ def handle_raw_string(self, schema): 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": "singer.decimal"} + result = json_schema_to_sql.to_sql_type(jsonschema_type) + assert isinstance(result, sa.types.DECIMAL) + def test_annotation_sql_datatype(self): json_schema_to_sql = JSONSchemaToSQL() json_schema_to_sql.register_sql_datatype_handler("json", sa.types.JSON)