From 0cb32e1f6b96d36477d8084bc0e981b059092983 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 12:38:18 +0800 Subject: [PATCH 1/7] feat: add duckdb data source with v3 metadata API support - Add DataSource.duckdb enum value and LocalFileConnectionInfo mapping - Route duckdb through DuckDBConnector and DuckDBMetadata in factory - Add GET /v3/connector/duckdb/metadata/constraints endpoint - Add duckdb pytest marker - Add duckdb connector tests (query + metadata) with jaffle_shop fixture Co-Authored-By: Claude Sonnet 4.6 --- ibis-server/app/model/__init__.py | 4 + ibis-server/app/model/connector.py | 1 + ibis-server/app/model/data_source.py | 7 +- ibis-server/app/model/metadata/factory.py | 1 + ibis-server/app/routers/v3/connector.py | 31 +++- ibis-server/pyproject.toml | 1 + .../tests/resource/duckdb/jaffle_shop.duckdb | Bin 0 -> 2371584 bytes .../routers/v3/connector/duckdb/__init__.py | 0 .../routers/v3/connector/duckdb/conftest.py | 25 ++++ .../v3/connector/duckdb/test_metadata.py | 44 ++++++ .../routers/v3/connector/duckdb/test_query.py | 137 ++++++++++++++++++ 11 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 ibis-server/tests/resource/duckdb/jaffle_shop.duckdb create mode 100644 ibis-server/tests/routers/v3/connector/duckdb/__init__.py create mode 100644 ibis-server/tests/routers/v3/connector/duckdb/conftest.py create mode 100644 ibis-server/tests/routers/v3/connector/duckdb/test_metadata.py create mode 100644 ibis-server/tests/routers/v3/connector/duckdb/test_query.py diff --git a/ibis-server/app/model/__init__.py b/ibis-server/app/model/__init__.py index af9fadefd..6f7692075 100644 --- a/ibis-server/app/model/__init__.py +++ b/ibis-server/app/model/__init__.py @@ -99,6 +99,10 @@ class QueryLocalFileDTO(QueryDTO): connection_info: LocalFileConnectionInfo = connection_info_field +class QueryDuckDBDTO(QueryDTO): + connection_info: LocalFileConnectionInfo = connection_info_field + + class QueryS3FileDTO(QueryDTO): connection_info: S3FileConnectionInfo = connection_info_field diff --git a/ibis-server/app/model/connector.py b/ibis-server/app/model/connector.py index c86ff6877..be9f52ee8 100644 --- a/ibis-server/app/model/connector.py +++ b/ibis-server/app/model/connector.py @@ -92,6 +92,7 @@ def __init__(self, data_source: DataSource, connection_info: ConnectionInfo): DataSource.s3_file, DataSource.minio_file, DataSource.gcs_file, + DataSource.duckdb, }: self._connector = DuckDBConnector(connection_info) elif data_source == DataSource.redshift: diff --git a/ibis-server/app/model/data_source.py b/ibis-server/app/model/data_source.py index c30aa8827..beb6b00a9 100644 --- a/ibis-server/app/model/data_source.py +++ b/ibis-server/app/model/data_source.py @@ -37,6 +37,7 @@ QueryClickHouseDTO, QueryDatabricksDTO, QueryDTO, + QueryDuckDBDTO, QueryGcsFileDTO, QueryLocalFileDTO, QueryMinioFileDTO, @@ -78,6 +79,7 @@ class DataSource(StrEnum): s3_file = auto() minio_file = auto() gcs_file = auto() + duckdb = auto() spark = auto() databricks = auto() @@ -179,6 +181,8 @@ def _build_connection_info(self, data: dict) -> ConnectionInfo: return SnowflakeConnectionInfo.model_validate(data) case DataSource.trino: return TrinoConnectionInfo.model_validate(data) + case DataSource.duckdb: + return LocalFileConnectionInfo.model_validate(data) case DataSource.local_file: return LocalFileConnectionInfo.model_validate(data) case DataSource.s3_file: @@ -242,6 +246,7 @@ class DataSourceExtension(Enum): snowflake = QuerySnowflakeDTO trino = QueryTrinoDTO local_file = QueryLocalFileDTO + duckdb = QueryDuckDBDTO s3_file = QueryS3FileDTO minio_file = QueryMinioFileDTO gcs_file = QueryGcsFileDTO @@ -256,7 +261,7 @@ def get_connection(self, info: ConnectionInfo) -> BaseBackend: if hasattr(info, "connection_url"): kwargs = info.kwargs if info.kwargs else {} return ibis.connect(info.connection_url.get_secret_value(), **kwargs) - if self.name in {"local_file", "redshift", "spark"}: + if self.name in {"local_file", "redshift", "spark", "duckdb"}: raise NotImplementedError( f"{self.name} connection is not implemented to get ibis backend" ) diff --git a/ibis-server/app/model/metadata/factory.py b/ibis-server/app/model/metadata/factory.py index 4c8d49c6d..7725de9fc 100644 --- a/ibis-server/app/model/metadata/factory.py +++ b/ibis-server/app/model/metadata/factory.py @@ -39,6 +39,7 @@ DataSource.gcs_file: GcsFileMetadata, DataSource.databricks: DatabricksMetadata, DataSource.spark: SparkMetadata, + DataSource.duckdb: DuckDBMetadata, } diff --git a/ibis-server/app/routers/v3/connector.py b/ibis-server/app/routers/v3/connector.py index c44d0d38c..54ac347a2 100644 --- a/ibis-server/app/routers/v3/connector.py +++ b/ibis-server/app/routers/v3/connector.py @@ -32,7 +32,13 @@ from app.model.connector import Connector from app.model.data_source import DataSource from app.model.error import DatabaseTimeoutError -from app.model.metadata.dto import Catalog, MetadataDTO, Table, get_filter_info +from app.model.metadata.dto import ( + Catalog, + Constraint, + MetadataDTO, + Table, + get_filter_info, +) from app.model.metadata.factory import MetadataFactory from app.model.validator import Validator from app.query_cache import QueryCacheManager @@ -42,6 +48,7 @@ append_fallback_context, build_context, execute_dry_run_with_timeout, + execute_get_constraints_with_timeout, execute_get_schema_list_with_timeout, execute_get_table_list_with_timeout, execute_query_with_timeout, @@ -605,3 +612,25 @@ async def get_schema_list( filter_info, dto.table_limit, ) + + +@router.post( + "/{data_source}/metadata/constraints", + response_model=list[Constraint], + description="get the constraints of the specified data source", +) +async def get_constraints( + data_source: DataSource, + dto: MetadataDTO, + headers: Annotated[Headers, Depends(get_wren_headers)], +) -> list[Constraint]: + span_name = f"v3_metadata_constraints_{data_source}" + with tracer.start_as_current_span( + name=span_name, kind=trace.SpanKind.SERVER, context=build_context(headers) + ) as span: + set_attribute(headers, span) + connection_info = data_source.get_connection_info( + resolve_connection_info(dto), dict(headers) + ) + metadata = MetadataFactory.get_metadata(data_source, connection_info) + return await execute_get_constraints_with_timeout(metadata) diff --git a/ibis-server/pyproject.toml b/ibis-server/pyproject.toml index d2bca6f05..94df1eb7c 100644 --- a/ibis-server/pyproject.toml +++ b/ibis-server/pyproject.toml @@ -104,6 +104,7 @@ markers = [ "trino: mark a test as a trino test", "databricks: mark a test as a databricks test", "spark: mark a test as a spark test", + "duckdb: mark a test as a duckdb test", "local_file: mark a test as a local file test", "s3_file: mark a test as a s3 file test", "minio_file: mark a test as a minio file test", diff --git a/ibis-server/tests/resource/duckdb/jaffle_shop.duckdb b/ibis-server/tests/resource/duckdb/jaffle_shop.duckdb new file mode 100644 index 0000000000000000000000000000000000000000..ca7ed9367909dc4099c489a24e0af58287ba1aee GIT binary patch literal 2371584 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 dict[str, str]: + return { + "url": "tests/resource/duckdb", + "format": "duckdb", + } diff --git a/ibis-server/tests/routers/v3/connector/duckdb/test_metadata.py b/ibis-server/tests/routers/v3/connector/duckdb/test_metadata.py new file mode 100644 index 000000000..0f5d32163 --- /dev/null +++ b/ibis-server/tests/routers/v3/connector/duckdb/test_metadata.py @@ -0,0 +1,44 @@ +from tests.routers.v3.connector.duckdb.conftest import base_url + +v3_base_url = base_url + + +async def test_metadata_list_tables(client, connection_info): + response = await client.post( + url=f"{v3_base_url}/metadata/tables", + json={"connectionInfo": connection_info}, + ) + assert response.status_code == 200 + + tables = response.json() + assert len(tables) > 0 + + result = next( + filter(lambda x: x["name"] == "main.customers", tables), + None, + ) + assert result is not None + assert result["primaryKey"] == "" + assert result["properties"] == { + "catalog": "jaffle_shop", + "schema": "main", + "table": "customers", + "path": None, + } + assert len(result["columns"]) > 0 + + customer_id_col = next( + filter(lambda c: c["name"] == "customer_id", result["columns"]), None + ) + assert customer_id_col is not None + assert customer_id_col["nestedColumns"] is None + assert customer_id_col["properties"] is None + + +async def test_metadata_list_constraints(client, connection_info): + response = await client.post( + url=f"{v3_base_url}/metadata/constraints", + json={"connectionInfo": connection_info}, + ) + assert response.status_code == 200 + assert response.json() == [] diff --git a/ibis-server/tests/routers/v3/connector/duckdb/test_query.py b/ibis-server/tests/routers/v3/connector/duckdb/test_query.py new file mode 100644 index 000000000..aac8fb61e --- /dev/null +++ b/ibis-server/tests/routers/v3/connector/duckdb/test_query.py @@ -0,0 +1,137 @@ +import base64 + +import orjson +import pytest + +from tests.routers.v3.connector.duckdb.conftest import base_url + +manifest = { + "catalog": "wren", + "schema": "public", + "models": [ + { + "name": "customers", + "tableReference": { + "catalog": "jaffle_shop", + "schema": "main", + "table": "customers", + }, + "columns": [ + {"name": "customer_id", "type": "integer"}, + {"name": "first_name", "type": "varchar"}, + {"name": "last_name", "type": "varchar"}, + {"name": "first_order", "type": "date"}, + {"name": "most_recent_order", "type": "date"}, + {"name": "number_of_orders", "type": "bigint"}, + {"name": "customer_lifetime_value", "type": "double"}, + ], + "primaryKey": "customer_id", + }, + { + "name": "orders", + "tableReference": { + "catalog": "jaffle_shop", + "schema": "main", + "table": "orders", + }, + "columns": [ + {"name": "order_id", "type": "integer"}, + {"name": "customer_id", "type": "integer"}, + {"name": "order_date", "type": "date"}, + {"name": "status", "type": "varchar"}, + {"name": "amount", "type": "double"}, + ], + "primaryKey": "order_id", + }, + ], + "relationships": [ + { + "name": "CustomersOrders", + "models": ["customers", "orders"], + "joinType": "ONE_TO_MANY", + "condition": '"customers".customer_id = "orders".customer_id', + } + ], +} + + +@pytest.fixture(scope="module") +def manifest_str(): + return base64.b64encode(orjson.dumps(manifest)).decode("utf-8") + + +async def test_query(client, manifest_str, connection_info): + response = await client.post( + f"{base_url}/query", + json={ + "manifestStr": manifest_str, + "sql": 'SELECT customer_id, first_name, last_name FROM "customers" ORDER BY customer_id LIMIT 1', + "connectionInfo": connection_info, + }, + ) + assert response.status_code == 200 + result = response.json() + assert result["columns"] == ["customer_id", "first_name", "last_name"] + assert len(result["data"]) == 1 + assert result["data"][0] == [1, "Michael", "P."] + assert result["dtypes"] == { + "customer_id": "int32", + "first_name": "string", + "last_name": "string", + } + + +async def test_query_with_limit(client, manifest_str, connection_info): + response = await client.post( + f"{base_url}/query", + params={"limit": 1}, + json={ + "manifestStr": manifest_str, + "sql": 'SELECT * FROM "customers" LIMIT 5', + "connectionInfo": connection_info, + }, + ) + assert response.status_code == 200 + result = response.json() + assert len(result["data"]) == 1 + + +async def test_query_orders(client, manifest_str, connection_info): + response = await client.post( + f"{base_url}/query", + json={ + "manifestStr": manifest_str, + "sql": 'SELECT order_id, customer_id, status, amount FROM "orders" ORDER BY order_id LIMIT 1', + "connectionInfo": connection_info, + }, + ) + assert response.status_code == 200 + result = response.json() + assert result["columns"] == ["order_id", "customer_id", "status", "amount"] + assert len(result["data"]) == 1 + assert result["data"][0] == [1, 1, "returned", 10.0] + + +async def test_dry_run(client, manifest_str, connection_info): + response = await client.post( + f"{base_url}/query", + params={"dryRun": True}, + json={ + "manifestStr": manifest_str, + "sql": 'SELECT * FROM "customers" LIMIT 1', + "connectionInfo": connection_info, + }, + ) + assert response.status_code == 204 + + response = await client.post( + f"{base_url}/query", + params={"dryRun": True}, + json={ + "manifestStr": manifest_str, + "sql": 'SELECT * FROM "NotFound" LIMIT 1', + "connectionInfo": connection_info, + }, + ) + assert response.status_code == 422 + assert response.text is not None From 79a0b4512f7ec5a254b5c286d52787226746dca9 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 12:44:57 +0800 Subject: [PATCH 2/7] fix: resolve CodeQL path-injection alerts in resolve_connection_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct the candidate path by joining the trusted allowed_root with the user-supplied value before resolving, matching the CodeQL-recognised safe pattern (join trusted base + user input → normalize → startswith check). Previously the path was resolved directly from user input, so CodeQL's taint tracker still flagged open(path) even though the startswith guard was present. Co-Authored-By: Claude Sonnet 4.6 --- ibis-server/app/util.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ibis-server/app/util.py b/ibis-server/app/util.py index d5bf61a61..fb0d5ca9a 100644 --- a/ibis-server/app/util.py +++ b/ibis-server/app/util.py @@ -79,8 +79,12 @@ def resolve_connection_info(dto) -> dict: "connectionFilePath requires the CONNECTION_FILE_ROOT environment variable to be set", ) allowed_root_resolved = str(pathlib.Path(allowed_root).resolve()) - path = pathlib.Path(dto.connection_file_path).resolve() - # Explicit string prefix check — recognized by static analysis as a path sanitizer + # Construct path by joining with the trusted root first, then normalize. + # This follows the CodeQL-recognized safe pattern: join trusted base with + # user input, normalize, then check the prefix. When user supplies an + # absolute path, pathlib / keeps it absolute (same as os.path.join), so + # the startswith guard still catches escapes. + path = (pathlib.Path(allowed_root_resolved) / dto.connection_file_path).resolve() if ( not str(path).startswith(allowed_root_resolved + os.sep) and str(path) != allowed_root_resolved From 6a4617e2995af2ede76f553cf723eb0b4c501512 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 12:48:51 +0800 Subject: [PATCH 3/7] Potential fix for code scanning alert no. 47: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- ibis-server/app/util.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ibis-server/app/util.py b/ibis-server/app/util.py index fb0d5ca9a..c9aa9e6ee 100644 --- a/ibis-server/app/util.py +++ b/ibis-server/app/util.py @@ -78,17 +78,16 @@ def resolve_connection_info(dto) -> dict: ErrorCode.INVALID_CONNECTION_INFO, "connectionFilePath requires the CONNECTION_FILE_ROOT environment variable to be set", ) - allowed_root_resolved = str(pathlib.Path(allowed_root).resolve()) + allowed_root_path = pathlib.Path(allowed_root).resolve() # Construct path by joining with the trusted root first, then normalize. # This follows the CodeQL-recognized safe pattern: join trusted base with - # user input, normalize, then check the prefix. When user supplies an - # absolute path, pathlib / keeps it absolute (same as os.path.join), so - # the startswith guard still catches escapes. - path = (pathlib.Path(allowed_root_resolved) / dto.connection_file_path).resolve() - if ( - not str(path).startswith(allowed_root_resolved + os.sep) - and str(path) != allowed_root_resolved - ): + # user input, normalize, then ensure the final path is still inside the + # trusted root directory. + path = (allowed_root_path / dto.connection_file_path).resolve() + try: + # Raises ValueError if 'path' is not inside 'allowed_root_path' + path.relative_to(allowed_root_path) + except ValueError: raise WrenError( ErrorCode.INVALID_CONNECTION_INFO, f"Connection file path is outside the allowed directory: {dto.connection_file_path}", From 6a105efecd9bd693a6137ce6fc8be290ca222afa Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 12:52:06 +0800 Subject: [PATCH 4/7] Potential fix for code scanning alert no. 48: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- ibis-server/app/util.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ibis-server/app/util.py b/ibis-server/app/util.py index c9aa9e6ee..573313a93 100644 --- a/ibis-server/app/util.py +++ b/ibis-server/app/util.py @@ -83,17 +83,18 @@ def resolve_connection_info(dto) -> dict: # This follows the CodeQL-recognized safe pattern: join trusted base with # user input, normalize, then ensure the final path is still inside the # trusted root directory. - path = (allowed_root_path / dto.connection_file_path).resolve() + user_path = pathlib.Path(dto.connection_file_path) + resolved_path = (allowed_root_path / user_path).resolve() try: - # Raises ValueError if 'path' is not inside 'allowed_root_path' - path.relative_to(allowed_root_path) + # Raises ValueError if 'resolved_path' is not inside 'allowed_root_path' + resolved_path.relative_to(allowed_root_path) except ValueError: raise WrenError( ErrorCode.INVALID_CONNECTION_INFO, f"Connection file path is outside the allowed directory: {dto.connection_file_path}", ) try: - with open(path) as f: + with open(resolved_path) as f: return _normalize_port(json.load(f)) except FileNotFoundError: raise WrenError( From b6d58b91e50d19b2d032f4297109c8e84fabc434 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 12:58:31 +0800 Subject: [PATCH 5/7] fix: use os.path.normpath(join) pattern for CodeQL path-injection compliance Switch from pathlib to os.path module to exactly match the CodeQL-documented safe pattern: os.path.normpath(os.path.join(trusted_root, user_input)) with a simple startswith guard. The previous pathlib / operator and relative_to() check were not recognised as sanitisers by CodeQL's taint tracker. Also wraps with realpath to prevent symlink escape. Removes now-unused pathlib import. Co-Authored-By: Claude Sonnet 4.6 --- ibis-server/app/util.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/ibis-server/app/util.py b/ibis-server/app/util.py index 573313a93..f1e3ea0e1 100644 --- a/ibis-server/app/util.py +++ b/ibis-server/app/util.py @@ -1,7 +1,6 @@ import asyncio import base64 import json -import pathlib import time try: @@ -78,23 +77,23 @@ def resolve_connection_info(dto) -> dict: ErrorCode.INVALID_CONNECTION_INFO, "connectionFilePath requires the CONNECTION_FILE_ROOT environment variable to be set", ) - allowed_root_path = pathlib.Path(allowed_root).resolve() - # Construct path by joining with the trusted root first, then normalize. - # This follows the CodeQL-recognized safe pattern: join trusted base with - # user input, normalize, then ensure the final path is still inside the - # trusted root directory. - user_path = pathlib.Path(dto.connection_file_path) - resolved_path = (allowed_root_path / user_path).resolve() - try: - # Raises ValueError if 'resolved_path' is not inside 'allowed_root_path' - resolved_path.relative_to(allowed_root_path) - except ValueError: + # Resolve the trusted root (no user input involved) + allowed_root_str = os.path.realpath(allowed_root) + # Build the candidate path by joining the trusted root with the user + # value, then normalise. Using os.path.normpath(os.path.join(base, user)) + # is the pattern recognised by CodeQL as safe for path-injection checks. + # realpath additionally resolves symlinks so a symlink inside the allowed + # root cannot escape to a file outside it. + fullpath = os.path.realpath( + os.path.normpath(os.path.join(allowed_root_str, dto.connection_file_path)) + ) + if not fullpath.startswith(allowed_root_str + os.sep): raise WrenError( ErrorCode.INVALID_CONNECTION_INFO, f"Connection file path is outside the allowed directory: {dto.connection_file_path}", ) try: - with open(resolved_path) as f: + with open(fullpath) as f: return _normalize_port(json.load(f)) except FileNotFoundError: raise WrenError( From 7a9b950c94e11a25b8520f2734c2c1909e266acf Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 13:56:43 +0800 Subject: [PATCH 6/7] fix: avoid double separator in root prefix when allowed root is filesystem root When CONNECTION_FILE_ROOT=/, allowed_root_str is "/" and appending os.sep produced "//" which would never match any real path. Build the prefix once, adding the separator only if the root string does not already end with one. Co-Authored-By: Claude Sonnet 4.6 --- ibis-server/app/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ibis-server/app/util.py b/ibis-server/app/util.py index f1e3ea0e1..f87c10473 100644 --- a/ibis-server/app/util.py +++ b/ibis-server/app/util.py @@ -87,7 +87,8 @@ def resolve_connection_info(dto) -> dict: fullpath = os.path.realpath( os.path.normpath(os.path.join(allowed_root_str, dto.connection_file_path)) ) - if not fullpath.startswith(allowed_root_str + os.sep): + root_prefix = allowed_root_str if allowed_root_str.endswith(os.sep) else allowed_root_str + os.sep + if not fullpath.startswith(root_prefix): raise WrenError( ErrorCode.INVALID_CONNECTION_INFO, f"Connection file path is outside the allowed directory: {dto.connection_file_path}", From 58b5ee4fda2d1e25cb9bc56917af7b5bc29232c8 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Tue, 10 Mar 2026 13:58:24 +0800 Subject: [PATCH 7/7] chore: fix fmt --- ibis-server/app/util.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ibis-server/app/util.py b/ibis-server/app/util.py index f87c10473..ba96ba7c0 100644 --- a/ibis-server/app/util.py +++ b/ibis-server/app/util.py @@ -87,7 +87,11 @@ def resolve_connection_info(dto) -> dict: fullpath = os.path.realpath( os.path.normpath(os.path.join(allowed_root_str, dto.connection_file_path)) ) - root_prefix = allowed_root_str if allowed_root_str.endswith(os.sep) else allowed_root_str + os.sep + root_prefix = ( + allowed_root_str + if allowed_root_str.endswith(os.sep) + else allowed_root_str + os.sep + ) if not fullpath.startswith(root_prefix): raise WrenError( ErrorCode.INVALID_CONNECTION_INFO,