simple-db-settings 1.0.0__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.
- simple_db_settings/__init__.py +41 -0
- simple_db_settings/audit.py +75 -0
- simple_db_settings/cache.py +70 -0
- simple_db_settings/cli.py +183 -0
- simple_db_settings/codec.py +67 -0
- simple_db_settings/exceptions.py +38 -0
- simple_db_settings/py.typed +0 -0
- simple_db_settings/schema.py +50 -0
- simple_db_settings/store.py +306 -0
- simple_db_settings/transfer.py +87 -0
- simple_db_settings/typed.py +86 -0
- simple_db_settings/upsert.py +42 -0
- simple_db_settings-1.0.0.dist-info/METADATA +385 -0
- simple_db_settings-1.0.0.dist-info/RECORD +17 -0
- simple_db_settings-1.0.0.dist-info/WHEEL +4 -0
- simple_db_settings-1.0.0.dist-info/entry_points.txt +2 -0
- simple_db_settings-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from simple_db_settings.audit import causer, current_causer
|
|
2
|
+
from simple_db_settings.cache import CacheBackend, NullCache, TTLCache
|
|
3
|
+
from simple_db_settings.store import DEFAULT_GROUP, GroupView, SettingsStore
|
|
4
|
+
from simple_db_settings.exceptions import (
|
|
5
|
+
CodecError,
|
|
6
|
+
InvalidValueError,
|
|
7
|
+
MissingExtraError,
|
|
8
|
+
SimpleSettingsError,
|
|
9
|
+
TransferError,
|
|
10
|
+
UnknownTypeError,
|
|
11
|
+
)
|
|
12
|
+
from simple_db_settings.transfer import (
|
|
13
|
+
export_json,
|
|
14
|
+
export_rows,
|
|
15
|
+
import_json,
|
|
16
|
+
import_rows,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"causer",
|
|
23
|
+
"current_causer",
|
|
24
|
+
"CacheBackend",
|
|
25
|
+
"NullCache",
|
|
26
|
+
"TTLCache",
|
|
27
|
+
"DEFAULT_GROUP",
|
|
28
|
+
"GroupView",
|
|
29
|
+
"SettingsStore",
|
|
30
|
+
"CodecError",
|
|
31
|
+
"InvalidValueError",
|
|
32
|
+
"MissingExtraError",
|
|
33
|
+
"SimpleSettingsError",
|
|
34
|
+
"TransferError",
|
|
35
|
+
"UnknownTypeError",
|
|
36
|
+
"export_json",
|
|
37
|
+
"export_rows",
|
|
38
|
+
"import_json",
|
|
39
|
+
"import_rows",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Connection, Table, insert
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("simple_db_settings")
|
|
11
|
+
|
|
12
|
+
EVENT_CREATED = "created"
|
|
13
|
+
EVENT_UPDATED = "updated"
|
|
14
|
+
EVENT_DELETED = "deleted"
|
|
15
|
+
|
|
16
|
+
_causer_var: ContextVar[tuple[str | None, int | None]] = ContextVar(
|
|
17
|
+
"simple_db_settings_causer", default=(None, None)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def causer(causer_type: str, causer_id: int) -> Iterator[None]:
|
|
23
|
+
"""Attribute audit rows written inside the block to this actor."""
|
|
24
|
+
token = _causer_var.set((causer_type, causer_id))
|
|
25
|
+
try:
|
|
26
|
+
yield
|
|
27
|
+
finally:
|
|
28
|
+
_causer_var.reset(token)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def current_causer() -> tuple[str | None, int | None]:
|
|
32
|
+
return _causer_var.get()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def payload(value: Any) -> str | None:
|
|
36
|
+
"""Audit payload: SQL NULL both for missing values and for None."""
|
|
37
|
+
if value is None:
|
|
38
|
+
return None
|
|
39
|
+
return json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def record(
|
|
43
|
+
conn: Connection,
|
|
44
|
+
table: Table,
|
|
45
|
+
group: str,
|
|
46
|
+
entries: list[dict[str, Any]],
|
|
47
|
+
now: datetime,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Write all audit rows of one operation with a single insert.
|
|
50
|
+
|
|
51
|
+
Each entry carries: name, event, old (value or None), new (value or None).
|
|
52
|
+
"""
|
|
53
|
+
if not entries:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
causer_type, causer_id = current_causer()
|
|
57
|
+
rows = [
|
|
58
|
+
{
|
|
59
|
+
"group": group,
|
|
60
|
+
"name": e["name"],
|
|
61
|
+
"event": e["event"],
|
|
62
|
+
"old_payload": payload(e["old"]),
|
|
63
|
+
"new_payload": payload(e["new"]),
|
|
64
|
+
"causer_type": causer_type,
|
|
65
|
+
"causer_id": causer_id,
|
|
66
|
+
"created_at": now,
|
|
67
|
+
}
|
|
68
|
+
for e in entries
|
|
69
|
+
]
|
|
70
|
+
conn.execute(insert(table).values(rows))
|
|
71
|
+
logger.debug(
|
|
72
|
+
"audit recorded: group=%s events=%s",
|
|
73
|
+
group,
|
|
74
|
+
[e["event"] for e in entries],
|
|
75
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable, Protocol
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("simple_db_settings")
|
|
7
|
+
|
|
8
|
+
DEFAULT_TTL = 5.0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheBackend(Protocol):
|
|
12
|
+
def get(self, group: str) -> dict[str, Any] | None: ...
|
|
13
|
+
|
|
14
|
+
def set(self, group: str, data: dict[str, Any]) -> None: ...
|
|
15
|
+
|
|
16
|
+
def invalidate(self, group: str) -> None: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NullCache:
|
|
20
|
+
"""Cache that never stores anything."""
|
|
21
|
+
|
|
22
|
+
def get(self, group: str) -> dict[str, Any] | None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def set(self, group: str, data: dict[str, Any]) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def invalidate(self, group: str) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TTLCache:
|
|
33
|
+
"""In-process cache of whole groups with a time-to-live.
|
|
34
|
+
|
|
35
|
+
The TTL bounds staleness across processes: another worker cannot
|
|
36
|
+
invalidate this cache, so entries must expire on their own.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
ttl: float = DEFAULT_TTL,
|
|
42
|
+
clock: Callable[[], float] = time.monotonic,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._ttl = ttl
|
|
45
|
+
self._clock = clock
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
48
|
+
|
|
49
|
+
def get(self, group: str) -> dict[str, Any] | None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
entry = self._entries.get(group)
|
|
52
|
+
if entry is None:
|
|
53
|
+
logger.debug("cache miss: group=%s", group)
|
|
54
|
+
return None
|
|
55
|
+
expires_at, data = entry
|
|
56
|
+
if self._clock() >= expires_at:
|
|
57
|
+
del self._entries[group]
|
|
58
|
+
logger.debug("cache expired: group=%s", group)
|
|
59
|
+
return None
|
|
60
|
+
logger.debug("cache hit: group=%s", group)
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
def set(self, group: str, data: dict[str, Any]) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._entries[group] = (self._clock() + self._ttl, data)
|
|
66
|
+
|
|
67
|
+
def invalidate(self, group: str) -> None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._entries.pop(group, None)
|
|
70
|
+
logger.debug("cache invalidated: group=%s", group)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Command line interface for the settings table (extra: cli)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
from simple_db_settings.exceptions import MissingExtraError, SimpleSettingsError
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import typer
|
|
13
|
+
except ImportError as exc:
|
|
14
|
+
raise MissingExtraError("Command line interface", "cli") from exc
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import create_engine
|
|
17
|
+
from sqlalchemy.pool import NullPool
|
|
18
|
+
|
|
19
|
+
from simple_db_settings.schema import DEFAULT_SETTINGS_TABLE
|
|
20
|
+
from simple_db_settings.store import DEFAULT_GROUP, SettingsStore
|
|
21
|
+
from simple_db_settings.transfer import export_json, import_json
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="Manage DB-backed settings", no_args_is_help=True)
|
|
24
|
+
|
|
25
|
+
UrlOption = Annotated[
|
|
26
|
+
str,
|
|
27
|
+
typer.Option("--url", envvar="SIMPLE_DB_SETTINGS_URL", help="Database URL"),
|
|
28
|
+
]
|
|
29
|
+
TableOption = Annotated[
|
|
30
|
+
str, typer.Option("--table", help="Settings table name")
|
|
31
|
+
]
|
|
32
|
+
GroupOption = Annotated[str, typer.Option("--group", "-g", help="Settings group")]
|
|
33
|
+
VerboseOption = Annotated[bool, typer.Option("--verbose", help="Debug logging")]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _store(url: str, table: str, verbose: bool) -> SettingsStore:
|
|
37
|
+
logging.basicConfig(level=logging.DEBUG if verbose else logging.WARNING)
|
|
38
|
+
engine = create_engine(url, poolclass=NullPool)
|
|
39
|
+
return SettingsStore(engine, table_name=table)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(message: str) -> "typer.Exit":
|
|
43
|
+
typer.echo(f"Error: {message}", err=True)
|
|
44
|
+
return typer.Exit(code=1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def get(
|
|
49
|
+
key: str,
|
|
50
|
+
url: UrlOption,
|
|
51
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
52
|
+
group: GroupOption = DEFAULT_GROUP,
|
|
53
|
+
verbose: VerboseOption = False,
|
|
54
|
+
):
|
|
55
|
+
"""Print one setting as JSON."""
|
|
56
|
+
store = _store(url, table, verbose)
|
|
57
|
+
try:
|
|
58
|
+
value = store.group(group)[key]
|
|
59
|
+
except KeyError:
|
|
60
|
+
raise _fail(f"Setting {group}.{key} not found") from None
|
|
61
|
+
except SimpleSettingsError as exc:
|
|
62
|
+
raise _fail(str(exc)) from None
|
|
63
|
+
typer.echo(json.dumps(value, ensure_ascii=False))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("set")
|
|
67
|
+
def set_(
|
|
68
|
+
key: str,
|
|
69
|
+
value: str,
|
|
70
|
+
url: UrlOption,
|
|
71
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
72
|
+
group: GroupOption = DEFAULT_GROUP,
|
|
73
|
+
verbose: VerboseOption = False,
|
|
74
|
+
):
|
|
75
|
+
"""Store a setting. VALUE is parsed as JSON, falling back to a string."""
|
|
76
|
+
store = _store(url, table, verbose)
|
|
77
|
+
try:
|
|
78
|
+
parsed = json.loads(value)
|
|
79
|
+
except ValueError:
|
|
80
|
+
parsed = value
|
|
81
|
+
try:
|
|
82
|
+
store.group(group)[key] = parsed
|
|
83
|
+
except SimpleSettingsError as exc:
|
|
84
|
+
raise _fail(str(exc)) from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def delete(
|
|
89
|
+
key: str,
|
|
90
|
+
url: UrlOption,
|
|
91
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
92
|
+
group: GroupOption = DEFAULT_GROUP,
|
|
93
|
+
verbose: VerboseOption = False,
|
|
94
|
+
):
|
|
95
|
+
"""Remove one setting."""
|
|
96
|
+
store = _store(url, table, verbose)
|
|
97
|
+
try:
|
|
98
|
+
del store.group(group)[key]
|
|
99
|
+
except KeyError:
|
|
100
|
+
raise _fail(f"Setting {group}.{key} not found") from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("list")
|
|
104
|
+
def list_(
|
|
105
|
+
url: UrlOption,
|
|
106
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
107
|
+
group: Annotated[Optional[str], typer.Option("--group", "-g")] = None,
|
|
108
|
+
verbose: VerboseOption = False,
|
|
109
|
+
):
|
|
110
|
+
"""List raw rows: group, name, type, val."""
|
|
111
|
+
store = _store(url, table, verbose)
|
|
112
|
+
for row in store.rows(group):
|
|
113
|
+
typer.echo(f"{row.group}\t{row.name}\t{row.type}\t{row.val}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def groups(
|
|
118
|
+
url: UrlOption,
|
|
119
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
120
|
+
verbose: VerboseOption = False,
|
|
121
|
+
):
|
|
122
|
+
"""List group names."""
|
|
123
|
+
store = _store(url, table, verbose)
|
|
124
|
+
for name in store.groups():
|
|
125
|
+
typer.echo(name)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def clear(
|
|
130
|
+
url: UrlOption,
|
|
131
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
132
|
+
group: GroupOption = DEFAULT_GROUP,
|
|
133
|
+
verbose: VerboseOption = False,
|
|
134
|
+
):
|
|
135
|
+
"""Remove all settings in a group."""
|
|
136
|
+
store = _store(url, table, verbose)
|
|
137
|
+
store.group(group).clear()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def export(
|
|
142
|
+
url: UrlOption,
|
|
143
|
+
file: Annotated[Optional[Path], typer.Argument()] = None,
|
|
144
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
145
|
+
group: Annotated[Optional[str], typer.Option("--group", "-g")] = None,
|
|
146
|
+
verbose: VerboseOption = False,
|
|
147
|
+
):
|
|
148
|
+
"""Export settings to JSON (stdout when FILE is omitted)."""
|
|
149
|
+
store = _store(url, table, verbose)
|
|
150
|
+
dump = export_json(store, group)
|
|
151
|
+
if file is None:
|
|
152
|
+
typer.echo(dump)
|
|
153
|
+
else:
|
|
154
|
+
file.write_text(dump)
|
|
155
|
+
typer.echo(f"Exported to {file}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command("import")
|
|
159
|
+
def import_(
|
|
160
|
+
file: Path,
|
|
161
|
+
url: UrlOption,
|
|
162
|
+
table: TableOption = DEFAULT_SETTINGS_TABLE,
|
|
163
|
+
group: Annotated[Optional[str], typer.Option("--group", "-g")] = None,
|
|
164
|
+
replace: Annotated[bool, typer.Option("--replace")] = False,
|
|
165
|
+
verbose: VerboseOption = False,
|
|
166
|
+
):
|
|
167
|
+
"""Import settings from a JSON file made by export."""
|
|
168
|
+
store = _store(url, table, verbose)
|
|
169
|
+
if not file.is_file():
|
|
170
|
+
raise _fail(f"File not found: {file}")
|
|
171
|
+
try:
|
|
172
|
+
count = import_json(store, file.read_text(), group=group, replace=replace)
|
|
173
|
+
except SimpleSettingsError as exc:
|
|
174
|
+
raise _fail(str(exc)) from None
|
|
175
|
+
typer.echo(f"Imported {count} setting(s)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> None:
|
|
179
|
+
app()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Value codec for the shared settings table.
|
|
2
|
+
|
|
3
|
+
Follows the cross-language storage contract: values are written with
|
|
4
|
+
type='json', rows written by the Laravel package (string, integer, float,
|
|
5
|
+
boolean, array, object, null, legacy double) stay readable forever.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from simple_db_settings.exceptions import (
|
|
11
|
+
CodecError,
|
|
12
|
+
InvalidValueError,
|
|
13
|
+
UnknownTypeError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
JSON_TYPE = "json"
|
|
17
|
+
|
|
18
|
+
_PHP_FALSE_STRINGS = frozenset(("", "0"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def encode(value: object) -> tuple[str, str]:
|
|
22
|
+
"""Serialize a value for storage. Returns (val, type)."""
|
|
23
|
+
try:
|
|
24
|
+
val = json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
|
|
25
|
+
except (TypeError, ValueError) as exc:
|
|
26
|
+
raise InvalidValueError(f"Value is not JSON-serializable: {exc}") from exc
|
|
27
|
+
return val, JSON_TYPE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decode(val: str | None, type_: str, group: str = "", name: str = "") -> object:
|
|
31
|
+
"""Restore a value from a stored (val, type) pair.
|
|
32
|
+
|
|
33
|
+
Dispatch is driven by the type column only, never by val content.
|
|
34
|
+
"""
|
|
35
|
+
if val is None:
|
|
36
|
+
val = ""
|
|
37
|
+
kind = type_.strip().lower()
|
|
38
|
+
|
|
39
|
+
if kind == "string":
|
|
40
|
+
return val
|
|
41
|
+
if kind == "boolean":
|
|
42
|
+
return val not in _PHP_FALSE_STRINGS
|
|
43
|
+
if kind == "null":
|
|
44
|
+
return None
|
|
45
|
+
if kind == "integer":
|
|
46
|
+
try:
|
|
47
|
+
return int(val)
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise CodecError(
|
|
50
|
+
f"Bad integer value {val!r} for {group}.{name}"
|
|
51
|
+
) from exc
|
|
52
|
+
if kind in ("float", "double"):
|
|
53
|
+
try:
|
|
54
|
+
return float(val)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise CodecError(
|
|
57
|
+
f"Bad float value {val!r} for {group}.{name}"
|
|
58
|
+
) from exc
|
|
59
|
+
if kind in ("array", "object", "json"):
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(val)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise CodecError(
|
|
64
|
+
f"Bad JSON value for {group}.{name}: {exc}"
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
raise UnknownTypeError(group, name, type_)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class SimpleSettingsError(Exception):
|
|
2
|
+
"""Base error for the simple-db-settings package."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MissingExtraError(SimpleSettingsError):
|
|
6
|
+
"""Optional dependency is not installed."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, feature: str, extra: str) -> None:
|
|
9
|
+
self.feature = feature
|
|
10
|
+
self.extra = extra
|
|
11
|
+
super().__init__(
|
|
12
|
+
f"{feature} requires an optional dependency. "
|
|
13
|
+
f"Install it with: pip install simple-db-settings[{extra}]"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidValueError(SimpleSettingsError):
|
|
18
|
+
"""Value cannot be serialized for storage."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CodecError(SimpleSettingsError):
|
|
22
|
+
"""Stored data cannot be decoded."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransferError(SimpleSettingsError):
|
|
26
|
+
"""Export or import data does not match the exchange format."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnknownTypeError(SimpleSettingsError):
|
|
30
|
+
"""Row has a type value the codec does not recognize."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, group: str, name: str, type_: str) -> None:
|
|
33
|
+
self.group = group
|
|
34
|
+
self.name = name
|
|
35
|
+
self.type = type_
|
|
36
|
+
super().__init__(
|
|
37
|
+
f"Unknown settings type {type_!r} for {group}.{name}"
|
|
38
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from sqlalchemy import (
|
|
2
|
+
CHAR,
|
|
3
|
+
BigInteger,
|
|
4
|
+
Column,
|
|
5
|
+
DateTime,
|
|
6
|
+
Index,
|
|
7
|
+
Integer,
|
|
8
|
+
MetaData,
|
|
9
|
+
String,
|
|
10
|
+
Table,
|
|
11
|
+
Text,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
DEFAULT_SETTINGS_TABLE = "simple_settings"
|
|
15
|
+
DEFAULT_CHANGES_TABLE = "simple_setting_changes"
|
|
16
|
+
|
|
17
|
+
# Автоинкремент BigInteger не работает на SQLite, вариант Integer его чинит.
|
|
18
|
+
_PK_BIGINT = BigInteger().with_variant(Integer, "sqlite")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_settings_table(metadata: MetaData, name: str = DEFAULT_SETTINGS_TABLE) -> Table:
|
|
22
|
+
return Table(
|
|
23
|
+
name,
|
|
24
|
+
metadata,
|
|
25
|
+
Column("group", String(255), primary_key=True),
|
|
26
|
+
Column("name", String(255), primary_key=True),
|
|
27
|
+
Column("val", Text, nullable=False),
|
|
28
|
+
Column("type", CHAR(20), nullable=False, server_default="string"),
|
|
29
|
+
Column("created_at", DateTime, nullable=True),
|
|
30
|
+
Column("updated_at", DateTime, nullable=True),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_changes_table(metadata: MetaData, name: str = DEFAULT_CHANGES_TABLE) -> Table:
|
|
35
|
+
return Table(
|
|
36
|
+
name,
|
|
37
|
+
metadata,
|
|
38
|
+
Column("id", _PK_BIGINT, primary_key=True, autoincrement=True),
|
|
39
|
+
Column("group", String(255), nullable=False),
|
|
40
|
+
Column("name", String(255), nullable=False),
|
|
41
|
+
Column("event", String(20), nullable=False),
|
|
42
|
+
Column("old_payload", Text, nullable=True),
|
|
43
|
+
Column("new_payload", Text, nullable=True),
|
|
44
|
+
Column("causer_type", String(255), nullable=True),
|
|
45
|
+
Column("causer_id", BigInteger, nullable=True),
|
|
46
|
+
Column("created_at", DateTime, nullable=True),
|
|
47
|
+
Index(f"{name}_target_idx", "group", "name", "created_at"),
|
|
48
|
+
Index(f"{name}_causer_idx", "causer_type", "causer_id"),
|
|
49
|
+
Index(f"{name}_event_idx", "event"),
|
|
50
|
+
)
|