diff --git a/target_clickhouse/connectors.py b/target_clickhouse/connectors.py index a2d7295..b86d9ed 100644 --- a/target_clickhouse/connectors.py +++ b/target_clickhouse/connectors.py @@ -128,6 +128,17 @@ def to_sql_type( ): sql_type = clickhouse_sqlalchemy_types.Nullable(sql_type) + # Wrap any type in Nullable if the JSON schema allows null values + # and it's not already Nullable and not a primary key. + schema_type = jsonschema_type.get("type", []) + if ( + isinstance(schema_type, list) + and "null" in schema_type + and not is_primary_key + and not isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + ): + sql_type = clickhouse_sqlalchemy_types.Nullable(sql_type) + return sql_type def create_empty_table( diff --git a/tests/test_nullable_types.py b/tests/test_nullable_types.py new file mode 100644 index 0000000..61c433f --- /dev/null +++ b/tests/test_nullable_types.py @@ -0,0 +1,68 @@ +"""Tests for nullable type handling in to_sql_type.""" + +from clickhouse_sqlalchemy import types as clickhouse_sqlalchemy_types + +from target_clickhouse.connectors import ClickhouseConnector + + +def _to_sql_type(jsonschema_type, is_primary_key=False): + """Call to_sql_type as an unbound method — avoids full connector init.""" + return ClickhouseConnector.to_sql_type( + None, + jsonschema_type, + is_primary_key=is_primary_key, + ) + + +class TestNullableTypeMapping: + """Tests for nullable type wrapping in ClickhouseConnector.to_sql_type.""" + + def test_nullable_bool_returns_nullable(self): + """Nullable bool schema should produce Nullable(Bool).""" + sql_type = _to_sql_type({"type": ["boolean", "null"]}) + assert isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_non_nullable_bool_returns_plain_bool(self): + """Non-nullable bool schema should not be wrapped.""" + sql_type = _to_sql_type({"type": ["boolean"]}) + assert not isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_nullable_integer_returns_nullable(self): + """Nullable integer schema should produce Nullable(Int64).""" + sql_type = _to_sql_type({"type": ["integer", "null"]}) + assert isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_nullable_string_returns_nullable(self): + """Nullable string schema should produce Nullable(String).""" + sql_type = _to_sql_type({"type": ["string", "null"]}) + assert isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_nullable_number_returns_nullable(self): + """Nullable number schema should produce Nullable(Float).""" + sql_type = _to_sql_type({"type": ["number", "null"]}) + assert isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_nullable_datetime_stays_nullable(self): + """Datetime was already wrapped — should not double-wrap.""" + sql_type = _to_sql_type( + {"type": ["string", "null"], "format": "date-time"}, + ) + assert isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_primary_key_not_nullable(self): + """Primary key columns should never be Nullable.""" + sql_type = _to_sql_type( + {"type": ["boolean", "null"]}, + is_primary_key=True, + ) + assert not isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable) + + def test_non_nullable_integer_returns_int64(self): + """Non-nullable integer should produce Int64.""" + sql_type = _to_sql_type({"type": ["integer"]}) + assert isinstance(sql_type, clickhouse_sqlalchemy_types.Int64) + + def test_string_type_not_list(self): + """Handle case where type is a string, not a list.""" + sql_type = _to_sql_type({"type": "boolean"}) + assert not isinstance(sql_type, clickhouse_sqlalchemy_types.Nullable)