sqliac 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqliac-0.1.0/LICENSE +18 -0
- sqliac-0.1.0/PKG-INFO +44 -0
- sqliac-0.1.0/pyproject.toml +45 -0
- sqliac-0.1.0/setup.cfg +4 -0
- sqliac-0.1.0/src/sqliac/__init__.py +20 -0
- sqliac-0.1.0/src/sqliac/__main__.py +6 -0
- sqliac-0.1.0/src/sqliac/adapters/__init__.py +17 -0
- sqliac-0.1.0/src/sqliac/adapters/base.py +154 -0
- sqliac-0.1.0/src/sqliac/adapters/connection_manager.py +135 -0
- sqliac-0.1.0/src/sqliac/adapters/factory.py +30 -0
- sqliac-0.1.0/src/sqliac/cli.py +315 -0
- sqliac-0.1.0/src/sqliac/constants.py +59 -0
- sqliac-0.1.0/src/sqliac/definitions_loader.py +205 -0
- sqliac-0.1.0/src/sqliac/drift.py +340 -0
- sqliac-0.1.0/src/sqliac/errors.py +58 -0
- sqliac-0.1.0/src/sqliac/execution_plan.py +205 -0
- sqliac-0.1.0/src/sqliac/main.py +33 -0
- sqliac-0.1.0/src/sqliac/providers_loader.py +237 -0
- sqliac-0.1.0/src/sqliac/scheduler.py +303 -0
- sqliac-0.1.0/src/sqliac/template_engine.py +154 -0
- sqliac-0.1.0/src/sqliac/templates/__init__.py +0 -0
- sqliac-0.1.0/src/sqliac/templates/definitions/database.toml +12 -0
- sqliac-0.1.0/src/sqliac/templates/definitions/schema.toml +18 -0
- sqliac-0.1.0/src/sqliac/templates/definitions/table.toml +46 -0
- sqliac-0.1.0/src/sqliac/templates/provider/config.toml +58 -0
- sqliac-0.1.0/src/sqliac/templates/provider/credentials.toml +5 -0
- sqliac-0.1.0/src/sqliac/templates/provider/database/ddl_template.sql +31 -0
- sqliac-0.1.0/src/sqliac/templates/provider/database/state.sql +34 -0
- sqliac-0.1.0/src/sqliac/templates/provider/schema/ddl_template.sql +19 -0
- sqliac-0.1.0/src/sqliac/templates/provider/schema/state.sql +11 -0
- sqliac-0.1.0/src/sqliac/templates/provider/table/ddl_template.sql +62 -0
- sqliac-0.1.0/src/sqliac/templates/provider/table/state.sql +18 -0
- sqliac-0.1.0/src/sqliac/utils.py +61 -0
- sqliac-0.1.0/src/sqliac/value_sanitizer.py +85 -0
- sqliac-0.1.0/src/sqliac.egg-info/PKG-INFO +44 -0
- sqliac-0.1.0/src/sqliac.egg-info/SOURCES.txt +46 -0
- sqliac-0.1.0/src/sqliac.egg-info/dependency_links.txt +1 -0
- sqliac-0.1.0/src/sqliac.egg-info/entry_points.txt +2 -0
- sqliac-0.1.0/src/sqliac.egg-info/requires.txt +10 -0
- sqliac-0.1.0/src/sqliac.egg-info/top_level.txt +1 -0
- sqliac-0.1.0/tests/test_cli.py +21 -0
- sqliac-0.1.0/tests/test_definitions_loader.py +82 -0
- sqliac-0.1.0/tests/test_drift.py +119 -0
- sqliac-0.1.0/tests/test_execution_plan.py +65 -0
- sqliac-0.1.0/tests/test_providers_loader.py +58 -0
- sqliac-0.1.0/tests/test_scheduler.py +94 -0
- sqliac-0.1.0/tests/test_template_engine.py +66 -0
- sqliac-0.1.0/tests/test_value_sanitizer.py +44 -0
sqliac-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ruskgo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
sqliac-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqliac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An alternative to Terraform for SQL databases without using a state.
|
|
5
|
+
Author-email: Ruslan Gonzalez Konstantinov <rus.kgo.gmail@example.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 ruskgo
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
11
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
12
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
14
|
+
following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
17
|
+
portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
20
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
21
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
22
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
23
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
24
|
+
|
|
25
|
+
Project-URL: Homepage, https://codeberg.org/ruskgo/sqliac
|
|
26
|
+
Project-URL: Bug Tracker, https://codeberg.org/ruskgo/sqliac/issues
|
|
27
|
+
Keywords: sql,iac,terraform-alternative,database
|
|
28
|
+
Classifier: Programming Language :: Python :: 3
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: Topic :: Database
|
|
33
|
+
Requires-Python: >=3.12
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
License-File: LICENSE
|
|
36
|
+
Requires-Dist: Jinja2
|
|
37
|
+
Requires-Dist: rich
|
|
38
|
+
Requires-Dist: sqlparse
|
|
39
|
+
Requires-Dist: dictdiffer
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
42
|
+
Provides-Extra: snowflake
|
|
43
|
+
Requires-Dist: sqliac-snowflake; extra == "snowflake"
|
|
44
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqliac"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An alternative to Terraform for SQL databases without using a state."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Ruslan Gonzalez Konstantinov", email = "rus.kgo.gmail@example.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["sql", "iac", "terraform-alternative", "database"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Database",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["Jinja2", "rich", "sqlparse", "dictdiffer"]
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://codeberg.org/ruskgo/sqliac"
|
|
22
|
+
"Bug Tracker" = "https://codeberg.org/ruskgo/sqliac/issues"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=9.1.1"]
|
|
26
|
+
snowflake = ["sqliac-snowflake"]
|
|
27
|
+
|
|
28
|
+
[tool.uv.workspace]
|
|
29
|
+
members = ["sqliac_adapters/sqliac_snowflake"]
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
sqliac-snowflake = { workspace = true }
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
sqliac = "sqliac.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
sqliac = ["templates/**/*.toml", "templates/**/*.sql"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
45
|
+
build-backend = "setuptools.build_meta"
|
sqliac-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""sqliac - SQL Infrastructure as Code."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__author__ = "Ruslan Gonzalez Konstantinov"
|
|
5
|
+
__license__ = "MIT"
|
|
6
|
+
__prog_name__ = "sqliac"
|
|
7
|
+
|
|
8
|
+
from sqliac.constants import TemplateType, DDLCommand, IacAction, RunMode, Paths
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
# Version
|
|
12
|
+
"__version__",
|
|
13
|
+
"__author__",
|
|
14
|
+
"__license__",
|
|
15
|
+
"TemplateType",
|
|
16
|
+
"DDLCommand",
|
|
17
|
+
"IacAction",
|
|
18
|
+
"RunMode",
|
|
19
|
+
"Paths",
|
|
20
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Adapters package for sqliac."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .base import (
|
|
6
|
+
BaseAdapter,
|
|
7
|
+
BaseCredentials,
|
|
8
|
+
)
|
|
9
|
+
from .factory import AdapterFactory
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
# Base classes
|
|
13
|
+
"BaseAdapter",
|
|
14
|
+
"BaseCredentials",
|
|
15
|
+
# Factory
|
|
16
|
+
"AdapterFactory",
|
|
17
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Simplified, production-grade SQL adapter layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tomllib import TOMLDecodeError
|
|
10
|
+
from typing import Any, Type
|
|
11
|
+
|
|
12
|
+
import sqlparse
|
|
13
|
+
|
|
14
|
+
from sqliac import Paths
|
|
15
|
+
from sqliac.adapters.connection_manager import ThreadConnectionManager
|
|
16
|
+
from sqliac.errors import RustyError
|
|
17
|
+
from sqliac.template_engine import TemplateEngine
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseCredentials(abc.ABC):
|
|
21
|
+
"""Abstract base class establishing database connection credentials."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
def dialect(self) -> str:
|
|
26
|
+
"""The identifier string for the target database system."""
|
|
27
|
+
raise NotImplementedError(
|
|
28
|
+
f"Credentials class {self.__class__.__name__} must implement 'dialect' property."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
"""Validate if the required fields are present."""
|
|
33
|
+
raise NotImplementedError(
|
|
34
|
+
f"Credentials class {self.__class__.__name__} must implement post init validation."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BaseAdapter(abc.ABC):
|
|
39
|
+
"""Abstract base class handling configuration lifecycles and query execution."""
|
|
40
|
+
|
|
41
|
+
# Define the concrete Connection Manager class
|
|
42
|
+
ConnectionManager: Type[ThreadConnectionManager]
|
|
43
|
+
|
|
44
|
+
# Standard Types
|
|
45
|
+
Row = tuple[Any, ...] | None
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
# Load environment credentials immediately upon initialization
|
|
49
|
+
self.credentials = self._load_credentials()
|
|
50
|
+
self.connections = self.ConnectionManager(self.credentials)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@abc.abstractmethod
|
|
54
|
+
def dialect(self) -> str:
|
|
55
|
+
"""The identifier string for the target database system."""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
@abc.abstractmethod
|
|
60
|
+
def credentials_class(self) -> type[BaseCredentials]:
|
|
61
|
+
"""The concrete Credentials class associated with this adapter."""
|
|
62
|
+
raise NotImplementedError(
|
|
63
|
+
f"Adapter class {self.__class__.__name__} must implement 'credentials_class' property."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@abc.abstractmethod
|
|
67
|
+
def _fetch_row(self, cursor: Any) -> Row:
|
|
68
|
+
"""Process an open database cursor into standard formatted row."""
|
|
69
|
+
raise NotImplementedError(
|
|
70
|
+
f"Adapter class {self.__class__.__name__} must implement '_fetch_row' method."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _load_credentials(self) -> BaseCredentials:
|
|
74
|
+
"""Load from file or environment variables prefixed with the dialect name."""
|
|
75
|
+
target_path = self._resolve_path(Paths.PROVIDER_CREDENTIALS_FILE)
|
|
76
|
+
if target_path.is_file():
|
|
77
|
+
try:
|
|
78
|
+
with open(target_path, "rb") as f:
|
|
79
|
+
config = tomllib.load(f)
|
|
80
|
+
|
|
81
|
+
if self.dialect in config:
|
|
82
|
+
for key, value in config[self.dialect].items():
|
|
83
|
+
os.environ[f"{self.dialect.upper()}_{key.upper()}"] = str(value)
|
|
84
|
+
|
|
85
|
+
except TOMLDecodeError as err:
|
|
86
|
+
raise RustyError(
|
|
87
|
+
error="invalid TOML file",
|
|
88
|
+
file=str(target_path),
|
|
89
|
+
help="check TOML formatting in the definition file",
|
|
90
|
+
) from err
|
|
91
|
+
|
|
92
|
+
except PermissionError:
|
|
93
|
+
raise RustyError(
|
|
94
|
+
error="read permission denied",
|
|
95
|
+
file=str(target_path),
|
|
96
|
+
help="the file might be open by another application or missing read permissions",
|
|
97
|
+
) from None
|
|
98
|
+
|
|
99
|
+
prefix = f"{self.dialect.upper()}_"
|
|
100
|
+
credentials = {
|
|
101
|
+
key.replace(prefix, "").lower(): val
|
|
102
|
+
for key, val in os.environ.items()
|
|
103
|
+
if key.startswith(prefix)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return self.credentials_class(**credentials)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _resolve_path(path: Path):
|
|
110
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
111
|
+
|
|
112
|
+
def _split_statements(self, sql: str) -> list[str]:
|
|
113
|
+
"""Cleans syntax comments and splits batches into single statements."""
|
|
114
|
+
formatted = TemplateEngine.pretty_sql(sql)
|
|
115
|
+
return [str(s).strip() for s in sqlparse.parse(formatted) if str(s).strip()]
|
|
116
|
+
|
|
117
|
+
def execute(self, sql: str) -> Row:
|
|
118
|
+
"""Executes one or more SQL statements sequentially within a clean connection lifecycle."""
|
|
119
|
+
statements = self._split_statements(sql)
|
|
120
|
+
|
|
121
|
+
with self.connections.get_connection() as conn:
|
|
122
|
+
try:
|
|
123
|
+
cursor = conn.cursor()
|
|
124
|
+
except Exception as err:
|
|
125
|
+
raise RustyError(
|
|
126
|
+
error="failed to get the connection cursor", details=str(err)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for statement in statements:
|
|
130
|
+
try:
|
|
131
|
+
if cursor:
|
|
132
|
+
cursor.execute(statement)
|
|
133
|
+
except RustyError:
|
|
134
|
+
raise
|
|
135
|
+
except Exception as err:
|
|
136
|
+
raise RustyError(
|
|
137
|
+
error="SQL statement execution error",
|
|
138
|
+
sql=TemplateEngine.pretty_sql(sql=statement),
|
|
139
|
+
details=str(err),
|
|
140
|
+
) from err
|
|
141
|
+
|
|
142
|
+
return self._fetch_row(cursor)
|
|
143
|
+
|
|
144
|
+
def cleanup(self) -> None:
|
|
145
|
+
"""Cleans up all managed connections."""
|
|
146
|
+
self.connections.cleanup_all()
|
|
147
|
+
|
|
148
|
+
def __enter__(self):
|
|
149
|
+
"""Context manager entry."""
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
153
|
+
"""Context manager exit - cleanup connections."""
|
|
154
|
+
self.cleanup()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Manages database connections with thread safety and connection pooling."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import threading
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, ClassVar, Generator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ConnectionState:
|
|
12
|
+
"""Represents the current state of a database connection.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
name(str): Unique identifier for this connection thread
|
|
16
|
+
state(str): Current state ('init', 'open', 'closed', 'fail')
|
|
17
|
+
handle(str): The underlying database connection object
|
|
18
|
+
credentials(str): Credentials used to establish this connection
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
state: str = "init"
|
|
23
|
+
handle: Any | None = None
|
|
24
|
+
credentials: Any | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ThreadConnectionManager(abc.ABC):
|
|
28
|
+
"""Manages database connections with thread safety."""
|
|
29
|
+
|
|
30
|
+
# Each adapter implementation must define its type of credentials
|
|
31
|
+
CredentialsClass: ClassVar[type[Any]]
|
|
32
|
+
|
|
33
|
+
def __init__(self, credentials: Any):
|
|
34
|
+
"""Initialize the connection manager.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
credentials: Database credentials for establishing connections
|
|
38
|
+
"""
|
|
39
|
+
self.credentials = credentials
|
|
40
|
+
|
|
41
|
+
# Dictionary to store one connection per thread
|
|
42
|
+
# Key: thread name, Value: ConnectionState object
|
|
43
|
+
self.thread_connections: dict[str, ConnectionState] = {}
|
|
44
|
+
|
|
45
|
+
# Reentrant lock allows the same thread to acquire it multiple times
|
|
46
|
+
self.lock = threading.RLock()
|
|
47
|
+
|
|
48
|
+
def get_thread_identifier(self) -> str:
|
|
49
|
+
"""Get unique identifier for the current thread."""
|
|
50
|
+
return threading.current_thread().name
|
|
51
|
+
|
|
52
|
+
def get_thread_connection(self) -> ConnectionState:
|
|
53
|
+
"""Get or create connection for the current thread."""
|
|
54
|
+
thread_id = self.get_thread_identifier()
|
|
55
|
+
|
|
56
|
+
# Thread-safe access to the connections dictionary
|
|
57
|
+
with self.lock:
|
|
58
|
+
if thread_id not in self.thread_connections:
|
|
59
|
+
# Create a new connection state for this thread
|
|
60
|
+
self.thread_connections[thread_id] = ConnectionState(
|
|
61
|
+
name=thread_id,
|
|
62
|
+
credentials=self.credentials,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return self.thread_connections[thread_id]
|
|
66
|
+
|
|
67
|
+
@abc.abstractmethod
|
|
68
|
+
def open(self, connection: ConnectionState) -> ConnectionState:
|
|
69
|
+
"""Open a new database connection.
|
|
70
|
+
|
|
71
|
+
This method must be implemented by each database adapter to handle
|
|
72
|
+
the specific connection logic for that database.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
connection: ConnectionState object to populate with connection
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Updated ConnectionState with opened connection
|
|
79
|
+
"""
|
|
80
|
+
raise NotImplementedError(
|
|
81
|
+
f"{self.__class__.__name__} must implement the 'open' method"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def close(self, conn_state: ConnectionState) -> ConnectionState:
|
|
85
|
+
"""Close a database connection."""
|
|
86
|
+
if conn_state.state == "closed":
|
|
87
|
+
return conn_state
|
|
88
|
+
|
|
89
|
+
if conn_state.handle is not None:
|
|
90
|
+
try:
|
|
91
|
+
# Handle custom connection types
|
|
92
|
+
conn_state.handle.close()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
# Log warning instead of raising, as we are cleaning up
|
|
95
|
+
print(f"warning: error closing connection {conn_state.name}: {e}")
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Update state
|
|
99
|
+
conn_state.state = "closed"
|
|
100
|
+
conn_state.handle = None
|
|
101
|
+
|
|
102
|
+
return conn_state
|
|
103
|
+
|
|
104
|
+
def cleanup_all(self) -> None:
|
|
105
|
+
"""Close all open connections across all threads."""
|
|
106
|
+
with self.lock:
|
|
107
|
+
for connection in self.thread_connections.values():
|
|
108
|
+
if connection.state == "open":
|
|
109
|
+
self.close(connection)
|
|
110
|
+
# Clear the connections dictionary
|
|
111
|
+
self.thread_connections.clear()
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def get_connection(self) -> Generator[Any]:
|
|
115
|
+
"""Context manager to get and manage a database connection.
|
|
116
|
+
|
|
117
|
+
Yields:
|
|
118
|
+
SQL server connection object ready for use
|
|
119
|
+
"""
|
|
120
|
+
# Get or create connection state for this thread
|
|
121
|
+
conn_state = self.get_thread_connection()
|
|
122
|
+
|
|
123
|
+
# Open connection if not already open
|
|
124
|
+
if conn_state.state != "open":
|
|
125
|
+
self.close(conn_state)
|
|
126
|
+
# Ensure the concrete open method is called
|
|
127
|
+
conn_state = self.open(conn_state)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Yield the actual database connection handle
|
|
131
|
+
yield conn_state.handle # pyright: ignore[reportReturnType]
|
|
132
|
+
except Exception:
|
|
133
|
+
# Mark connection as failed on exception
|
|
134
|
+
conn_state.state = "fail"
|
|
135
|
+
raise
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Adapter factory for creating database adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
|
|
7
|
+
from sqliac.errors import RustyError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AdapterFactory:
|
|
11
|
+
GROUP = "sqliac.adapters"
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def list_adapters(cls) -> list[str]:
|
|
15
|
+
return sorted(ep.name for ep in entry_points(group=cls.GROUP))
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def get_adapter(cls, provider: str):
|
|
19
|
+
adapters = {ep.name: ep for ep in entry_points(group=cls.GROUP)}
|
|
20
|
+
try:
|
|
21
|
+
adapter_cls = adapters[provider]
|
|
22
|
+
except KeyError:
|
|
23
|
+
available = ", ".join(sorted(adapters)) or "none installed"
|
|
24
|
+
raise RustyError(
|
|
25
|
+
error=f"adapter `{provider}` is not installed",
|
|
26
|
+
help=f"install it, e.g. `pip install sqliac-{provider}`",
|
|
27
|
+
details=f"available adapters: {available}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return adapter_cls.load()()
|