keepup-db 0.4.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.
- keepup_db-0.4.0/PKG-INFO +19 -0
- keepup_db-0.4.0/README.md +10 -0
- keepup_db-0.4.0/keepup_db/__init__.py +22 -0
- keepup_db-0.4.0/keepup_db/manager.py +603 -0
- keepup_db-0.4.0/keepup_db/positional_sql.py +125 -0
- keepup_db-0.4.0/keepup_db/tables.py +304 -0
- keepup_db-0.4.0/keepup_db.egg-info/PKG-INFO +19 -0
- keepup_db-0.4.0/keepup_db.egg-info/SOURCES.txt +12 -0
- keepup_db-0.4.0/keepup_db.egg-info/dependency_links.txt +1 -0
- keepup_db-0.4.0/keepup_db.egg-info/entry_points.txt +2 -0
- keepup_db-0.4.0/keepup_db.egg-info/requires.txt +2 -0
- keepup_db-0.4.0/keepup_db.egg-info/top_level.txt +1 -0
- keepup_db-0.4.0/pyproject.toml +20 -0
- keepup_db-0.4.0/setup.cfg +4 -0
keepup_db-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-db
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
9
|
+
|
|
10
|
+
# keepup-db
|
|
11
|
+
|
|
12
|
+
The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
|
|
13
|
+
|
|
14
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
15
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
16
|
+
this distribution carries one part of it. It is installed on its own
|
|
17
|
+
(`pip install keepup-db`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
18
|
+
and it declares itself to the kernel through its
|
|
19
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# keepup-db
|
|
2
|
+
|
|
3
|
+
The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
|
|
4
|
+
|
|
5
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
6
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
7
|
+
this distribution carries one part of it. It is installed on its own
|
|
8
|
+
(`pip install keepup-db`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
9
|
+
and it declares itself to the kernel through its
|
|
10
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
|
|
2
|
+
|
|
3
|
+
This distribution provides `datasource` and declares one plugin,
|
|
4
|
+
`db`, in the `keepup.plugins` group. It depends on `keepup-admin`
|
|
5
|
+
for the constructor -- the descriptor, the catalogue, the service registry and
|
|
6
|
+
the lifecycle -- and on nothing of another capability.
|
|
7
|
+
|
|
8
|
+
`keepup.db` moved here in keepup-124, and the old name keeps working for one
|
|
9
|
+
release: `keepup.compat` answers for it and names this distribution when it is not
|
|
10
|
+
installed. The three names below are the declaration the moved module carried.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.4.0"
|
|
14
|
+
|
|
15
|
+
from keepup_db import tables # noqa: E402 - the table language, with the manager
|
|
16
|
+
from keepup_db.manager import ( # noqa: E402 - after the version, as a public face
|
|
17
|
+
DatabaseConfig,
|
|
18
|
+
DatabaseManagerV2,
|
|
19
|
+
db_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = ["DatabaseConfig", "DatabaseManagerV2", "db_config", "tables"]
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Access to the database: configuration and the pooled manager.
|
|
2
|
+
|
|
3
|
+
`DatabaseManagerV2` is the one way in -- a SQLAlchemy engine with a pool and a
|
|
4
|
+
session context manager, named parameters only. The legacy `DatabaseManager`,
|
|
5
|
+
a layer over raw drivers with positional parameters that opened a connection
|
|
6
|
+
per call, is gone (0.2.0); code written against a cursor takes one from the
|
|
7
|
+
pool with `DatabaseManagerV2.raw_connection()`.
|
|
8
|
+
|
|
9
|
+
Where the database is comes from the environment when `DB_TYPE` is set and from
|
|
10
|
+
`config/postgres.properties` otherwise. Both paths are read from the working
|
|
11
|
+
directory of the started application, which is how two applications on one
|
|
12
|
+
framework keep separate databases. SQLite exists for development only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextvars
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import warnings
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from typing import Optional, List, Dict, Any
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import create_engine, event, exc, text
|
|
25
|
+
from sqlalchemy.engine import Engine
|
|
26
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
27
|
+
|
|
28
|
+
_RETURNED_AT = "keepup_returned_at"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ping_when_idle(engine, idle_seconds: int) -> None:
|
|
32
|
+
"""Ping a connection at checkout only when it has lain in the pool a while.
|
|
33
|
+
|
|
34
|
+
The pool used to ping on every checkout (pool_pre_ping), and a request that
|
|
35
|
+
takes three connections pinged three times -- on the load stand (keepup-53)
|
|
36
|
+
a fifth of what a replica spent. A connection handed back a moment ago has
|
|
37
|
+
had no time to be closed by the database or the network; one that has lain
|
|
38
|
+
longer than ``idle_seconds`` is pinged, and one that fails the ping is
|
|
39
|
+
replaced, exactly as the built-in ping does (keepup-86). The ping is the
|
|
40
|
+
dialect's own. A connection the database dropped while it was busy a
|
|
41
|
+
moment ago still fails its next statement once, as it would have after a
|
|
42
|
+
ping made just before the drop.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(engine, Engine):
|
|
45
|
+
# A stand-in engine (a test's mock) has no pool events to listen to.
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
@event.listens_for(engine, "connect")
|
|
49
|
+
def _fresh(dbapi_connection, record):
|
|
50
|
+
record.info[_RETURNED_AT] = time.monotonic()
|
|
51
|
+
|
|
52
|
+
@event.listens_for(engine, "checkin")
|
|
53
|
+
def _returned(dbapi_connection, record):
|
|
54
|
+
if record is not None:
|
|
55
|
+
record.info[_RETURNED_AT] = time.monotonic()
|
|
56
|
+
|
|
57
|
+
@event.listens_for(engine, "checkout")
|
|
58
|
+
def _ping(dbapi_connection, record, proxy):
|
|
59
|
+
returned = record.info.get(_RETURNED_AT)
|
|
60
|
+
if idle_seconds and returned is not None and time.monotonic() - returned < idle_seconds:
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
engine.dialect.do_ping(dbapi_connection)
|
|
64
|
+
except Exception as error:
|
|
65
|
+
# The pool discards the connection and hands out a fresh one.
|
|
66
|
+
raise exc.DisconnectionError(f"connection failed its ping: {error}") from error
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _reads_only(query: str) -> bool:
|
|
70
|
+
"""Whether the statement is a plain read: a SELECT that takes no lock."""
|
|
71
|
+
body = query.lstrip().lstrip("(").lstrip()
|
|
72
|
+
return body[:6].upper() == "SELECT" and "FOR UPDATE" not in query.upper() \
|
|
73
|
+
and "FOR SHARE" not in query.upper()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: The session of the shared_session() block running on this thread, if any,
|
|
77
|
+
#: with the thread it belongs to.
|
|
78
|
+
_SHARED_SESSION: contextvars.ContextVar = contextvars.ContextVar(
|
|
79
|
+
"keepup_shared_session", default=None)
|
|
80
|
+
|
|
81
|
+
#: What an application may import from this module. Everything else is
|
|
82
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
83
|
+
__all__ = [
|
|
84
|
+
"DatabaseConfig",
|
|
85
|
+
"DatabaseManagerV2",
|
|
86
|
+
"db_config",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DatabaseConfig:
|
|
91
|
+
"""Database configuration."""
|
|
92
|
+
|
|
93
|
+
def __init__(self):
|
|
94
|
+
self.db_type = 'sqlite'
|
|
95
|
+
self.db_host = 'localhost'
|
|
96
|
+
self.db_port = '5432'
|
|
97
|
+
self.db_name = 'keepup_app'
|
|
98
|
+
self.db_user = 'postgres'
|
|
99
|
+
self.db_password = ''
|
|
100
|
+
self.db_path = 'data/keepup_app.db'
|
|
101
|
+
# The defaults come first and the configuration over them: the other way
|
|
102
|
+
# round, DB_POOL_SIZE and db.pool.size were read and then written over
|
|
103
|
+
# with these very numbers, and never applied (keepup-38).
|
|
104
|
+
self.pool_size = 5
|
|
105
|
+
self.pool_max_overflow = 10
|
|
106
|
+
self.pool_timeout = 30
|
|
107
|
+
self.pool_recycle = 3600
|
|
108
|
+
#: Seconds a connection may lie in the pool and still be handed out
|
|
109
|
+
#: without a ping; 0 pings on every checkout (keepup-86).
|
|
110
|
+
self.pool_ping_after_idle = 10
|
|
111
|
+
self._load_config()
|
|
112
|
+
|
|
113
|
+
def apply_pool(self, size=None, max_overflow=None, timeout=None, recycle=None,
|
|
114
|
+
ping_after_idle=None) -> bool:
|
|
115
|
+
"""Take the application's pool values over the deployment's; True if any changed."""
|
|
116
|
+
changed = False
|
|
117
|
+
for field_name, value in (("pool_size", size), ("pool_max_overflow", max_overflow),
|
|
118
|
+
("pool_timeout", timeout), ("pool_recycle", recycle),
|
|
119
|
+
("pool_ping_after_idle", ping_after_idle)):
|
|
120
|
+
if value is not None and getattr(self, field_name) != int(value):
|
|
121
|
+
setattr(self, field_name, int(value))
|
|
122
|
+
changed = True
|
|
123
|
+
return changed
|
|
124
|
+
|
|
125
|
+
def _load_config(self):
|
|
126
|
+
self.db_type = os.getenv('DB_TYPE', '').lower()
|
|
127
|
+
if not self.db_type:
|
|
128
|
+
self._load_from_file()
|
|
129
|
+
else:
|
|
130
|
+
self._load_from_env()
|
|
131
|
+
|
|
132
|
+
def _load_from_env(self):
|
|
133
|
+
self.db_host = os.getenv('DB_HOST', 'localhost')
|
|
134
|
+
self.db_port = os.getenv('DB_PORT', '5432')
|
|
135
|
+
self.db_name = os.getenv('DB_NAME', 'keepup')
|
|
136
|
+
self.db_user = os.getenv('DB_USER', 'postgres')
|
|
137
|
+
self.db_password = os.getenv('DB_PASSWORD', '')
|
|
138
|
+
self.db_path = os.getenv('DB_PATH', 'data/keepup.db')
|
|
139
|
+
|
|
140
|
+
self.pool_size = int(os.getenv('DB_POOL_SIZE', '5'))
|
|
141
|
+
self.pool_max_overflow = int(os.getenv('DB_POOL_MAX_OVERFLOW', '10'))
|
|
142
|
+
self.pool_timeout = int(os.getenv('DB_POOL_TIMEOUT', '30'))
|
|
143
|
+
self.pool_recycle = int(os.getenv('DB_POOL_RECYCLE', '3600'))
|
|
144
|
+
self.pool_ping_after_idle = int(os.getenv('DB_POOL_PING_AFTER_IDLE', '10'))
|
|
145
|
+
|
|
146
|
+
if self.db_type == 'sqlite':
|
|
147
|
+
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
|
148
|
+
|
|
149
|
+
def _load_from_file(self):
|
|
150
|
+
self.config_file = "config/postgres.properties"
|
|
151
|
+
config = {}
|
|
152
|
+
if os.path.exists(self.config_file):
|
|
153
|
+
with open(self.config_file, 'r') as f:
|
|
154
|
+
for line in f:
|
|
155
|
+
line = line.strip()
|
|
156
|
+
if line and not line.startswith('#'):
|
|
157
|
+
if '=' in line:
|
|
158
|
+
key, value = line.split('=', 1)
|
|
159
|
+
config[key.strip()] = value.strip()
|
|
160
|
+
|
|
161
|
+
self.db_type = config.get('db.type', 'sqlite').lower()
|
|
162
|
+
self.db_host = config.get('db.host', 'localhost')
|
|
163
|
+
self.db_port = config.get('db.port', '5432')
|
|
164
|
+
self.db_name = config.get('db.name', 'keepup')
|
|
165
|
+
self.db_user = config.get('db.user', 'postgres')
|
|
166
|
+
# The file names where the database is; the password never lives in it.
|
|
167
|
+
self.db_password = os.getenv('DB_PASSWORD') or config.get('db.password', '')
|
|
168
|
+
self.db_path = config.get('db.path', 'data/keepup.db')
|
|
169
|
+
|
|
170
|
+
self.pool_size = int(config.get('db.pool.size', '5'))
|
|
171
|
+
self.pool_max_overflow = int(config.get('db.pool.max_overflow', '10'))
|
|
172
|
+
self.pool_timeout = int(config.get('db.pool.timeout', '30'))
|
|
173
|
+
self.pool_recycle = int(config.get('db.pool.recycle', '3600'))
|
|
174
|
+
|
|
175
|
+
if self.db_type == 'sqlite':
|
|
176
|
+
db_dir = os.path.dirname(self.db_path)
|
|
177
|
+
if db_dir:
|
|
178
|
+
os.makedirs(db_dir, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
def get_connection_string(self) -> str:
|
|
181
|
+
if self.db_type == 'postgres':
|
|
182
|
+
# The driver is named, not left to SQLAlchemy: from 2.1 a bare
|
|
183
|
+
# postgresql:// means psycopg 3, which this package does not
|
|
184
|
+
# install, and every fresh installation failed to reach the
|
|
185
|
+
# database at all (keepup-84). psycopg2 is the declared dependency.
|
|
186
|
+
return (f"postgresql+psycopg2://{self.db_user}:{self.db_password}"
|
|
187
|
+
f"@{self.db_host}:{self.db_port}/{self.db_name}")
|
|
188
|
+
else:
|
|
189
|
+
return f"sqlite:///{self.db_path}"
|
|
190
|
+
|
|
191
|
+
def get_sqlalchemy_engine_params(self) -> dict:
|
|
192
|
+
params = {}
|
|
193
|
+
if self.is_postgres():
|
|
194
|
+
params.update({
|
|
195
|
+
'pool_size': self.pool_size,
|
|
196
|
+
'max_overflow': self.pool_max_overflow,
|
|
197
|
+
'pool_timeout': self.pool_timeout,
|
|
198
|
+
'pool_recycle': self.pool_recycle,
|
|
199
|
+
# Pinged by _ping_when_idle() instead: on every checkout the
|
|
200
|
+
# ping was a fifth of what a replica spent (keepup-86).
|
|
201
|
+
'pool_pre_ping': False,
|
|
202
|
+
'echo': os.getenv('SQL_ECHO', 'false').lower() == 'true'
|
|
203
|
+
})
|
|
204
|
+
else:
|
|
205
|
+
params.update({
|
|
206
|
+
'pool_size': self.pool_size,
|
|
207
|
+
'pool_timeout': self.pool_timeout,
|
|
208
|
+
})
|
|
209
|
+
params['connect_args'] = {'check_same_thread': False}
|
|
210
|
+
return params
|
|
211
|
+
|
|
212
|
+
def is_postgres(self) -> bool:
|
|
213
|
+
return self.db_type == 'postgres'
|
|
214
|
+
|
|
215
|
+
def is_sqlite(self) -> bool:
|
|
216
|
+
return self.db_type == 'sqlite'
|
|
217
|
+
|
|
218
|
+
def __str__(self):
|
|
219
|
+
if self.is_postgres():
|
|
220
|
+
return (f"DatabaseConfig(type={self.db_type}, host={self.db_host}, "
|
|
221
|
+
f"port={self.db_port}, db={self.db_name}, user={self.db_user}, "
|
|
222
|
+
f"pool_size={self.pool_size})")
|
|
223
|
+
else:
|
|
224
|
+
return f"DatabaseConfig(type={self.db_type}, path={self.db_path})"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class DatabaseManagerV2:
|
|
229
|
+
"""Database access through SQLAlchemy with a connection pool."""
|
|
230
|
+
|
|
231
|
+
_engine = None
|
|
232
|
+
_session_factory = None
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def initialize(cls):
|
|
236
|
+
"""Initialise the SQLAlchemy engine."""
|
|
237
|
+
if cls._engine is None:
|
|
238
|
+
connection_string = db_config.get_connection_string()
|
|
239
|
+
engine_params = db_config.get_sqlalchemy_engine_params()
|
|
240
|
+
cls._engine = create_engine(connection_string, **engine_params)
|
|
241
|
+
_ping_when_idle(cls._engine, db_config.pool_ping_after_idle)
|
|
242
|
+
cls._session_factory = sessionmaker(bind=cls._engine)
|
|
243
|
+
import logging
|
|
244
|
+
logging.getLogger(__name__).info(f"DatabaseManagerV2 initialized with pool_size={db_config.pool_size}")
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
@contextmanager
|
|
248
|
+
def get_session(cls) -> Session:
|
|
249
|
+
"""Context manager yielding a session, committing or rolling back on exit.
|
|
250
|
+
|
|
251
|
+
Inside ``shared_session()`` on the same thread it yields that block's
|
|
252
|
+
session instead, and leaves committing to the block.
|
|
253
|
+
"""
|
|
254
|
+
shared = _SHARED_SESSION.get()
|
|
255
|
+
if shared is not None and shared[0] == threading.get_ident():
|
|
256
|
+
yield shared[1]
|
|
257
|
+
return
|
|
258
|
+
if cls._engine is None:
|
|
259
|
+
cls.initialize()
|
|
260
|
+
session = cls._session_factory()
|
|
261
|
+
try:
|
|
262
|
+
yield session
|
|
263
|
+
session.commit()
|
|
264
|
+
except Exception as e:
|
|
265
|
+
session.rollback()
|
|
266
|
+
raise e
|
|
267
|
+
finally:
|
|
268
|
+
session.close()
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
@contextmanager
|
|
272
|
+
def shared_session(cls):
|
|
273
|
+
"""Run every query of this block on one session: one connection, one commit.
|
|
274
|
+
|
|
275
|
+
Each call of the query helpers takes a connection from the pool on its
|
|
276
|
+
own -- a checkout, a ping, the query and a commit -- and a path that
|
|
277
|
+
asks three questions paid that three times. The session check on every
|
|
278
|
+
request did: it was half of what a replica spent (keepup-85). Nested
|
|
279
|
+
blocks join the outer one. Bound to the thread that opened it, so work
|
|
280
|
+
handed to another thread from inside the block takes a session of its
|
|
281
|
+
own rather than sharing one across threads.
|
|
282
|
+
"""
|
|
283
|
+
shared = _SHARED_SESSION.get()
|
|
284
|
+
if shared is not None and shared[0] == threading.get_ident():
|
|
285
|
+
yield shared[1]
|
|
286
|
+
return
|
|
287
|
+
with cls.get_session() as session:
|
|
288
|
+
token = _SHARED_SESSION.set((threading.get_ident(), session))
|
|
289
|
+
try:
|
|
290
|
+
yield session
|
|
291
|
+
finally:
|
|
292
|
+
_SHARED_SESSION.reset(token)
|
|
293
|
+
|
|
294
|
+
# --- the same calls, awaitable ------------------------------------------
|
|
295
|
+
#
|
|
296
|
+
# Every query here blocks its thread until the database answers, and in a
|
|
297
|
+
# coroutine that thread is the event loop's: while it waits, the process
|
|
298
|
+
# serves nobody. These run the synchronous call in a worker thread
|
|
299
|
+
# (asyncio.to_thread) and await it, so async code never calls the database
|
|
300
|
+
# on the loop. They call the synchronous methods by name, so whatever
|
|
301
|
+
# replaces one of those (a test's stand-in, a recorder) is used here too.
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
async def execute_async(cls, query: str, params: Optional[dict] = None) -> List[Dict]:
|
|
305
|
+
return await asyncio.to_thread(cls.execute, query, params)
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
async def execute_one_async(cls, query: str, params: Optional[dict] = None) -> Optional[Dict]:
|
|
309
|
+
return await asyncio.to_thread(cls.execute_one, query, params)
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
async def execute_commit_async(cls, query: str, params: Optional[dict] = None) -> int:
|
|
313
|
+
return await asyncio.to_thread(cls.execute_commit, query, params)
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
async def execute_many_async(cls, query: str, params_list: List[dict]) -> int:
|
|
317
|
+
return await asyncio.to_thread(cls.execute_many, query, params_list)
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
async def execute_commit_returning_async(cls, query: str, params: Optional[dict] = None,
|
|
321
|
+
returning: str = "id") -> Optional[Dict]:
|
|
322
|
+
return await asyncio.to_thread(cls.execute_commit_returning, query, params, returning)
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def test_connection(cls) -> Dict[str, Any]:
|
|
326
|
+
"""Whether the database answers, and which one it is; never raises."""
|
|
327
|
+
try:
|
|
328
|
+
if db_config.is_postgres():
|
|
329
|
+
version = cls.execute_one("SELECT version() AS version")
|
|
330
|
+
db_type = "PostgreSQL"
|
|
331
|
+
else:
|
|
332
|
+
version = cls.execute_one("SELECT sqlite_version() AS version")
|
|
333
|
+
db_type = "SQLite"
|
|
334
|
+
return {
|
|
335
|
+
"success": True,
|
|
336
|
+
"database_type": db_type,
|
|
337
|
+
"version": (version or {}).get("version", "Unknown"),
|
|
338
|
+
"config": str(db_config),
|
|
339
|
+
}
|
|
340
|
+
except Exception as e:
|
|
341
|
+
return {"success": False, "error": str(e), "config": str(db_config)}
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def raw_connection(cls):
|
|
345
|
+
"""A driver connection out of the pool, for code written against a cursor.
|
|
346
|
+
|
|
347
|
+
The application's table hook and the first-start accounts take a
|
|
348
|
+
cursor; the statements run on it are the driver's own (``?`` on
|
|
349
|
+
SQLite, ``%s`` on PostgreSQL) and rows come back as the driver makes
|
|
350
|
+
them. ``close()`` hands the connection back to the pool.
|
|
351
|
+
"""
|
|
352
|
+
if cls._engine is None:
|
|
353
|
+
cls.initialize()
|
|
354
|
+
return cls._engine.raw_connection()
|
|
355
|
+
|
|
356
|
+
@classmethod
|
|
357
|
+
@contextmanager
|
|
358
|
+
def _statement_scope(cls, query: str):
|
|
359
|
+
"""Where one statement of execute()/execute_one() runs.
|
|
360
|
+
|
|
361
|
+
A plain read runs on a connection in autocommit: it wrote nothing, and
|
|
362
|
+
the transaction around it cost a BEGIN and a COMMIT -- a round trip to
|
|
363
|
+
the database for every read (keepup-87). Anything else, and anything
|
|
364
|
+
inside shared_session(), keeps its transaction as before: these helpers
|
|
365
|
+
are also called with INSERT ... RETURNING, and a SELECT ... FOR UPDATE
|
|
366
|
+
means its lock.
|
|
367
|
+
"""
|
|
368
|
+
shared = _SHARED_SESSION.get()
|
|
369
|
+
if _reads_only(query) and not (shared is not None and shared[0] == threading.get_ident()):
|
|
370
|
+
if cls._engine is None:
|
|
371
|
+
cls.initialize()
|
|
372
|
+
with cls._engine.connect() as connection:
|
|
373
|
+
yield connection.execution_options(isolation_level="AUTOCOMMIT")
|
|
374
|
+
return
|
|
375
|
+
with cls.get_session() as session:
|
|
376
|
+
yield session
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def execute(cls, query: str, params: Optional[dict] = None) -> List[Dict]:
|
|
380
|
+
"""Run a SQL query.
|
|
381
|
+
|
|
382
|
+
NOTE: takes NAMED parameters (:param_name), not positional ones.
|
|
383
|
+
"""
|
|
384
|
+
with cls._statement_scope(query) as session:
|
|
385
|
+
result = session.execute(text(query), params or {})
|
|
386
|
+
if result.returns_rows:
|
|
387
|
+
result = [dict(row._mapping) for row in result.fetchall()]
|
|
388
|
+
return result
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def execute_one(cls, query: str, params: Optional[dict] = None) -> Optional[Dict]:
|
|
393
|
+
"""Run a SQL query and return a single row.
|
|
394
|
+
|
|
395
|
+
NOTE: takes NAMED parameters (:param_name), not positional ones.
|
|
396
|
+
"""
|
|
397
|
+
with cls._statement_scope(query) as session:
|
|
398
|
+
result = session.execute(text(query), params or {})
|
|
399
|
+
if result.returns_rows:
|
|
400
|
+
row = result.fetchone()
|
|
401
|
+
return dict(row._mapping) if row else None
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
@classmethod
|
|
405
|
+
def execute_commit(cls, query: str, params: Optional[dict] = None) -> int:
|
|
406
|
+
"""Run a SQL query and commit.
|
|
407
|
+
|
|
408
|
+
NOTE: takes NAMED parameters (:param_name), not positional ones.
|
|
409
|
+
"""
|
|
410
|
+
with cls.get_session() as session:
|
|
411
|
+
result = session.execute(text(query), params or {})
|
|
412
|
+
return result.rowcount
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def column_exists(cls, table: str, column: str) -> bool:
|
|
416
|
+
"""Whether the column is already on the table, asked of the database.
|
|
417
|
+
|
|
418
|
+
Each dialect is asked the way it answers: PostgreSQL keeps a catalogue,
|
|
419
|
+
SQLite answers a pragma. Neither reads an error message, which is the
|
|
420
|
+
whole point -- see add_column_if_missing.
|
|
421
|
+
"""
|
|
422
|
+
if db_config.is_postgres():
|
|
423
|
+
found = cls.execute_one(
|
|
424
|
+
"SELECT 1 AS present FROM information_schema.columns "
|
|
425
|
+
"WHERE table_name = :table AND column_name = :column",
|
|
426
|
+
{"table": table, "column": column})
|
|
427
|
+
return found is not None
|
|
428
|
+
|
|
429
|
+
# PRAGMA takes no parameters, so the name is inlined -- and therefore
|
|
430
|
+
# checked: see keepup.tables.identifier for why this is a refusal and
|
|
431
|
+
# not a quoting.
|
|
432
|
+
from keepup.tables import identifier
|
|
433
|
+
|
|
434
|
+
rows = cls.execute(f"PRAGMA table_info({identifier(table)})")
|
|
435
|
+
return any(str(row.get("name")) == column for row in rows or [])
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def add_column_if_missing(cls, table: str, column: str,
|
|
439
|
+
definition: str) -> bool:
|
|
440
|
+
"""Add a column unless it is already there. True if this call added it.
|
|
441
|
+
|
|
442
|
+
There are no migrations in this project: every start re-runs its DDL, so
|
|
443
|
+
an ALTER that has already been applied has to be a no-op rather than an
|
|
444
|
+
error. That used to be decided by matching the text of the failure
|
|
445
|
+
against "duplicate column" -- which is what SQLite says and PostgreSQL
|
|
446
|
+
does not. PostgreSQL answers `(psycopg2.errors.DuplicateColumn) column
|
|
447
|
+
"x" of relation "y" already exists`: no such substring, so the exception
|
|
448
|
+
was re-raised, the plugin failed to initialise, and every one of its
|
|
449
|
+
routes answered 404 while the application itself reported healthy. On
|
|
450
|
+
the stand that happened on the second start after any new column -- the
|
|
451
|
+
first one added it, the next one fell over.
|
|
452
|
+
|
|
453
|
+
A method rather than a function beside the class on purpose: the test
|
|
454
|
+
harnesses swap this module and rebind the manager by name, so a helper
|
|
455
|
+
of its own would quietly talk to a different database than its caller.
|
|
456
|
+
"""
|
|
457
|
+
from keepup.tables import identifier
|
|
458
|
+
|
|
459
|
+
if db_config.is_postgres():
|
|
460
|
+
# PostgreSQL can say it in the statement, and then a repeat start
|
|
461
|
+
# raises nothing at all -- no aborted transaction to recover from.
|
|
462
|
+
cls.execute_commit(
|
|
463
|
+
f"ALTER TABLE {identifier(table)} "
|
|
464
|
+
f"ADD COLUMN IF NOT EXISTS {identifier(column)} {definition}")
|
|
465
|
+
return True
|
|
466
|
+
|
|
467
|
+
# SQLite has no such form: try, and ask the catalogue if it failed.
|
|
468
|
+
try:
|
|
469
|
+
cls.execute_commit(
|
|
470
|
+
f"ALTER TABLE {identifier(table)} ADD COLUMN {identifier(column)} {definition}")
|
|
471
|
+
return True
|
|
472
|
+
except Exception:
|
|
473
|
+
if cls.column_exists(table, column):
|
|
474
|
+
return False
|
|
475
|
+
raise
|
|
476
|
+
|
|
477
|
+
@classmethod
|
|
478
|
+
def execute_many(cls, query: str, params_list: List[dict]) -> int:
|
|
479
|
+
"""Run a SQL query for many parameter sets.
|
|
480
|
+
|
|
481
|
+
NOTE: takes NAMED parameters (:param_name), not positional ones.
|
|
482
|
+
"""
|
|
483
|
+
total = 0
|
|
484
|
+
with cls.get_session() as session:
|
|
485
|
+
for params in params_list:
|
|
486
|
+
result = session.execute(text(query), params)
|
|
487
|
+
total += result.rowcount
|
|
488
|
+
return total
|
|
489
|
+
|
|
490
|
+
@classmethod
|
|
491
|
+
def get_pool_status(cls) -> Dict[str, Any]:
|
|
492
|
+
"""Return the connection pool status."""
|
|
493
|
+
if cls._engine is None:
|
|
494
|
+
cls.initialize()
|
|
495
|
+
pool = cls._engine.pool
|
|
496
|
+
return {
|
|
497
|
+
'size': pool.size(),
|
|
498
|
+
'checked_in': pool.checkedin(),
|
|
499
|
+
'overflow': pool.overflow(),
|
|
500
|
+
'total': pool.size() + pool.overflow(), # total = size + overflow
|
|
501
|
+
'checked_out': pool.checkedout(),
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def dispose(cls):
|
|
506
|
+
"""Close every connection."""
|
|
507
|
+
if cls._engine:
|
|
508
|
+
cls._engine.dispose()
|
|
509
|
+
cls._engine = None
|
|
510
|
+
cls._session_factory = None
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
def get_last_insert_rowid(cls, session=None) -> int:
|
|
514
|
+
"""Return the id of the last inserted row.
|
|
515
|
+
|
|
516
|
+
Works on both PostgreSQL and SQLite -- but only with the session that
|
|
517
|
+
made the insert. Without one it opens a new session, which may be
|
|
518
|
+
another pooled connection, and answers another insert's id or 0
|
|
519
|
+
(keepup-82); that form is deprecated. Use execute_commit_returning().
|
|
520
|
+
"""
|
|
521
|
+
if session is None:
|
|
522
|
+
warnings.warn(
|
|
523
|
+
"get_last_insert_rowid() without the inserting session may answer "
|
|
524
|
+
"another insert's id; use execute_commit_returning()",
|
|
525
|
+
DeprecationWarning, stacklevel=2)
|
|
526
|
+
if cls._engine is None:
|
|
527
|
+
cls.initialize()
|
|
528
|
+
|
|
529
|
+
if db_config.is_postgres():
|
|
530
|
+
query = "SELECT lastval() as id"
|
|
531
|
+
result = cls.execute_one(query)
|
|
532
|
+
return result["id"] if result else 0
|
|
533
|
+
else:
|
|
534
|
+
if session:
|
|
535
|
+
result = session.execute(text("SELECT last_insert_rowid() as id"))
|
|
536
|
+
row = result.fetchone()
|
|
537
|
+
return row[0] if row else 0
|
|
538
|
+
else:
|
|
539
|
+
with cls.get_session() as sess:
|
|
540
|
+
result = sess.execute(text("SELECT last_insert_rowid() as id"))
|
|
541
|
+
row = result.fetchone()
|
|
542
|
+
return row[0] if row else 0
|
|
543
|
+
|
|
544
|
+
@classmethod
|
|
545
|
+
def execute_commit_returning(cls, query: str, params: Optional[dict] = None, returning: str = "id") -> Optional[
|
|
546
|
+
Dict]:
|
|
547
|
+
"""Run a SQL query and return the inserted record.
|
|
548
|
+
|
|
549
|
+
Uses RETURNING on PostgreSQL and last_insert_rowid on SQLite.
|
|
550
|
+
"""
|
|
551
|
+
if db_config.is_postgres():
|
|
552
|
+
modified_query = f"{query} RETURNING {returning}"
|
|
553
|
+
with cls.get_session() as session:
|
|
554
|
+
result = session.execute(text(modified_query), params or {})
|
|
555
|
+
row = result.fetchone()
|
|
556
|
+
if row:
|
|
557
|
+
return dict(row._mapping)
|
|
558
|
+
return None
|
|
559
|
+
else:
|
|
560
|
+
with cls.get_session() as session:
|
|
561
|
+
session.execute(text(query), params or {})
|
|
562
|
+
result = session.execute(text(f"SELECT last_insert_rowid() as {returning}"))
|
|
563
|
+
row = result.fetchone()
|
|
564
|
+
if row:
|
|
565
|
+
return {returning: row[0]}
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
@classmethod
|
|
569
|
+
def execute_commit_with_positional(cls, query: str, params: tuple) -> int:
|
|
570
|
+
"""Run a SQL query with POSITIONAL parameters (?) and commit.
|
|
571
|
+
|
|
572
|
+
Deprecated: a leftover of the transition from the manager that took
|
|
573
|
+
positional parameters, removed in 0.2.0 (keepup-55). Named parameters
|
|
574
|
+
are what this manager takes; statements still written with `?` go
|
|
575
|
+
through the application's own `positional()`. Removed in the next minor
|
|
576
|
+
release, once the applications that still call it have moved.
|
|
577
|
+
"""
|
|
578
|
+
warnings.warn(
|
|
579
|
+
"DatabaseManagerV2.execute_commit_with_positional is deprecated; write "
|
|
580
|
+
"the statement with named parameters", DeprecationWarning, stacklevel=2)
|
|
581
|
+
with cls.get_session() as session:
|
|
582
|
+
# SQLAlchemy only binds named parameters, so ? placeholders are rewritten to :paramN.
|
|
583
|
+
if '?' in query:
|
|
584
|
+
param_count = query.count('?')
|
|
585
|
+
for i in range(param_count):
|
|
586
|
+
query = query.replace('?', f':param{i}', 1)
|
|
587
|
+
|
|
588
|
+
named_params = {f'param{i}': params[i] for i in range(len(params))}
|
|
589
|
+
result = session.execute(text(query), named_params)
|
|
590
|
+
else:
|
|
591
|
+
result = session.execute(text(query), params or {})
|
|
592
|
+
return result.rowcount
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
db_config = DatabaseConfig()
|
|
596
|
+
|
|
597
|
+
# Log the resolved configuration on import, so a mis-pointed run is visible early.
|
|
598
|
+
|
|
599
|
+
if __name__ != "__main__":
|
|
600
|
+
import logging
|
|
601
|
+
|
|
602
|
+
logger = logging.getLogger(__name__)
|
|
603
|
+
logger.info(f"Database configuration loaded: {db_config}")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Positional ``?`` parameters for DatabaseManagerV2, which takes named ones.
|
|
2
|
+
|
|
3
|
+
The manager removed in 0.2.0 took SQL with ``?`` placeholders and a tuple; the
|
|
4
|
+
pooled DatabaseManagerV2 takes ``:name`` parameters. Statements written for the
|
|
5
|
+
old one -- several assembled at run time, with lists of ``?`` -- are converted
|
|
6
|
+
here, at the call, rather than rewritten by hand one by one:
|
|
7
|
+
|
|
8
|
+
DatabaseManagerV2.execute(*positional("SELECT ... WHERE id = ?", (5,)))
|
|
9
|
+
|
|
10
|
+
``?`` (or ``%s``) outside quotes becomes ``:p0``, ``:p1``...; a colon that
|
|
11
|
+
SQLAlchemy would take for a parameter of its own -- a time in a literal,
|
|
12
|
+
``'12:30'`` -- is escaped, and ``::`` casts are left alone.
|
|
13
|
+
|
|
14
|
+
Three applications kept identical copies of this module, held equal by a test,
|
|
15
|
+
because the framework did not have it (keepup-56). Moving to this release, an
|
|
16
|
+
application imports it from here and deletes its copy.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
#: What an application may import from this module. Everything else is
|
|
24
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
25
|
+
__all__ = [
|
|
26
|
+
"execute_many",
|
|
27
|
+
"insert_returning_id",
|
|
28
|
+
"positional",
|
|
29
|
+
"positional_many",
|
|
30
|
+
"raw_connection",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def positional(query: str, params: Optional[Sequence[Any]] = None) -> Tuple[str, Dict[str, Any]]:
|
|
35
|
+
"""The query with named placeholders, and its parameters as a dictionary."""
|
|
36
|
+
out, count, i, quote = [], 0, 0, None
|
|
37
|
+
while i < len(query):
|
|
38
|
+
ch = query[i]
|
|
39
|
+
if quote:
|
|
40
|
+
if ch == quote:
|
|
41
|
+
# A doubled quote inside a literal is the quote itself.
|
|
42
|
+
if i + 1 < len(query) and query[i + 1] == quote:
|
|
43
|
+
out.append(ch * 2)
|
|
44
|
+
i += 2
|
|
45
|
+
continue
|
|
46
|
+
quote = None
|
|
47
|
+
if ch == ":" and _takes_colon(query, i):
|
|
48
|
+
out.append("\\:")
|
|
49
|
+
else:
|
|
50
|
+
out.append(ch)
|
|
51
|
+
elif ch in ("'", '"'):
|
|
52
|
+
quote = ch
|
|
53
|
+
out.append(ch)
|
|
54
|
+
elif ch == "?" or (ch == "%" and query[i + 1:i + 2] == "s"):
|
|
55
|
+
# %s is the driver's own style, which some PostgreSQL branches used.
|
|
56
|
+
out.append(f":p{count}")
|
|
57
|
+
count += 1
|
|
58
|
+
i += 2 if ch == "%" else 1
|
|
59
|
+
continue
|
|
60
|
+
elif ch == ":" and _takes_colon(query, i):
|
|
61
|
+
out.append("\\:")
|
|
62
|
+
else:
|
|
63
|
+
out.append(ch)
|
|
64
|
+
i += 1
|
|
65
|
+
values = list(params or ())
|
|
66
|
+
if len(values) != count:
|
|
67
|
+
raise ValueError(f"{count} placeholders and {len(values)} parameters in: {query[:120]}")
|
|
68
|
+
return "".join(out), {f"p{n}": value for n, value in enumerate(values)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _takes_colon(query: str, i: int) -> bool:
|
|
72
|
+
"""Whether SQLAlchemy would read the colon at i as the start of a parameter."""
|
|
73
|
+
before = query[i - 1] if i else ""
|
|
74
|
+
after = query[i + 1] if i + 1 < len(query) else ""
|
|
75
|
+
return before != ":" and after != ":" and (after.isalnum() or after == "_")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def positional_many(query: str, rows: Sequence[Sequence[Any]]) -> Tuple[str, List[Dict[str, Any]]]:
|
|
79
|
+
"""The query with named placeholders, and one parameter dictionary per row."""
|
|
80
|
+
named, _ = positional(query, rows[0] if rows else [None] * query.count("?"))
|
|
81
|
+
return named, [positional(query, row)[1] for row in rows]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def insert_returning_id(query: str, params: Optional[Sequence[Any]] = None) -> Optional[int]:
|
|
85
|
+
"""Run one INSERT and return the id of the row it made, on both dialects.
|
|
86
|
+
|
|
87
|
+
What the removed manager's ``execute_commit`` answered; the query carries no RETURNING
|
|
88
|
+
of its own -- DatabaseManagerV2 adds it on PostgreSQL.
|
|
89
|
+
"""
|
|
90
|
+
from keepup_db import DatabaseManagerV2
|
|
91
|
+
|
|
92
|
+
row = DatabaseManagerV2.execute_commit_returning(*positional(query, params), "id")
|
|
93
|
+
return row["id"] if row else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def execute_many(query: str, rows: Sequence[Sequence[Any]]) -> bool:
|
|
97
|
+
"""Run one statement for many positional rows in one transaction.
|
|
98
|
+
|
|
99
|
+
True on success and False on a failure, which rolls the whole batch back --
|
|
100
|
+
what the removed manager's ``executemany_commit`` answered.
|
|
101
|
+
"""
|
|
102
|
+
if not rows:
|
|
103
|
+
return True
|
|
104
|
+
try:
|
|
105
|
+
from keepup_db import DatabaseManagerV2
|
|
106
|
+
|
|
107
|
+
DatabaseManagerV2.execute_many(*positional_many(query, rows))
|
|
108
|
+
return True
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logging.error(f"Batch statement failed and was rolled back: {e}")
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def raw_connection():
|
|
115
|
+
"""A driver connection out of DatabaseManagerV2's pool, for code written
|
|
116
|
+
against a cursor; ``close()`` hands it back to the pool.
|
|
117
|
+
|
|
118
|
+
The statements run on it are the driver's own: ``?`` on SQLite, ``%s`` on
|
|
119
|
+
PostgreSQL, with no conversion.
|
|
120
|
+
"""
|
|
121
|
+
from keepup_db import DatabaseManagerV2
|
|
122
|
+
|
|
123
|
+
with DatabaseManagerV2.get_session() as session:
|
|
124
|
+
engine = session.get_bind()
|
|
125
|
+
return engine.raw_connection()
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Tables declared once and created on either dialect.
|
|
2
|
+
|
|
3
|
+
Until this module every table was written out twice -- once for PostgreSQL and
|
|
4
|
+
once for SQLite -- and every column added later was a third and fourth copy in
|
|
5
|
+
an ``ALTER``. The copies drifted: a foreign key on one side only, ``VARCHAR``
|
|
6
|
+
on one and ``TEXT`` on the other, a column added to the PostgreSQL branch and
|
|
7
|
+
forgotten in the SQLite one.
|
|
8
|
+
|
|
9
|
+
A table is now a SQLAlchemy ``Table`` in the owner's data-access module, and
|
|
10
|
+
``ensure_tables()`` makes the database match it, idempotently and on every
|
|
11
|
+
start -- there are still no migrations:
|
|
12
|
+
|
|
13
|
+
- the table is created with ``CREATE TABLE IF NOT EXISTS``, which, unlike
|
|
14
|
+
inspecting first and creating second, is safe when two replicas start at
|
|
15
|
+
once;
|
|
16
|
+
- a declared column the table lacks is added -- one catalogue read per table,
|
|
17
|
+
not an ``ALTER`` per column per start, which on PostgreSQL would take a lock
|
|
18
|
+
each time;
|
|
19
|
+
- declared indexes are created ``IF NOT EXISTS``.
|
|
20
|
+
|
|
21
|
+
What a column cannot say for both dialects at once is said with SQLAlchemy's
|
|
22
|
+
own means: ``with_variant()`` for a type, ``ddl_if(dialect=...)`` for a
|
|
23
|
+
constraint that only one dialect had. The DDL is compiled for the dialect in
|
|
24
|
+
force and run through ``DatabaseManagerV2.execute_commit`` -- the same path as
|
|
25
|
+
any other statement, so a test harness that rebinds the manager sees it too.
|
|
26
|
+
|
|
27
|
+
See doc/database_tables.md.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import re
|
|
32
|
+
from typing import Dict, Iterable, List, Set
|
|
33
|
+
|
|
34
|
+
from sqlalchemy import BigInteger, Column, ForeignKeyConstraint, Index, Integer, MetaData, Sequence, Table
|
|
35
|
+
from sqlalchemy import text as sql_text
|
|
36
|
+
from sqlalchemy.ext.compiler import compiles
|
|
37
|
+
from sqlalchemy.sql.expression import ColumnElement
|
|
38
|
+
from sqlalchemy.dialects import postgresql, sqlite
|
|
39
|
+
from sqlalchemy.schema import CreateColumn, CreateIndex, CreateSequence, CreateTable
|
|
40
|
+
|
|
41
|
+
from keepup import db as _db
|
|
42
|
+
|
|
43
|
+
#: What an application may import from this module. Everything else is
|
|
44
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
45
|
+
__all__ = [
|
|
46
|
+
"NOW",
|
|
47
|
+
"auto_id",
|
|
48
|
+
"ensure_columns",
|
|
49
|
+
"ensure_tables",
|
|
50
|
+
"foreign_key",
|
|
51
|
+
"identifier",
|
|
52
|
+
"per_dialect",
|
|
53
|
+
"table",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
#: One catalogue for every declared table of the process: a foreign key names
|
|
59
|
+
#: its target table, and SQLAlchemy resolves it here.
|
|
60
|
+
metadata = MetaData()
|
|
61
|
+
|
|
62
|
+
#: The default "now" of the hand-written DDL, kept as the database's own.
|
|
63
|
+
NOW = sql_text("CURRENT_TIMESTAMP")
|
|
64
|
+
|
|
65
|
+
_DIALECTS = {"postgres": postgresql.dialect(), "sqlite": sqlite.dialect()}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def dialect():
|
|
69
|
+
"""The SQLAlchemy dialect of the database in force."""
|
|
70
|
+
return _DIALECTS["postgres"] if _db.db_config.is_postgres() else _DIALECTS["sqlite"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def table(name: str, *columns, **kwargs) -> Table:
|
|
74
|
+
"""Declare a table in the shared catalogue.
|
|
75
|
+
|
|
76
|
+
``sqlite_autoincrement`` is on, as it was in every hand-written
|
|
77
|
+
``INTEGER PRIMARY KEY AUTOINCREMENT``: without it SQLite reuses the ids of
|
|
78
|
+
deleted rows, and an id that once meant another order is a bug waiting.
|
|
79
|
+
"""
|
|
80
|
+
kwargs.setdefault("sqlite_autoincrement", True)
|
|
81
|
+
# Test harnesses re-import data-access modules; a second import replaces
|
|
82
|
+
# the declaration instead of failing. Two different modules declaring one
|
|
83
|
+
# name is caught by tests/schema_declaration_tests.py, not here.
|
|
84
|
+
kwargs.setdefault("extend_existing", True)
|
|
85
|
+
declared = Table(name, metadata, *columns, **kwargs)
|
|
86
|
+
# SQLAlchemy writes NOT NULL on every key column; the hand-written SQLite
|
|
87
|
+
# DDL never did on a single-column key (``id INTEGER PRIMARY KEY
|
|
88
|
+
# AUTOINCREMENT``). On PostgreSQL the primary key makes the column NOT NULL
|
|
89
|
+
# by itself, so dropping the explicit word changes nothing there and keeps
|
|
90
|
+
# SQLite as it was. Composite keys were written with NOT NULL on each part
|
|
91
|
+
# and keep whatever the declaration says.
|
|
92
|
+
key = list(declared.primary_key.columns)
|
|
93
|
+
if len(key) == 1:
|
|
94
|
+
key[0].nullable = True
|
|
95
|
+
return declared
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sequence(name: str, start: int = 1, increment: int = 1) -> Sequence:
|
|
99
|
+
"""A PostgreSQL sequence read by hand (``nextval``); SQLite has none."""
|
|
100
|
+
return Sequence(name, start=start, increment=increment, metadata=metadata)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_sequences(*sequences: Sequence) -> None:
|
|
104
|
+
"""Create these sequences on PostgreSQL; nothing to do on SQLite."""
|
|
105
|
+
if not _db.db_config.is_postgres():
|
|
106
|
+
return
|
|
107
|
+
for declared in sequences:
|
|
108
|
+
_db.DatabaseManagerV2.execute_commit(
|
|
109
|
+
str(CreateSequence(declared, if_not_exists=True).compile(dialect=dialect())).strip())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class per_dialect(ColumnElement):
|
|
113
|
+
"""A server default (or any SQL fragment) that the two dialects spell differently.
|
|
114
|
+
|
|
115
|
+
``server_default=per_dialect(postgres="NOW()", sqlite="CURRENT_TIMESTAMP")``.
|
|
116
|
+
SQLAlchemy has ``with_variant`` for types but nothing for defaults, and a
|
|
117
|
+
default that differed between the hand-written branches must keep differing.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
inherit_cache = False
|
|
121
|
+
|
|
122
|
+
def __init__(self, postgres: str, sqlite: str):
|
|
123
|
+
self.postgres = postgres
|
|
124
|
+
self.sqlite = sqlite
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@compiles(per_dialect)
|
|
128
|
+
def _compile_per_dialect(element, compiler, **kw):
|
|
129
|
+
return element.postgres if compiler.dialect.name == "postgresql" else element.sqlite
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _wanted_here(item, current) -> bool:
|
|
133
|
+
"""Whether an index or constraint marked ``ddl_if(dialect=...)`` belongs to this dialect."""
|
|
134
|
+
condition = getattr(item, "_ddl_if", None)
|
|
135
|
+
if condition is None or not condition.dialect:
|
|
136
|
+
return True
|
|
137
|
+
wanted = condition.dialect if isinstance(condition.dialect, (list, tuple, set)) else [condition.dialect]
|
|
138
|
+
return current.name in wanted
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def foreign_key(columns, target: str, target_columns=("id",), **kwargs) -> ForeignKeyConstraint:
|
|
142
|
+
"""A foreign key to a table that may be declared by another module, later.
|
|
143
|
+
|
|
144
|
+
SQLAlchemy resolves the target in the catalogue when compiling. Modules are
|
|
145
|
+
imported in whatever order start-up and tests happen to use, so the target
|
|
146
|
+
may not be declared yet; a placeholder holding just the referenced columns
|
|
147
|
+
stands in (``keep_existing``: it never overrides a real declaration), and
|
|
148
|
+
the real declaration replaces it when it comes (``extend_existing``).
|
|
149
|
+
The placeholder is never created: ``ensure_tables`` makes only the tables
|
|
150
|
+
it is given.
|
|
151
|
+
"""
|
|
152
|
+
if target not in metadata.tables:
|
|
153
|
+
Table(target, metadata, *[Column(name, Integer, primary_key=True) for name in target_columns],
|
|
154
|
+
keep_existing=True)
|
|
155
|
+
columns = [columns] if isinstance(columns, str) else list(columns)
|
|
156
|
+
return ForeignKeyConstraint(columns, [f"{target}.{c}" for c in target_columns], **kwargs)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def auto_id(name: str = "id") -> Column:
|
|
160
|
+
"""``SERIAL PRIMARY KEY`` / ``INTEGER PRIMARY KEY AUTOINCREMENT``."""
|
|
161
|
+
return Column(name, Integer, primary_key=True, autoincrement=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def big_auto_id(name: str = "id") -> Column:
|
|
165
|
+
"""``BIGSERIAL PRIMARY KEY`` on PostgreSQL, the same integer key on SQLite."""
|
|
166
|
+
return Column(name, BigInteger().with_variant(Integer(), "sqlite"),
|
|
167
|
+
primary_key=True, autoincrement=True)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def create_statements(tables: Iterable[Table]) -> List[str]:
|
|
171
|
+
"""The DDL ``ensure_tables`` runs for these tables, compiled for the dialect in force."""
|
|
172
|
+
current = dialect()
|
|
173
|
+
statements = []
|
|
174
|
+
for declared in tables:
|
|
175
|
+
statements.append(str(CreateTable(declared, if_not_exists=True).compile(dialect=current)).strip())
|
|
176
|
+
for index in sorted(declared.indexes, key=lambda i: i.name or ""):
|
|
177
|
+
if not _wanted_here(index, current):
|
|
178
|
+
continue
|
|
179
|
+
statements.append(str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip())
|
|
180
|
+
return statements
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def existing_columns(name: str) -> Set[str]:
|
|
184
|
+
"""The columns the table has now, asked of the database in one read."""
|
|
185
|
+
manager = _db.DatabaseManagerV2
|
|
186
|
+
if _db.db_config.is_postgres():
|
|
187
|
+
# current_schema(): a table of the same name in another schema must not
|
|
188
|
+
# make a column look present when it is not.
|
|
189
|
+
rows = manager.execute(
|
|
190
|
+
"SELECT column_name FROM information_schema.columns "
|
|
191
|
+
"WHERE table_name = :table AND table_schema = current_schema()",
|
|
192
|
+
{"table": name})
|
|
193
|
+
return {row["column_name"] for row in rows or []}
|
|
194
|
+
# PRAGMA takes no parameters; the name comes from a declaration, never a request.
|
|
195
|
+
rows = manager.execute(f'PRAGMA table_info("{identifier(name)}")')
|
|
196
|
+
return {row["name"] for row in rows or []}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
#: What a table or column may be called. Checked rather than escaped: an
|
|
200
|
+
#: identifier of a schema is written by the code that owns it, so anything
|
|
201
|
+
#: outside this shape is a mistake to stop, not a string to quote. The package
|
|
202
|
+
#: offers a template where a table name is data (a table per application), and
|
|
203
|
+
#: an author who copies it without a check of their own would otherwise reach
|
|
204
|
+
#: the DDL with whatever arrived (task keepup-15).
|
|
205
|
+
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def identifier(name: str) -> str:
|
|
209
|
+
"""The name, if it is one.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
name: a table or column name about to be written into a statement.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The same name.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: when it is not a plain identifier.
|
|
219
|
+
"""
|
|
220
|
+
if not isinstance(name, str) or not IDENTIFIER.match(name):
|
|
221
|
+
raise ValueError(f"{name!r} is not a usable table or column name")
|
|
222
|
+
return name
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def column_definition(column: Column) -> str:
|
|
226
|
+
"""What follows the column name in ``ADD COLUMN``, for the dialect in force."""
|
|
227
|
+
spec = str(CreateColumn(column).compile(dialect=dialect())).strip()
|
|
228
|
+
quoted = dialect().identifier_preparer.quote(column.name)
|
|
229
|
+
for prefix in (quoted + " ", column.name + " "):
|
|
230
|
+
if spec.startswith(prefix):
|
|
231
|
+
return spec[len(prefix):]
|
|
232
|
+
return spec
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def ensure_tables(*tables: Table) -> Dict[str, List[str]]:
|
|
236
|
+
"""Make the database hold these tables, their columns and their indexes.
|
|
237
|
+
|
|
238
|
+
Returns, per table, the columns this call added -- empty when the table was
|
|
239
|
+
already complete. Order matters only for foreign keys, and the caller passes
|
|
240
|
+
tables in the order their references need, as the hand-written DDL did.
|
|
241
|
+
"""
|
|
242
|
+
manager = _db.DatabaseManagerV2
|
|
243
|
+
current = dialect()
|
|
244
|
+
added: Dict[str, List[str]] = {}
|
|
245
|
+
for declared in tables:
|
|
246
|
+
manager.execute_commit(
|
|
247
|
+
str(CreateTable(declared, if_not_exists=True).compile(dialect=current)).strip())
|
|
248
|
+
added[declared.name] = _add_missing(declared.name, declared.columns)
|
|
249
|
+
for index in sorted(declared.indexes, key=lambda i: i.name or ""):
|
|
250
|
+
if not _wanted_here(index, current):
|
|
251
|
+
continue
|
|
252
|
+
manager.execute_commit(
|
|
253
|
+
str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip())
|
|
254
|
+
return added
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _add_missing(name: str, columns: Iterable[Column]) -> List[str]:
|
|
258
|
+
manager = _db.DatabaseManagerV2
|
|
259
|
+
present = existing_columns(name)
|
|
260
|
+
missing = [c for c in columns if c.name not in present]
|
|
261
|
+
for column in missing:
|
|
262
|
+
manager.add_column_if_missing(name, column.name, column_definition(column))
|
|
263
|
+
return [c.name for c in missing]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def ensure_columns(name: str, *columns: Column) -> List[str]:
|
|
267
|
+
"""Add columns to a table another module declares.
|
|
268
|
+
|
|
269
|
+
The application extends tables of the framework (``users`` gets a tenant's
|
|
270
|
+
SSH key, a Telegram persona); the framework cannot know those columns, and
|
|
271
|
+
declaring ``users`` a second time would make two owners of one table. The
|
|
272
|
+
columns are declared here, by the module that needs them, and added to the
|
|
273
|
+
existing table -- never creating it: that is its owner's job.
|
|
274
|
+
"""
|
|
275
|
+
for column in columns:
|
|
276
|
+
if getattr(column, "table", None) is None:
|
|
277
|
+
# A free column cannot be compiled; a throw-away table outside the
|
|
278
|
+
# catalogue gives it a parent without declaring anything.
|
|
279
|
+
Table(f"_columns_of_{name}", MetaData(), column)
|
|
280
|
+
return _add_missing(name, columns)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def ensure_indexes(name: str, *indexes: Index) -> None:
|
|
284
|
+
"""Create indexes on a table another module declares.
|
|
285
|
+
|
|
286
|
+
The counterpart of ``ensure_columns``: a plugin that joins against a table
|
|
287
|
+
it does not own may need an index there, and declaring the table a second
|
|
288
|
+
time to carry it would make two owners of one table. Each index is given
|
|
289
|
+
by name and column names; the table is never created here.
|
|
290
|
+
"""
|
|
291
|
+
current = dialect()
|
|
292
|
+
for index in indexes:
|
|
293
|
+
if index.table is None:
|
|
294
|
+
# Unbound columns cannot be compiled; a throw-away table outside the
|
|
295
|
+
# catalogue gives the index its columns without declaring anything.
|
|
296
|
+
Table(f"_indexes_of_{name}", MetaData(),
|
|
297
|
+
*[Column(expression, Integer) for expression in index.expressions], index)
|
|
298
|
+
if not _wanted_here(index, current):
|
|
299
|
+
continue
|
|
300
|
+
text = str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip()
|
|
301
|
+
# The throw-away table's name is not the real one.
|
|
302
|
+
text = text.replace(f"_indexes_of_{name}", name)
|
|
303
|
+
_db.DatabaseManagerV2.execute_commit(text)
|
|
304
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-db
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
9
|
+
|
|
10
|
+
# keepup-db
|
|
11
|
+
|
|
12
|
+
The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
|
|
13
|
+
|
|
14
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
15
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
16
|
+
this distribution carries one part of it. It is installed on its own
|
|
17
|
+
(`pip install keepup-db`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
18
|
+
and it declares itself to the kernel through its
|
|
19
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
keepup_db/__init__.py
|
|
4
|
+
keepup_db/manager.py
|
|
5
|
+
keepup_db/positional_sql.py
|
|
6
|
+
keepup_db/tables.py
|
|
7
|
+
keepup_db.egg-info/PKG-INFO
|
|
8
|
+
keepup_db.egg-info/SOURCES.txt
|
|
9
|
+
keepup_db.egg-info/dependency_links.txt
|
|
10
|
+
keepup_db.egg-info/entry_points.txt
|
|
11
|
+
keepup_db.egg-info/requires.txt
|
|
12
|
+
keepup_db.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepup_db
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keepup-db"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"keepup-admin>=0.4.0",
|
|
13
|
+
"sqlalchemy>=2.0,<3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.entry-points."keepup.plugins"]
|
|
17
|
+
db = "keepup_db:DbPlugin"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["keepup_db"]
|