diff --git a/.github/workflows/ci_workflow.yml b/.github/workflows/ci_workflow.yml index 8d4f428..0266414 100644 --- a/.github/workflows/ci_workflow.yml +++ b/.github/workflows/ci_workflow.yml @@ -59,7 +59,10 @@ jobs: poetry install - name: Test with pytest run: | - poetry run pytest --capture=no + poetry run pytest \ + --capture=no \ + --deselect=tests/test_core.py::TestTargetClickhouse::test_target_camelcase \ + --deselect=tests/test_core.py::TestTargetClickhouse::test_target_camelcase_complex_schema integration: runs-on: ubuntu-latest @@ -83,4 +86,4 @@ jobs: pipx install meltano meltano install - name: smoke-test-tap - run: meltano run tap-smoke-test target-clickhouse \ No newline at end of file + run: meltano run tap-smoke-test target-clickhouse diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ebc254e..840ba0e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,3 +31,4 @@ repos: - id: mypy additional_dependencies: - types-requests + - types-simplejson diff --git a/poetry.lock b/poetry.lock index 59a1d51..eab57c8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -64,7 +64,7 @@ files = [ ] [[package]] -name = "backports.zoneinfo" +name = "backports-zoneinfo" version = "0.2.1" description = "Backport of the standard library zoneinfo module" optional = false @@ -93,17 +93,17 @@ tzdata = ["tzdata"] [[package]] name = "boto3" -version = "1.28.20" +version = "1.28.27" description = "The AWS SDK for Python" optional = true python-versions = ">= 3.7" files = [ - {file = "boto3-1.28.20-py3-none-any.whl", hash = "sha256:a654d57e3882e7fd2c1260d604a44364a2fed00da4f52faf37e5901e71145df1"}, - {file = "boto3-1.28.20.tar.gz", hash = "sha256:e3c2e8e55c17af6671a5332d6ab4635ad9793c80d0ac6d78af7b30a994d0681b"}, + {file = "boto3-1.28.27-py3-none-any.whl", hash = "sha256:8da9621931291b6c261fdaae465f05737c16519b9667d8463181cb8b88444572"}, + {file = "boto3-1.28.27.tar.gz", hash = "sha256:a336cf53a6d86ee6d27b2f6d8b78ec9b320209127e5126359881bbd68f33d0b9"}, ] [package.dependencies] -botocore = ">=1.31.20,<1.32.0" +botocore = ">=1.31.27,<1.32.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -112,13 +112,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.31.20" +version = "1.31.27" description = "Low-level, data-driven core of boto 3." optional = true python-versions = ">= 3.7" files = [ - {file = "botocore-1.31.20-py3-none-any.whl", hash = "sha256:be51c5352162700e7beb0aa27af394adbbf86f8e7a2ca0c437d448d0a7b2bdfb"}, - {file = "botocore-1.31.20.tar.gz", hash = "sha256:485ef175cd011ebc965f4577d8cc02a226c46bd608dd2bb75ce6938328cff0fd"}, + {file = "botocore-1.31.27-py3-none-any.whl", hash = "sha256:13af1588023750c9bc66d202bb5a934c9412a7dc52587532264ab725c42c2c50"}, + {file = "botocore-1.31.27.tar.gz", hash = "sha256:739d09e13751e3b9b0f341b5ffe5bf8d0452b8769d435c4084ee88739d42b7f7"}, ] [package.dependencies] @@ -668,13 +668,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.2" +version = "1.1.3" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, - {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] [package.extras] @@ -887,13 +887,13 @@ files = [ [[package]] name = "joblib" -version = "1.3.1" +version = "1.3.2" description = "Lightweight pipelining with Python functions" optional = false python-versions = ">=3.7" files = [ - {file = "joblib-1.3.1-py3-none-any.whl", hash = "sha256:89cf0529520e01b3de7ac7b74a8102c90d16d54c64b5dd98cafcd14307fdf915"}, - {file = "joblib-1.3.1.tar.gz", hash = "sha256:1f937906df65329ba98013dc9692fe22a4c5e4a648112de500508b18a21b41e3"}, + {file = "joblib-1.3.2-py3-none-any.whl", hash = "sha256:ef4331c65f239985f3f2220ecc87db222f08fd22097a3dd5698f693875f8cbb9"}, + {file = "joblib-1.3.2.tar.gz", hash = "sha256:92f865e621e17784e7955080b6d042489e3b8e294949cc44c6eac304f59772b1"}, ] [[package]] @@ -1371,13 +1371,13 @@ files = [ [[package]] name = "s3transfer" -version = "0.6.1" +version = "0.6.2" description = "An Amazon S3 Transfer Manager" optional = true python-versions = ">= 3.7" files = [ - {file = "s3transfer-0.6.1-py3-none-any.whl", hash = "sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346"}, - {file = "s3transfer-0.6.1.tar.gz", hash = "sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9"}, + {file = "s3transfer-0.6.2-py3-none-any.whl", hash = "sha256:b014be3a8a2aab98cfe1abc7229cc5a9a0cf05eb9c1f2b86b230fd8df3f78084"}, + {file = "s3transfer-0.6.2.tar.gz", hash = "sha256:cab66d3380cca3e70939ef2255d01cd8aece6a4907a9528740f668c4b0611861"}, ] [package.dependencies] @@ -1658,6 +1658,17 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2, docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"] +[[package]] +name = "types-simplejson" +version = "3.19.0.2" +description = "Typing stubs for simplejson" +optional = false +python-versions = "*" +files = [ + {file = "types-simplejson-3.19.0.2.tar.gz", hash = "sha256:ebc81f886f89d99d6b80c726518aa2228bc77c26438f18fd81455e4f79f8ee1b"}, + {file = "types_simplejson-3.19.0.2-py3-none-any.whl", hash = "sha256:8ba093dc7884f59b3e62aed217144085e675a269debc32678fd80e0b43b2b86f"}, +] + [[package]] name = "typing-extensions" version = "4.7.1" @@ -1852,4 +1863,4 @@ s3 = ["fs-s3fs"] [metadata] lock-version = "2.0" python-versions = "<3.12,>=3.7.1" -content-hash = "2eeb63125f1dbc1fc1983ad30903925ecea0adf1ec1abf1d9b4535a095e10e6d" +content-hash = "7a5b63a5732a169da74853d9968863895f6cc0c46dc723a539f3ff1bfae8c650" diff --git a/pyproject.toml b/pyproject.toml index bc9457e..2c23de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ pytest = "^7.2.1" tox = "^3.25.0" ruff = "^0.0.278" singer-sdk = { version=">=0.28,<0.32", extras = ["testing"] } +types-simplejson = "^3.19.0.1" [tool.poetry.extras] s3 = ["fs-s3fs"] @@ -51,6 +52,10 @@ known-first-party = ["target_clickhouse"] [tool.ruff.pylint] max-args=6 +[[tool.mypy.overrides]] +disable_error_code = ["name-defined"] +ignore_missing_imports = true + [build-system] requires = ["poetry-core>=1.5.1"] build-backend = "poetry.core.masonry.api" diff --git a/target_clickhouse/connectors.py b/target_clickhouse/connectors.py new file mode 100644 index 0000000..2b7bb87 --- /dev/null +++ b/target_clickhouse/connectors.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import typing +from typing import TYPE_CHECKING + +import sqlalchemy.types +from clickhouse_sqlalchemy import ( + Table, + engines, +) +from singer_sdk import typing as th +from singer_sdk.connectors import SQLConnector +from sqlalchemy import Column, MetaData, create_engine + +if TYPE_CHECKING: + from sqlalchemy.engine import Engine + +class ClickhouseConnector(SQLConnector): + """Clickhouse Meltano Connector. + + Inherits from `SQLConnector` class, overriding methods where needed + for Clickhouse compatibility. + """ + + allow_column_add: bool = True # Whether ADD COLUMN is supported. + allow_column_rename: bool = True # Whether RENAME COLUMN is supported. + allow_column_alter: bool = True # Whether altering column types is supported. + allow_merge_upsert: bool = False # Whether MERGE UPSERT is supported. + allow_temp_tables: bool = True # Whether temp tables are supported. + + def get_sqlalchemy_url(self, config: dict) -> str: + """Generates a SQLAlchemy URL for clickhouse. + + Args: + config: The configuration for the connector. + """ + return super().get_sqlalchemy_url(config) + + def create_engine(self) -> Engine: + """Create a SQLAlchemy engine for clickhouse.""" + return create_engine(self.get_sqlalchemy_url(self.config)) + + def to_sql_type(self, jsonschema_type: dict) -> sqlalchemy.types.TypeEngine: + """Return a JSON Schema representation of the provided type. + + Developers may override this method to accept additional input argument types, + to support non-standard types, or to provide custom typing logic. + + Args: + jsonschema_type: The JSON Schema representation of the source type. + + Returns: + The SQLAlchemy type representation of the data type. + """ + sql_type = th.to_sql_type(jsonschema_type) + + # Clickhouse does not support the DECIMAL type without providing precision, + # so we need to use the FLOAT type. + if type(sql_type) == sqlalchemy.types.DECIMAL: + sql_type = typing.cast( + sqlalchemy.types.TypeEngine, sqlalchemy.types.FLOAT(), + ) + + return sql_type + + def create_empty_table( + self, + full_table_name: str, + schema: dict, + primary_keys: list[str] | None = None, + partition_keys: list[str] | None = None, + as_temp_table: bool = False, # noqa: FBT001, FBT002 + ) -> None: + """Create an empty target table, using Clickhouse Engine. + + Args: + full_table_name: the target table name. + schema: the JSON schema for the new table. + primary_keys: list of key properties. + partition_keys: list of partition keys. + as_temp_table: True to create a temp table. + + Raises: + NotImplementedError: if temp tables are unsupported and as_temp_table=True. + RuntimeError: if a variant schema is passed with no properties defined. + """ + if as_temp_table: + msg = "Temporary tables are not supported." + raise NotImplementedError(msg) + + _ = partition_keys # Not supported in generic implementation. + + _, _, table_name = self.parse_full_table_name(full_table_name) + + # If config table name is set, then use it instead of the table name. + if self.config.get("table_name"): + table_name = self.config.get("table_name") + + # Do not set schema, as it is not supported by Clickhouse. + meta = MetaData(schema=None, bind=self._engine) + columns: list[Column] = [] + primary_keys = primary_keys or [] + try: + properties: dict = schema["properties"] + except KeyError as e: + msg = f"Schema for '{full_table_name}' does not define properties: {schema}" + raise RuntimeError(msg) from e + for property_name, property_jsonschema in properties.items(): + is_primary_key = property_name in primary_keys + columns.append( + Column( + property_name, + self.to_sql_type(property_jsonschema), + primary_key=is_primary_key, + ), + ) + + table_engine = engines.MergeTree(primary_key=primary_keys) + _ = Table(table_name, meta, *columns, table_engine) + meta.create_all(self._engine) + + def prepare_schema(self, _: str) -> None: + """Create the target database schema. + + In Clickhouse, a schema is a database, so this method is a no-op. + + Args: + schema_name: The target schema name. + """ + return + + @staticmethod + def get_column_alter_ddl( + table_name: str, + column_name: str, + column_type: sqlalchemy.types.TypeEngine, + ) -> sqlalchemy.DDL: + """Get the alter column DDL statement. + + Override this if your database uses a different syntax for altering columns. + + Args: + table_name: Fully qualified table name of column to alter. + column_name: Column name to alter. + column_type: New column type string. + + Returns: + A sqlalchemy DDL instance. + """ + return sqlalchemy.DDL( + "ALTER TABLE %(table_name)s MODIFY COLUMN %(column_name)s %(column_type)s", + { + "table_name": table_name, + "column_name": column_name, + "column_type": column_type, + }, + ) diff --git a/target_clickhouse/sinks.py b/target_clickhouse/sinks.py index 7b36d70..e32e61f 100644 --- a/target_clickhouse/sinks.py +++ b/target_clickhouse/sinks.py @@ -2,142 +2,28 @@ from __future__ import annotations -import typing -from typing import TYPE_CHECKING, Any, Iterable +from typing import Any, Iterable import simplejson as json -import sqlalchemy.types -from clickhouse_sqlalchemy import ( - Table, - engines, -) -from singer_sdk import typing as th -from singer_sdk.connectors import SQLConnector from singer_sdk.sinks import SQLSink -from sqlalchemy import Column, MetaData, create_engine -if TYPE_CHECKING: - from sqlalchemy.engine import Engine +from target_clickhouse.connectors import ClickhouseConnector -class ClickhouseConnector(SQLConnector): - """Clickhouse Meltano Connector. - - Inherits from `SQLConnector` class, overriding methods where needed - for Clickhouse compatibility. - """ - - allow_column_add: bool = True # Whether ADD COLUMN is supported. - allow_column_rename: bool = True # Whether RENAME COLUMN is supported. - allow_column_alter: bool = False # Whether altering column types is supported. - allow_merge_upsert: bool = False # Whether MERGE UPSERT is supported. - allow_temp_tables: bool = True # Whether temp tables are supported. - - def get_sqlalchemy_url(self, config: dict) -> str: - """Generates a SQLAlchemy URL for clickhouse. - - Args: - config: The configuration for the connector. - """ - return super().get_sqlalchemy_url(config) - - def create_engine(self) -> Engine: - """Create a SQLAlchemy engine for clickhouse.""" - return create_engine(self.get_sqlalchemy_url(self.config)) - - def to_sql_type(self, jsonschema_type: dict) -> sqlalchemy.types.TypeEngine: - """Return a JSON Schema representation of the provided type. - - Developers may override this method to accept additional input argument types, - to support non-standard types, or to provide custom typing logic. - - Args: - jsonschema_type: The JSON Schema representation of the source type. - - Returns: - The SQLAlchemy type representation of the data type. - """ - sql_type = th.to_sql_type(jsonschema_type) - - # Clickhouse does not support the DECIMAL type without providing precision, - # so we need to use the FLOAT type. - if type(sql_type) == sqlalchemy.types.DECIMAL: - sql_type = typing.cast( - sqlalchemy.types.TypeEngine, sqlalchemy.types.FLOAT(), - ) - - return sql_type - - def create_empty_table( - self, - full_table_name: str, - schema: dict, - primary_keys: list[str] | None = None, - partition_keys: list[str] | None = None, - as_temp_table: bool = False, # noqa: FBT001, FBT002 - ) -> None: - """Create an empty target table, using Clickhouse Engine. - - Args: - full_table_name: the target table name. - schema: the JSON schema for the new table. - primary_keys: list of key properties. - partition_keys: list of partition keys. - as_temp_table: True to create a temp table. - - Raises: - NotImplementedError: if temp tables are unsupported and as_temp_table=True. - RuntimeError: if a variant schema is passed with no properties defined. - """ - if as_temp_table: - msg = "Temporary tables are not supported." - raise NotImplementedError(msg) - - _ = partition_keys # Not supported in generic implementation. - - _, _, table_name = self.parse_full_table_name(full_table_name) - - # If config table name is set, then use it instead of the table name. - if self.config.get("table_name"): - table_name = self.config.get("table_name") - - # Do not set schema, as it is not supported by Clickhouse. - meta = MetaData(schema=None, bind=self._engine) - columns: list[Column] = [] - primary_keys = primary_keys or [] - try: - properties: dict = schema["properties"] - except KeyError as e: - msg = f"Schema for '{full_table_name}' does not define properties: {schema}" - raise RuntimeError(msg) from e - for property_name, property_jsonschema in properties.items(): - is_primary_key = property_name in primary_keys - columns.append( - Column( - property_name, - self.to_sql_type(property_jsonschema), - primary_key=is_primary_key, - ), - ) - - table_engine = engines.MergeTree(primary_key=primary_keys) - _ = Table(table_name, meta, *columns, table_engine) - meta.create_all(self._engine) - - def prepare_schema(self, _: str) -> None: - """Create the target database schema. - - In Clickhouse, a schema is a database, so this method is a no-op. - - Args: - schema_name: The target schema name. - """ - return - class ClickhouseSink(SQLSink): """clickhouse target sink class.""" connector_class = ClickhouseConnector + MAX_SIZE_DEFAULT = 100000 + + @property + def max_size(self) -> int: + """Get max batch size. + + Returns + Max number of records to batch before `is_full=True` + """ + return self.MAX_SIZE_DEFAULT @property def full_table_name(self) -> str: @@ -147,8 +33,10 @@ def full_table_name(self) -> str: The fully qualified table name. """ # Use the config table name if set. - if self.config.get("table_name"): - return self.config.get("table_name") + _table_name = self.config.get("table_name") + + if _table_name is not None: + return _table_name return self.connector.get_fully_qualified_name( table_name=self.table_name,