|
| 1 | +# ============================================================================= |
| 2 | +# MIT License |
| 3 | +# Copyright (c) 2026 Aparavi Software AG |
| 4 | +# |
| 5 | +# Permission is hereby granted, free of charge, to any person obtaining a copy |
| 6 | +# of this software and associated documentation files (the "Software"), to deal |
| 7 | +# in the Software without restriction, including without limitation the rights |
| 8 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 9 | +# copies of the Software, and to permit persons to whom the Software is |
| 10 | +# furnished to do so, subject to the following conditions: |
| 11 | +# |
| 12 | +# The above copyright notice and this permission notice shall be included in |
| 13 | +# all copies or substantial portions of the Software. |
| 14 | +# |
| 15 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 16 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 17 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 18 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 19 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 20 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 21 | +# SOFTWARE. |
| 22 | +# ============================================================================= |
| 23 | + |
| 24 | +import urllib.parse |
| 25 | +from typing import Any, Dict |
| 26 | + |
| 27 | +from ai.common.database import DatabaseGlobalBase |
| 28 | + |
| 29 | + |
| 30 | +class IGlobal(DatabaseGlobalBase): |
| 31 | + """ClickHouse-specific global state. |
| 32 | +
|
| 33 | + Implements the two abstract methods that carry ClickHouse knowledge: |
| 34 | + how to read connection params from the node config, and how to build a |
| 35 | + clickhouse-sqlalchemy DSN from those params. Everything else (schema |
| 36 | + reflection, type inference, session lifecycle) lives in the base. |
| 37 | +
|
| 38 | + The DSN uses the native TCP interface (``clickhouse+native://``, default |
| 39 | + port 9000) via the ``clickhouse-driver`` backend. ClickHouse has no |
| 40 | + foreign keys; ``clickhouse-sqlalchemy`` reflects an empty FK list and a |
| 41 | + best-effort primary key, so the dialect-agnostic base works unchanged. |
| 42 | + """ |
| 43 | + |
| 44 | + def _connection_params(self, config: Dict[str, Any]) -> Dict[str, str]: |
| 45 | + # Config.getNodeConfig() strips the node namespace prefix before returning; |
| 46 | + # keys are unprefixed here by design (e.g. 'host', not 'clickhouse.host'). |
| 47 | + # 'tls' is a ClickHouse-specific option (not present on the MySQL/PostgreSQL |
| 48 | + # nodes). It is distinct from the field-level "secure": true attribute on the |
| 49 | + # password field — that attribute only marks the value as a masked secret and |
| 50 | + # is shared identically across all three database nodes. |
| 51 | + tls = config.get('tls', False) |
| 52 | + if isinstance(tls, str): |
| 53 | + # Config values may arrive as strings ('true'/'false'); 'false' must |
| 54 | + # not be truthy, so don't use bool() directly. |
| 55 | + tls = tls.strip().lower() in {'1', 'true', 'yes', 'on'} |
| 56 | + return { |
| 57 | + 'host': config.get('host', 'localhost').strip(), |
| 58 | + 'user': config.get('user', 'default').strip(), |
| 59 | + 'password': config.get('password', ''), # Do not strip — whitespace is valid in passwords |
| 60 | + 'database': config.get('database', 'default').strip(), |
| 61 | + 'table': config.get('table', 'table').strip(), |
| 62 | + # Normalised to a flag string so the params dict stays Dict[str, str]; |
| 63 | + # consumed by _build_connection_url below. |
| 64 | + 'tls': 'true' if tls else '', |
| 65 | + } |
| 66 | + |
| 67 | + def _build_connection_url(self, params: Dict[str, str]) -> str: |
| 68 | + # URL-encode the password so special characters (e.g. @, /, #) don't |
| 69 | + # break the SQLAlchemy connection string. |
| 70 | + password = urllib.parse.quote_plus(params['password']) |
| 71 | + |
| 72 | + host = params['host'] |
| 73 | + if params.get('tls'): |
| 74 | + # TLS is required by managed services such as ClickHouse Cloud, whose |
| 75 | + # native-protocol TLS port is 9440. Default to it when the user did |
| 76 | + # not pin an explicit port, so a bare cloud hostname just works. |
| 77 | + if ':' not in host: |
| 78 | + host = f'{host}:9440' |
| 79 | + # ?secure=true is clickhouse-driver's own wire-level parameter name for |
| 80 | + # enabling TLS; it is unrelated to the node's "tls" config field. |
| 81 | + return f'clickhouse+native://{params["user"]}:{password}@{host}/{params["database"]}?secure=true' |
| 82 | + |
| 83 | + # Plaintext native (e.g. a local server); defaults to port 9000 when the |
| 84 | + # host carries no explicit port. SQLAlchemy handles host:port correctly. |
| 85 | + return f'clickhouse+native://{params["user"]}:{password}@{host}/{params["database"]}' |
| 86 | + |
| 87 | + def _max_validation_attempts(self, config: Dict[str, Any]) -> int: |
| 88 | + try: |
| 89 | + return int(config.get('max_attempts', 5)) |
| 90 | + except (ValueError, TypeError): |
| 91 | + return 5 |
| 92 | + |
| 93 | + def _db_description(self, config: Dict[str, Any]) -> str: |
| 94 | + return config.get('db_description', '') |
0 commit comments