deev 0.0.1__py3-none-any.whl
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.
- deev/_ImmutableMixin.py +28 -0
- deev/_MigrationData.py +20 -0
- deev/__init__.py +30 -0
- deev/common/ConnectionString.py +108 -0
- deev/common/DbConnection.py +49 -0
- deev/common/DbContext.py +12 -0
- deev/common/DbCursor.py +66 -0
- deev/common/DbError.py +19 -0
- deev/common/DbMigrator.py +132 -0
- deev/common/DbParams.py +9 -0
- deev/common/DbTableAdapter.py +77 -0
- deev/common/DbTransactionContext.py +83 -0
- deev/common/DbTypeMapper.py +17 -0
- deev/common/__init__.py +27 -0
- deev/db_migrate.py +116 -0
- deev/entities.py +284 -0
- deev/mysql/MysqlTableAdapter.py +290 -0
- deev/mysql/MysqlTransactionContext.py +169 -0
- deev/mysql/MysqlTypeMapper.py +74 -0
- deev/mysql/__init__.py +13 -0
- deev/py.typed +0 -0
- deev/sqlite/SqliteTableAdapter.py +290 -0
- deev/sqlite/SqliteTransactionContext.py +170 -0
- deev/sqlite/SqliteTypeMapper.py +69 -0
- deev/sqlite/__init__.py +13 -0
- deev/translation.py +309 -0
- deev/utils.py +134 -0
- deev/validation.py +87 -0
- deev-0.0.1.dist-info/METADATA +211 -0
- deev-0.0.1.dist-info/RECORD +34 -0
- deev-0.0.1.dist-info/WHEEL +5 -0
- deev-0.0.1.dist-info/entry_points.txt +2 -0
- deev-0.0.1.dist-info/licenses/LICENSE +21 -0
- deev-0.0.1.dist-info/top_level.txt +1 -0
deev/_ImmutableMixin.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2025 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _ImmutableMixin:
|
|
8
|
+
"""Mixin that adds a ``freeze`` operation."""
|
|
9
|
+
__frozen__: bool = False
|
|
10
|
+
|
|
11
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
12
|
+
if getattr(self, '__frozen__', False):
|
|
13
|
+
raise AttributeError(f'Cannot modify frozen instance: {name}')
|
|
14
|
+
# Call ``super()`` so the next class in the MRO (e.g. BaseModel) can run
|
|
15
|
+
super().__setattr__(name, value)
|
|
16
|
+
|
|
17
|
+
def __delattr__(self, name: str) -> None:
|
|
18
|
+
if getattr(self, '__frozen__', False):
|
|
19
|
+
raise AttributeError(f'Cannot delete attribute from frozen instance: {name}')
|
|
20
|
+
super().__delattr__(name)
|
|
21
|
+
|
|
22
|
+
def __freeze__(self) -> Any:
|
|
23
|
+
"""Mark the instance as immutable."""
|
|
24
|
+
object.__setattr__(self, '__frozen__', True)
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ['_ImmutableMixin']
|
deev/_MigrationData.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from .entities import entity, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@entity(table_name='_migrationdata')
|
|
8
|
+
class _MigrationData:
|
|
9
|
+
"""Internal entity representation of ``_migrationdata`` tables used by ``deev``."""
|
|
10
|
+
id: int = field(
|
|
11
|
+
autoincrement=True,
|
|
12
|
+
primary_key=True
|
|
13
|
+
)
|
|
14
|
+
migration: str = field(
|
|
15
|
+
max=260,
|
|
16
|
+
sqltype='VARCVHAR(260)'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ['_MigrationData']
|
deev/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from .common.ConnectionString import ConnectionString
|
|
5
|
+
from .common.DbError import DbError
|
|
6
|
+
from .entities import (
|
|
7
|
+
entity,
|
|
8
|
+
field
|
|
9
|
+
)
|
|
10
|
+
from .utils import connect
|
|
11
|
+
from . import common, entities, mysql, translation, utils, validation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__version__ = '0.0.1'
|
|
15
|
+
__commit__ = 'cb1b8ad'
|
|
16
|
+
__all__ = [
|
|
17
|
+
'__version__', '__commit__',
|
|
18
|
+
'ConnectionString',
|
|
19
|
+
'DbError',
|
|
20
|
+
'common',
|
|
21
|
+
'connect',
|
|
22
|
+
'db_migrate',
|
|
23
|
+
'entities',
|
|
24
|
+
'entity',
|
|
25
|
+
'field',
|
|
26
|
+
'mysql',
|
|
27
|
+
'translation',
|
|
28
|
+
'utils',
|
|
29
|
+
'validation'
|
|
30
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConnectionString:
|
|
10
|
+
"""
|
|
11
|
+
A type-safe "Connection String" representation that can parse/build constituent parts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__server: Optional[str]
|
|
15
|
+
__database: Optional[str]
|
|
16
|
+
__user: Optional[str]
|
|
17
|
+
__password: Optional[str]
|
|
18
|
+
__provider: Optional[str]
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
connection_str: Optional[str] = None
|
|
23
|
+
):
|
|
24
|
+
self.__server = None
|
|
25
|
+
self.__database = None
|
|
26
|
+
self.__user = None
|
|
27
|
+
self.__password = None
|
|
28
|
+
self.__provider = None
|
|
29
|
+
if connection_str is not None:
|
|
30
|
+
self.parse(connection_str)
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
parts = []
|
|
34
|
+
if self.server is not None:
|
|
35
|
+
parts.append(f'Server={self.server}')
|
|
36
|
+
if self.database is not None:
|
|
37
|
+
parts.append(f'Database={self.database}')
|
|
38
|
+
if self.user is not None:
|
|
39
|
+
parts.append(f'UID={self.user}')
|
|
40
|
+
if self.password is not None:
|
|
41
|
+
parts.append(f'PWD={self.password}')
|
|
42
|
+
if self.provider is not None:
|
|
43
|
+
parts.append(f'Provider={self.provider}')
|
|
44
|
+
return ';'.join(parts)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def server(self) -> Optional[str]:
|
|
48
|
+
return self.__server
|
|
49
|
+
|
|
50
|
+
@server.setter
|
|
51
|
+
def server(self, value: Optional[str]):
|
|
52
|
+
self.__server = value
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def database(self) -> Optional[str]:
|
|
56
|
+
return self.__database
|
|
57
|
+
|
|
58
|
+
@database.setter
|
|
59
|
+
def database(self, value: Optional[str]):
|
|
60
|
+
self.__database = value
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def user(self) -> Optional[str]:
|
|
64
|
+
return self.__user
|
|
65
|
+
|
|
66
|
+
@user.setter
|
|
67
|
+
def user(self, value: Optional[str]):
|
|
68
|
+
self.__user = value
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def password(self) -> Optional[str]:
|
|
72
|
+
return self.__password
|
|
73
|
+
|
|
74
|
+
@password.setter
|
|
75
|
+
def password(self, value: Optional[str]):
|
|
76
|
+
self.__password = value
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def provider(self) -> Optional[str]:
|
|
80
|
+
return self.__provider
|
|
81
|
+
|
|
82
|
+
@provider.setter
|
|
83
|
+
def provider(self, value: Optional[str]):
|
|
84
|
+
self.__provider = value
|
|
85
|
+
|
|
86
|
+
def parse(self, connectionstring: Optional[str]) -> ConnectionString:
|
|
87
|
+
parts = (
|
|
88
|
+
[]
|
|
89
|
+
if connectionstring is None
|
|
90
|
+
else connectionstring.split(';')
|
|
91
|
+
)
|
|
92
|
+
for part in parts:
|
|
93
|
+
key, value = part.split('=')
|
|
94
|
+
match key.lower():
|
|
95
|
+
case 'server':
|
|
96
|
+
self.server = value
|
|
97
|
+
case 'database':
|
|
98
|
+
self.database = value
|
|
99
|
+
case 'uid' | 'user' | 'user id' | 'username':
|
|
100
|
+
self.user = value
|
|
101
|
+
case 'pwd' | 'password' | 'pass':
|
|
102
|
+
self.password = value
|
|
103
|
+
case 'provider':
|
|
104
|
+
self.provider = value
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ['ConnectionString']
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Literal, Optional, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from .DbCursor import DbCursor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class DbConnection(Protocol):
|
|
14
|
+
"""DB-API 2.0 Connection proto."""
|
|
15
|
+
|
|
16
|
+
def cursor(self, *args: Any, **kwargs: Any) -> DbCursor:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def commit(self) -> None:
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def rollback(self) -> None:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def close(self) -> None:
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def __enter__(self) -> DbConnection:
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def __exit__(self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType], /) -> Literal[False]:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def __subclasshook__(cls, subclass: type) -> bool | None: # type: ignore[override]
|
|
36
|
+
# if db api providers can't be bothered to follow the
|
|
37
|
+
# spec anyone else can't be bothered to enforce it.
|
|
38
|
+
required = {
|
|
39
|
+
name
|
|
40
|
+
for name in dir(cls)
|
|
41
|
+
if not name.startswith('_')
|
|
42
|
+
}
|
|
43
|
+
for name in required:
|
|
44
|
+
if name not in subclass.__dict__:
|
|
45
|
+
return False # pragma: no cover
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ['DbConnection']
|
deev/common/DbContext.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import TypeAlias
|
|
5
|
+
|
|
6
|
+
from .DbConnection import DbConnection
|
|
7
|
+
from .DbTransactionContext import DbTransactionContext
|
|
8
|
+
|
|
9
|
+
DbContext: TypeAlias = DbConnection | DbTransactionContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ['DbContext']
|
deev/common/DbCursor.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
Iterable,
|
|
9
|
+
Optional,
|
|
10
|
+
Sequence,
|
|
11
|
+
Protocol,
|
|
12
|
+
runtime_checkable
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .DbParams import DbParams
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class DbCursor(Protocol):
|
|
20
|
+
"""DB-API 2.0 Cursor proto."""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def description(self) -> Sequence[tuple[Any, ...]]:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def rowcount(self) -> int:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def execute(self, operation: str, params: Optional[DbParams] = ...) -> None:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def executemany(self, operation: str, seq_of_params: Sequence[DbParams]) -> None:
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def fetchone(self) -> tuple[Any, ...] | None:
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def fetchmany(self, size: int = ...) -> list[tuple[Any, ...]]:
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def fetchall(self) -> list[tuple[Any, ...]]:
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def __iter__(self) -> Iterable[tuple[Any, ...]]:
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def __subclasshook__(cls, subclass: type) -> bool | None: # type: ignore[override]
|
|
53
|
+
# if db api providers can't be bothered to follow the
|
|
54
|
+
# spec anyone else can't be bothered to enforce it.
|
|
55
|
+
required = {
|
|
56
|
+
name
|
|
57
|
+
for name in dir(cls)
|
|
58
|
+
if not name.startswith('_')
|
|
59
|
+
}
|
|
60
|
+
for name in required:
|
|
61
|
+
if name not in subclass.__dict__:
|
|
62
|
+
return False # pragma: no cover
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ['DbCursor']
|
deev/common/DbError.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import final
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@final
|
|
8
|
+
class DbError(Exception):
|
|
9
|
+
"""Exception raised when a DB operation fails."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, reason: str) -> None:
|
|
12
|
+
self.reason = reason
|
|
13
|
+
super().__init__(reason)
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return (f'{self.__class__.__name__}(reason={self.reason!r})')
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return self.__repr__()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import glob
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .._MigrationData import _MigrationData
|
|
14
|
+
from .ConnectionString import ConnectionString
|
|
15
|
+
from .DbError import DbError
|
|
16
|
+
from .DbTableAdapter import DbTableAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DbMigrator:
|
|
20
|
+
"""
|
|
21
|
+
Performs database changes for a set of migration scripts.
|
|
22
|
+
|
|
23
|
+
Migration scripts can be scanned from a path, or an explicit set of script paths may be provided.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__connectionstring: ConnectionString
|
|
27
|
+
__logger: logging.Logger
|
|
28
|
+
|
|
29
|
+
def __init__(self, connectionstring: ConnectionString):
|
|
30
|
+
self.__logger = logging.getLogger(__name__)
|
|
31
|
+
self.__connectionstring = connectionstring
|
|
32
|
+
|
|
33
|
+
def __get_or_create_migrations_table(self) -> DbTableAdapter:
|
|
34
|
+
from ..utils import get_table_adapter
|
|
35
|
+
table_adapter = get_table_adapter(_MigrationData, self.__connectionstring)
|
|
36
|
+
table_adapter.create_table()
|
|
37
|
+
return table_adapter
|
|
38
|
+
|
|
39
|
+
def __load_migration(self, path: str) -> ModuleType:
|
|
40
|
+
"""Load a Python file as a module given its absolute file path."""
|
|
41
|
+
module_name = os.path.splitext(os.path.basename(path))[0]
|
|
42
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
43
|
+
if spec is None or spec.loader is None: # pragma: no cover
|
|
44
|
+
raise ImportError(f'Cannot create spec for {path}')
|
|
45
|
+
module = importlib.util.module_from_spec(spec)
|
|
46
|
+
spec.loader.exec_module(module) # type: ignore[arg-type]
|
|
47
|
+
return module
|
|
48
|
+
|
|
49
|
+
def apply(self, migrations_path: Path | str, stop_at: Optional[str] = None) -> None:
|
|
50
|
+
from ..utils import create_database, get_transaction_context
|
|
51
|
+
create_database(self.__connectionstring)
|
|
52
|
+
if isinstance(migrations_path, str):
|
|
53
|
+
migrations_path = Path(migrations_path)
|
|
54
|
+
if not os.path.exists(migrations_path):
|
|
55
|
+
self.__logger.warn(f'Migrations path does not exist: {migrations_path}')
|
|
56
|
+
return
|
|
57
|
+
available_migrations = sorted(glob.glob(os.path.join(migrations_path, '*.py')))
|
|
58
|
+
if len(available_migrations) == 0:
|
|
59
|
+
self.__logger.warn(f'Migrations path does not contain migrations: {migrations_path}')
|
|
60
|
+
return
|
|
61
|
+
migrations_table = self.__get_or_create_migrations_table()
|
|
62
|
+
applied_migrations = dict[str, int]({
|
|
63
|
+
e.migration: e.id
|
|
64
|
+
for e in migrations_table.query(orderby='migration')
|
|
65
|
+
})
|
|
66
|
+
skipped_migration_count = 0
|
|
67
|
+
applied_migration_count = 0
|
|
68
|
+
for migration_filepath in available_migrations:
|
|
69
|
+
migration_name = os.path.splitext(os.path.basename(migration_filepath))[0]
|
|
70
|
+
if migration_name not in applied_migrations:
|
|
71
|
+
self.__logger.info(f'..apply migration "{migration_name}"')
|
|
72
|
+
migration_module = self.__load_migration(migration_filepath)
|
|
73
|
+
migration_func = getattr(migration_module, 'apply', None)
|
|
74
|
+
if migration_func is not None:
|
|
75
|
+
with get_transaction_context(self.__connectionstring) as db_transaction:
|
|
76
|
+
migration_func(db_transaction)
|
|
77
|
+
migrations_table.create(_MigrationData(migration=migration_name)) # type: ignore[call-arg]
|
|
78
|
+
migrations_table.commit()
|
|
79
|
+
applied_migration_count += 1
|
|
80
|
+
else:
|
|
81
|
+
raise DbError(f'Invalid migration "{migration_name}", missing `apply(...)` call.')
|
|
82
|
+
else:
|
|
83
|
+
self.__logger.info(f'..skipped migration "{migration_name}" (already applied.)')
|
|
84
|
+
skipped_migration_count += 1
|
|
85
|
+
if stop_at is not None and migration_name == stop_at:
|
|
86
|
+
self.__logger.info(f'..stopping at "{migration_name}", as instructed.')
|
|
87
|
+
break
|
|
88
|
+
self.__logger.info(f'Migrations applied {applied_migration_count}, skipped {skipped_migration_count}, available {len(available_migrations)}.')
|
|
89
|
+
|
|
90
|
+
def undo(self, migrations_path: Path | str, stop_at: Optional[str] = None) -> None:
|
|
91
|
+
from ..utils import create_database, get_transaction_context
|
|
92
|
+
create_database(self.__connectionstring)
|
|
93
|
+
if isinstance(migrations_path, str):
|
|
94
|
+
migrations_path = Path(migrations_path)
|
|
95
|
+
if not os.path.exists(migrations_path):
|
|
96
|
+
self.__logger.warn(f'Migrations path does not exist: {migrations_path}')
|
|
97
|
+
return
|
|
98
|
+
available_migrations = sorted(glob.glob(os.path.join(migrations_path, '*.py')), reverse=True)
|
|
99
|
+
if len(available_migrations) == 0:
|
|
100
|
+
self.__logger.warn(f'Migrations path does not contain migrations: {migrations_path}')
|
|
101
|
+
return
|
|
102
|
+
migrations_table = self.__get_or_create_migrations_table()
|
|
103
|
+
applied_migrations = dict[str, int]({
|
|
104
|
+
e.migration: e.id
|
|
105
|
+
for e in migrations_table.query(orderby='migration DESC')
|
|
106
|
+
})
|
|
107
|
+
skipped_migration_count = 0
|
|
108
|
+
applied_migration_count = 0
|
|
109
|
+
for migration_filepath in available_migrations:
|
|
110
|
+
migration_name = os.path.splitext(os.path.basename(migration_filepath))[0]
|
|
111
|
+
if migration_name in applied_migrations:
|
|
112
|
+
self.__logger.info(f'..undo migration "{migration_name}"')
|
|
113
|
+
migration_module = self.__load_migration(migration_filepath)
|
|
114
|
+
migration_func = getattr(migration_module, 'undo', None)
|
|
115
|
+
if migration_func is not None:
|
|
116
|
+
with get_transaction_context(self.__connectionstring) as db_transaction:
|
|
117
|
+
migration_func(db_transaction)
|
|
118
|
+
migrations_table.delete(id=applied_migrations.get(migration_name, 0))
|
|
119
|
+
migrations_table.commit()
|
|
120
|
+
applied_migration_count += 1
|
|
121
|
+
else:
|
|
122
|
+
raise DbError(f'Invalid migration "{migration_name}", missing `undo(...)` call.')
|
|
123
|
+
else:
|
|
124
|
+
self.__logger.info(f'..skipped migration "{migration_name}" (not applied.)')
|
|
125
|
+
skipped_migration_count += 1
|
|
126
|
+
if stop_at is not None and migration_name == stop_at:
|
|
127
|
+
self.__logger.info(f'..stopping at "{migration_name}", as instructed.')
|
|
128
|
+
break
|
|
129
|
+
self.__logger.info(f'Migrations undone {applied_migration_count}, skipped {skipped_migration_count}, available {len(available_migrations)}.')
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ['DbMigrator']
|
deev/common/DbParams.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
Generator,
|
|
9
|
+
Optional,
|
|
10
|
+
Protocol,
|
|
11
|
+
TypeVar,
|
|
12
|
+
runtime_checkable
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .DbParams import DbParams
|
|
16
|
+
|
|
17
|
+
TEntity = TypeVar('TEntity')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class DbTableAdapter(Protocol[TEntity]):
|
|
22
|
+
|
|
23
|
+
def create_table(self) -> None:
|
|
24
|
+
"""Utility method for creating the target table."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def commit(self) -> None:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def rollback(self) -> None:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def create(self, entity: TEntity) -> dict[str, Any]:
|
|
34
|
+
"""
|
|
35
|
+
Creates a new record in the specified table with the provided keyword arguments.
|
|
36
|
+
|
|
37
|
+
:param kwargs: A dictionary containing the column names and their corresponding values for the new record.
|
|
38
|
+
:type kwargs: dict[str, Any]
|
|
39
|
+
:return: The newly created record's ID if successful, otherwise None.
|
|
40
|
+
:rtype: Any
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def read(self, **kwargs: Any) -> TEntity | None:
|
|
45
|
+
"""
|
|
46
|
+
Reads a record from the specified table with the primary key represented by `kwargs`.
|
|
47
|
+
|
|
48
|
+
:param kwargs: The primary key.
|
|
49
|
+
:type kwargs: dict[str, Any]
|
|
50
|
+
:return: A dictionary containing the column names and their corresponding values for the specified record, or None if no such record exists.
|
|
51
|
+
:rtype: dict[str, Any] | None
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def update(self, entity: TEntity) -> None:
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def delete(self, **kwargs: Any) -> None:
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def exists(self, **kwargs: Any) -> bool:
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def upsert(self, entity: TEntity) -> dict[str, Any]:
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def query(
|
|
68
|
+
self,
|
|
69
|
+
where: Optional[str] = ...,
|
|
70
|
+
params: Optional[DbParams] = ...,
|
|
71
|
+
orderby: Optional[str] = ...,
|
|
72
|
+
limit: Optional[int] = ...
|
|
73
|
+
) -> Generator[TEntity, None, None]:
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ['DbTableAdapter']
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Generator,
|
|
10
|
+
Optional,
|
|
11
|
+
Protocol,
|
|
12
|
+
runtime_checkable
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .DbConnection import DbConnection
|
|
16
|
+
from .DbCursor import DbCursor
|
|
17
|
+
from .DbParams import DbParams
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class DbTransactionContext(Protocol):
|
|
22
|
+
|
|
23
|
+
def __init__(self, connection: DbConnection) -> None:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def __del__(self) -> None:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def __enter__(self) -> DbTransactionContext:
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def __exit__(
|
|
33
|
+
self,
|
|
34
|
+
exc_type: Optional[type[BaseException]] = None,
|
|
35
|
+
exc_value: Optional[BaseException] = None,
|
|
36
|
+
traceback: Optional[TracebackType] = None
|
|
37
|
+
) -> bool:
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def connection(self) -> DbConnection:
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def cursor(self) -> DbCursor:
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def commit(self) -> None:
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def execute(self, sql: str, params: Optional[DbParams] = ...) -> DbCursor:
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def execute_script(self, sql: str) -> None:
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def execute_nonquery(self, sql: str, params: Optional[DbParams] = ...) -> None:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def execute_reader(self, sql: str, params: Optional[DbParams] = ...) -> Generator[tuple[Any, ...], None, None]:
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def execute_scalar(self, sql: str, params: Optional[DbParams] = ...) -> Any:
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def rollback(self) -> None:
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def __subclasshook__(cls, subclass: type) -> bool | None: # type: ignore[override]
|
|
70
|
+
# if db api providers can't be bothered to follow the
|
|
71
|
+
# spec anyone else can't be bothered to enforce it.
|
|
72
|
+
required = {
|
|
73
|
+
name
|
|
74
|
+
for name in dir(cls)
|
|
75
|
+
if not name.startswith('_')
|
|
76
|
+
}
|
|
77
|
+
for name in required:
|
|
78
|
+
if name not in subclass.__dict__:
|
|
79
|
+
return False # pragma: no cover
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ['DbTransactionContext']
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class DbTypeMapper(Protocol):
|
|
9
|
+
|
|
10
|
+
def get_sqltype(self, field_name: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Get the SQL type (string) needed to represent an entity field in the underlying table.
|
|
13
|
+
|
|
14
|
+
:param field_spec: The "Entity Field Spec".
|
|
15
|
+
:return: The SQL type string.
|
|
16
|
+
"""
|
|
17
|
+
...
|
deev/common/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2023 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from .ConnectionString import ConnectionString
|
|
5
|
+
from .DbConnection import DbConnection
|
|
6
|
+
from .DbCursor import DbCursor
|
|
7
|
+
from .DbContext import DbContext
|
|
8
|
+
from .DbError import DbError
|
|
9
|
+
from .DbMigrator import DbMigrator
|
|
10
|
+
from .DbParams import DbParams
|
|
11
|
+
from .DbTableAdapter import DbTableAdapter
|
|
12
|
+
from .DbTransactionContext import DbTransactionContext
|
|
13
|
+
from .DbTypeMapper import DbTypeMapper
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'ConnectionString',
|
|
18
|
+
'DbConnection',
|
|
19
|
+
'DbCursor',
|
|
20
|
+
'DbContext',
|
|
21
|
+
'DbError',
|
|
22
|
+
'DbMigrator',
|
|
23
|
+
'DbParams',
|
|
24
|
+
'DbTableAdapter',
|
|
25
|
+
'DbTransactionContext',
|
|
26
|
+
'DbTypeMapper'
|
|
27
|
+
]
|