"""Regression coverage for openGauss/Dolphin through the MySQL protocol.""" from __future__ import annotations import asyncio import json from types import SimpleNamespace import pytest from sqlalchemy import Column, Integer, MetaData, String, Table from deerflow.persistence.gauss_mysql_compat import ( GAUSS_MYSQL_SQLALCHEMY_VERSION, _gauss_mysql_default_schema_name, _gauss_mysql_has_table, _gauss_mysql_isolation_level, _gauss_mysql_server_version_info, decode_gauss_mysql_json, install_gauss_mysql_dialect_compatibility, is_gauss_mysql_product, ) from deerflow.persistence.gauss_schema import ensure_gauss_schema_sync from deerflow.persistence.types import PortableJSON def _mysql_escape(value: str) -> str: table = { "\0": "\\0", "\\": "\\\\", "\n": "\\n", "\r": "\\r", "\x1a": "\\Z", '"': '\\"', "'": "\\'", } return "".join(table.get(character, character) for character in value) def test_product_switch_is_explicit_and_case_insensitive(monkeypatch) -> None: monkeypatch.delenv("DEERFLOW_DATABASE_PRODUCT", raising=False) assert not is_gauss_mysql_product() monkeypatch.setenv("DEERFLOW_DATABASE_PRODUCT", "mysql") assert not is_gauss_mysql_product() monkeypatch.setenv("DEERFLOW_DATABASE_PRODUCT", " GAUSS_MYSQL ") assert is_gauss_mysql_product() def test_json_decoder_accepts_strict_and_bounded_legacy_formats() -> None: strict = {"name": "中文", "enabled": True, "items": [1, None]} assert decode_gauss_mysql_json(json.dumps(strict, ensure_ascii=False)) == strict assert decode_gauss_mysql_json("{'name': 'legacy', 'enabled': True}") == { "name": "legacy", "enabled": True, } escaped = _mysql_escape(json.dumps(["web", "file:read", "bash"])) assert decode_gauss_mysql_json(escaped) == ["web", "file:read", "bash"] assert decode_gauss_mysql_json(memoryview(escaped.encode())) == ["web", "file:read", "bash"] @pytest.mark.parametrize( "value", [ r"[\"web\",", r"[\"bad\q\"]", r'[\"a\", "b"]', "__import__('os').system('echo unsafe')", "{1, 2}", "(1, 2)", "{'nested': {1, 2}}", "{'nested': (1, 2)}", "{1: 'non-string key'}", "{'nested': b'bytes'}", ], ) def test_json_decoder_rejects_malformed_or_unsafe_values(value: str) -> None: with pytest.raises(ValueError): decode_gauss_mysql_json(value) def test_portable_json_fallback_is_dolphin_only() -> None: ordinary_mysql = SimpleNamespace(name="mysql") dolphin = SimpleNamespace(name="mysql", _deerflow_gauss_mysql_compat_installed=True) column_type = PortableJSON() with pytest.raises(json.JSONDecodeError): column_type.process_result_value("{'enabled': True}", ordinary_mysql) assert column_type.process_result_value("{'enabled': True}", dolphin) == {"enabled": True} def test_dialect_helpers_use_current_schema_version_and_isolation() -> None: class Cursor: def __init__(self, row): self.row = row self.statement = None self.closed = False def execute(self, statement): self.statement = statement def fetchone(self): return self.row def close(self): self.closed = True version_cursor = Cursor(("openGauss 6.0.5",)) dialect = SimpleNamespace( _set_mariadb=lambda *_args: None, server_version_info=None, ) connection = SimpleNamespace(connection=SimpleNamespace(cursor=lambda: version_cursor)) assert _gauss_mysql_server_version_info(dialect, connection) == GAUSS_MYSQL_SQLALCHEMY_VERSION assert dialect.server_version_info == GAUSS_MYSQL_SQLALCHEMY_VERSION assert version_cursor.statement == "SELECT VERSION()" assert version_cursor.closed isolation_cursor = Cursor(("read-committed",)) dbapi = SimpleNamespace(cursor=lambda: isolation_cursor) assert _gauss_mysql_isolation_level(dialect, dbapi) == "READ COMMITTED" assert isolation_cursor.statement == "SELECT @@transaction_isolation" class ScalarResult: def scalar(self): return b"deerflow" class SchemaConnection: def exec_driver_sql(self, statement): assert statement == "SELECT current_schema()" return ScalarResult() assert _gauss_mysql_default_schema_name(dialect, SchemaConnection()) == "deerflow" def test_has_table_uses_native_catalog_instead_of_describe() -> None: observed = {} class Result: def __enter__(self): return self def __exit__(self, *_args): return False def fetchone(self): return (1,) class Connection: def exec_driver_sql(self, statement, parameters, execution_options): observed.update( statement=statement, parameters=parameters, execution_options=execution_options, ) return Result() dialect = SimpleNamespace( default_schema_name="deerflow", _ensure_has_table_connection=lambda _connection: None, ) assert _gauss_mysql_has_table(dialect, Connection(), "users") assert "pg_catalog.pg_class" in observed["statement"] assert "DESCRIBE" not in observed["statement"].upper() assert observed["parameters"] == ("deerflow", "users") def test_installer_is_driver_scoped_and_idempotent(monkeypatch) -> None: from sqlalchemy.dialects.mysql import asyncmy as asyncmy_module class FakeDialect: calls = 0 def create_connect_args(self, _url): type(self).calls += 1 return [], {"charset": "utf8mb4"} monkeypatch.setattr(asyncmy_module, "MySQLDialect_asyncmy", FakeDialect) install_gauss_mysql_dialect_compatibility("asyncmy") first_original = FakeDialect._deerflow_gauss_mysql_original_create_connect_args install_gauss_mysql_dialect_compatibility("asyncmy") _args, kwargs = FakeDialect().create_connect_args(object()) assert kwargs["charset"] == "utf8mb4" assert kwargs["init_command"] == "SET standard_conforming_strings TO off" assert FakeDialect.calls == 1 assert FakeDialect._deerflow_gauss_mysql_original_create_connect_args is first_original assert FakeDialect._deerflow_gauss_mysql_compat_installed is True def test_installer_rejects_conflicting_init_command(monkeypatch) -> None: from sqlalchemy.dialects.mysql import asyncmy as asyncmy_module class FakeDialect: def create_connect_args(self, _url): return [], {"init_command": "SET time_zone = '+00:00'"} monkeypatch.setattr(asyncmy_module, "MySQLDialect_asyncmy", FakeDialect) install_gauss_mysql_dialect_compatibility("asyncmy") with pytest.raises(ValueError, match="standard_conforming_strings"): FakeDialect().create_connect_args(object()) class _RowsResult: def __init__(self, *, scalar=None, rows=()): self._scalar = scalar self._rows = list(rows) def scalar_one_or_none(self): return self._scalar def fetchall(self): return list(self._rows) def test_schema_integrity_preflights_all_columns_before_ddl() -> None: metadata = MetaData() Table( "example", metadata, Column("id", Integer, primary_key=True), Column("safe_note", String(20), nullable=True), Column("unsafe_required", String(20), nullable=False), ) class Connection: engine = SimpleNamespace(url=SimpleNamespace(database="deerflow")) def __init__(self): self.statements = [] def exec_driver_sql(self, statement, parameters=None): self.statements.append((statement, parameters)) if statement == "SELECT current_schema()": return _RowsResult(scalar="deerflow") if "pg_catalog.pg_attribute" in statement: return _RowsResult(rows=[("example", "r", "id")]) raise AssertionError(f"DDL must not run after unsafe preflight: {statement}") connection = Connection() with pytest.raises(RuntimeError, match="NOT NULL column has no server_default"): ensure_gauss_schema_sync(connection, metadata) assert not any(statement.lstrip().startswith("ALTER TABLE") for statement, _ in connection.statements) def test_active_feature_models_are_registered_without_enterprise_research() -> None: import deerflow.persistence.models # noqa: F401 from deerflow.persistence.base import Base expected = { "ai_writing_sessions", "ai_writing_article_types", "scheduled_html_pages", "html_page_favorites", "task_analysis_tasks", "task_analysis_settings", } assert expected <= set(Base.metadata.tables) assert "enterprise_research_tasks" not in Base.metadata.tables @pytest.mark.asyncio @pytest.mark.parametrize( ("product", "expects_gauss"), [("mysql", False), ("gauss_mysql", True)], ) async def test_engine_selects_dolphin_hooks_without_changing_mysql( monkeypatch, product: str, expects_gauss: bool, ) -> None: import deerflow.persistence.engine as engine_module import deerflow.persistence.gauss_mysql_compat as compat_module calls: list[str] = [] class Connection: async def run_sync(self, function, *_args): calls.append(function.__name__) class Begin: async def __aenter__(self): return Connection() async def __aexit__(self, *_args): return False class Engine: dialect = SimpleNamespace(name="mysql") def begin(self): return Begin() async def dispose(self): calls.append("dispose") fake_engine = Engine() monkeypatch.setenv("DEERFLOW_DATABASE_PRODUCT", product) monkeypatch.setattr(engine_module, "find_spec", lambda _name: object()) monkeypatch.setattr(engine_module, "_add_runtime_vendor_path", lambda: None) monkeypatch.setattr(engine_module, "create_async_engine", lambda *_args, **_kwargs: fake_engine) monkeypatch.setattr(engine_module, "_acquire_gauss_mysql_schema_bootstrap_lock", lambda: None) monkeypatch.setattr(engine_module, "_release_gauss_mysql_schema_bootstrap_lock", lambda _handle: None) monkeypatch.setattr( compat_module, "install_gauss_mysql_dialect_compatibility", lambda driver: calls.append(f"install:{driver}"), ) await engine_module.close_engine() try: await engine_module.init_engine( "mysql", url="mysql+asyncmy://user:pass@db.example/deerflow", ) finally: await engine_module.close_engine() assert ("install:asyncmy" in calls) is expects_gauss assert ("ensure_gauss_schema_sync" in calls) is expects_gauss assert ("_ensure_orm_columns_sync" in calls) is (not expects_gauss) assert ("_backfill_thread_meta_task_projections_sync" in calls) is (not expects_gauss) @pytest.mark.asyncio async def test_dolphin_unknown_schema_never_runs_mysql_create_database(monkeypatch) -> None: import deerflow.persistence.engine as engine_module import deerflow.persistence.gauss_mysql_compat as compat_module calls: list[str] = [] class Connection: async def run_sync(self, function, *_args): if function.__name__ == "create_all": raise RuntimeError("1049 unknown database") class Begin: async def __aenter__(self): return Connection() async def __aexit__(self, *_args): return False class Engine: dialect = SimpleNamespace(name="mysql") def begin(self): return Begin() async def dispose(self): return None monkeypatch.setenv("DEERFLOW_DATABASE_PRODUCT", "gauss_mysql") monkeypatch.setattr(engine_module, "find_spec", lambda _name: object()) monkeypatch.setattr(engine_module, "_add_runtime_vendor_path", lambda: None) monkeypatch.setattr(engine_module, "create_async_engine", lambda *_args, **_kwargs: Engine()) monkeypatch.setattr(engine_module, "_acquire_gauss_mysql_schema_bootstrap_lock", lambda: None) monkeypatch.setattr(engine_module, "_release_gauss_mysql_schema_bootstrap_lock", lambda _handle: None) monkeypatch.setattr( engine_module, "_auto_create_mysql_db", lambda _url: calls.append("mysql-create-database"), ) monkeypatch.setattr(compat_module, "install_gauss_mysql_dialect_compatibility", lambda _driver: None) await engine_module.close_engine() try: # Startup remains best-effort by repository invariant, but the MySQL # CREATE DATABASE retry must not be attempted against Dolphin. await engine_module.init_engine( "mysql", url="mysql+asyncmy://user:pass@db.example/deerflow", ) finally: await engine_module.close_engine() assert "mysql-create-database" not in calls @pytest.mark.asyncio async def test_dolphin_analytics_skips_mysql_session_variables(monkeypatch) -> None: from app.gateway.routers.admin_users import _set_analytics_read_timeout class Session: async def connection(self): raise AssertionError("Dolphin must return before touching MySQL session variables") monkeypatch.setenv("DEERFLOW_DATABASE_PRODUCT", "gauss_mysql") await _set_analytics_read_timeout(Session()) @pytest.mark.asyncio async def test_skill_display_table_accepts_only_duplicate_create_race(monkeypatch) -> None: import app.gateway.routers.skills as skills_router class DuplicateBegin: async def __aenter__(self): raise RuntimeError('relation "skill_display_adapters" already exists') async def __aexit__(self, *_args): return False class DuplicateEngine: def begin(self): return DuplicateBegin() duplicate_engine = DuplicateEngine() monkeypatch.setattr(skills_router, "_skill_display_table_engine", None) monkeypatch.setattr(skills_router, "_SKILL_DISPLAY_TABLE_LOCK", asyncio.Lock()) monkeypatch.setattr(skills_router, "get_engine", lambda: duplicate_engine) monkeypatch.setattr(skills_router, "get_session_factory", lambda: object()) assert await skills_router._ensure_skill_display_table() assert skills_router._skill_display_table_engine is duplicate_engine assert skills_router._skill_display_table_already_exists(RuntimeError("error 1050")) assert not skills_router._skill_display_table_already_exists(RuntimeError("permission denied"))