pyaccesskit 0.1.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.
- pyaccesskit/AGENT_GUIDE.md +455 -0
- pyaccesskit/__init__.py +167 -0
- pyaccesskit/__main__.py +6 -0
- pyaccesskit/_backends/__init__.py +0 -0
- pyaccesskit/_backends/access/__init__.py +1 -0
- pyaccesskit/_backends/access/design.py +415 -0
- pyaccesskit/_backends/dao/__init__.py +1 -0
- pyaccesskit/_backends/dao/profile.py +40 -0
- pyaccesskit/_backends/dao/schema.py +805 -0
- pyaccesskit/_backends/dao/typemap.py +390 -0
- pyaccesskit/_backends/fake/__init__.py +3 -0
- pyaccesskit/_backends/fake/backend.py +680 -0
- pyaccesskit/_backends/protocols.py +339 -0
- pyaccesskit/_com/__init__.py +1 -0
- pyaccesskit/_com/constants.py +394 -0
- pyaccesskit/_com/dispatch.py +50 -0
- pyaccesskit/_com/errors.py +184 -0
- pyaccesskit/_com/gateway.py +199 -0
- pyaccesskit/_com/raw.py +164 -0
- pyaccesskit/_com/runtime.py +39 -0
- pyaccesskit/_com/variants.py +72 -0
- pyaccesskit/_engines/__init__.py +48 -0
- pyaccesskit/_engines/access.py +300 -0
- pyaccesskit/_engines/inproc.py +148 -0
- pyaccesskit/_engines/probe.py +231 -0
- pyaccesskit/_ledger.py +158 -0
- pyaccesskit/_ops/__init__.py +0 -0
- pyaccesskit/_ops/design.py +127 -0
- pyaccesskit/_ops/schema.py +471 -0
- pyaccesskit/_session/__init__.py +1 -0
- pyaccesskit/_session/protocols.py +78 -0
- pyaccesskit/_session/session.py +354 -0
- pyaccesskit/_text/__init__.py +0 -0
- pyaccesskit/_text/codec.py +114 -0
- pyaccesskit/_version.py +3 -0
- pyaccesskit/_win/__init__.py +1 -0
- pyaccesskit/_win/access_process.py +348 -0
- pyaccesskit/_win/console.py +56 -0
- pyaccesskit/_win/inspector.py +53 -0
- pyaccesskit/_win/job.py +65 -0
- pyaccesskit/_win/processes.py +159 -0
- pyaccesskit/_win/watchdog.py +253 -0
- pyaccesskit/cli/__init__.py +10 -0
- pyaccesskit/cli/_output.py +101 -0
- pyaccesskit/cli/agent.py +99 -0
- pyaccesskit/cli/app.py +54 -0
- pyaccesskit/cli/cleanup.py +56 -0
- pyaccesskit/cli/doctor.py +101 -0
- pyaccesskit/cli/inspection.py +223 -0
- pyaccesskit/database.py +296 -0
- pyaccesskit/diagnostics.py +319 -0
- pyaccesskit/enums.py +258 -0
- pyaccesskit/errors.py +407 -0
- pyaccesskit/forms/__init__.py +45 -0
- pyaccesskit/forms/builder.py +295 -0
- pyaccesskit/forms/collection.py +117 -0
- pyaccesskit/forms/controls.py +157 -0
- pyaccesskit/forms/layout.py +300 -0
- pyaccesskit/forms/spec.py +169 -0
- pyaccesskit/forms/vba.py +138 -0
- pyaccesskit/maintenance.py +32 -0
- pyaccesskit/modules.py +101 -0
- pyaccesskit/objects.py +81 -0
- pyaccesskit/options.py +40 -0
- pyaccesskit/properties.py +74 -0
- pyaccesskit/py.typed +0 -0
- pyaccesskit/queries.py +190 -0
- pyaccesskit/relationships.py +143 -0
- pyaccesskit/schema/__init__.py +73 -0
- pyaccesskit/schema/_base.py +55 -0
- pyaccesskit/schema/_reserved_words.py +55 -0
- pyaccesskit/schema/columns.py +609 -0
- pyaccesskit/schema/compat.py +57 -0
- pyaccesskit/schema/expressions.py +162 -0
- pyaccesskit/schema/indexes.py +114 -0
- pyaccesskit/schema/names.py +122 -0
- pyaccesskit/schema/queries.py +192 -0
- pyaccesskit/schema/relationships.py +132 -0
- pyaccesskit/schema/tables.py +178 -0
- pyaccesskit/tables.py +333 -0
- pyaccesskit/units.py +301 -0
- pyaccesskit-0.1.0.dist-info/METADATA +201 -0
- pyaccesskit-0.1.0.dist-info/RECORD +86 -0
- pyaccesskit-0.1.0.dist-info/WHEEL +4 -0
- pyaccesskit-0.1.0.dist-info/entry_points.txt +2 -0
- pyaccesskit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Schema operations: semantic pre-validation + linting, then one backend call.
|
|
2
|
+
|
|
3
|
+
Every public schema mutation goes through here, so users get the same precise errors regardless of the
|
|
4
|
+
backend (in-process DAO, Access-hosted DAO, or the in-memory fake) — and most mistakes are reported
|
|
5
|
+
before Access is touched at all.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from pyaccesskit._backends.protocols import SchemaBackend, TableInfo
|
|
15
|
+
from pyaccesskit.enums import DataType, ObjectKind
|
|
16
|
+
from pyaccesskit.errors import (
|
|
17
|
+
ObjectExistsError,
|
|
18
|
+
ObjectNotFoundError,
|
|
19
|
+
RelationshipError,
|
|
20
|
+
SchemaError,
|
|
21
|
+
SpecError,
|
|
22
|
+
)
|
|
23
|
+
from pyaccesskit.schema import (
|
|
24
|
+
ColumnBase,
|
|
25
|
+
ColumnSpec,
|
|
26
|
+
IndexField,
|
|
27
|
+
IndexSpec,
|
|
28
|
+
QuerySpec,
|
|
29
|
+
RelationshipSpec,
|
|
30
|
+
TableSpec,
|
|
31
|
+
UnsupportedColumn,
|
|
32
|
+
sql_equivalent,
|
|
33
|
+
)
|
|
34
|
+
from pyaccesskit.schema.compat import relationship_compatible, storage_type
|
|
35
|
+
from pyaccesskit.schema.names import check_name, warn_name
|
|
36
|
+
from pyaccesskit.schema.tables import MAX_INDEXES
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"add_column",
|
|
40
|
+
"create_index",
|
|
41
|
+
"create_or_replace_query",
|
|
42
|
+
"create_relationship",
|
|
43
|
+
"create_table",
|
|
44
|
+
"drop_column",
|
|
45
|
+
"drop_index",
|
|
46
|
+
"drop_query",
|
|
47
|
+
"drop_relationship",
|
|
48
|
+
"drop_table",
|
|
49
|
+
"find_query",
|
|
50
|
+
"find_table",
|
|
51
|
+
"rename_column",
|
|
52
|
+
"rename_query",
|
|
53
|
+
"rename_table",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _key(name: str) -> str:
|
|
58
|
+
return name.casefold()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------------------------- lookups
|
|
62
|
+
def find_table(schema: SchemaBackend, name: str) -> TableInfo | None:
|
|
63
|
+
"""The table called ``name`` (case-insensitive), or ``None``."""
|
|
64
|
+
key = _key(name)
|
|
65
|
+
return next((t for t in schema.list_tables() if _key(t.name) == key), None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_table(schema: SchemaBackend, name: str) -> TableInfo:
|
|
69
|
+
"""Like :func:`find_table` but raises :class:`ObjectNotFoundError`."""
|
|
70
|
+
info = find_table(schema, name)
|
|
71
|
+
if info is None:
|
|
72
|
+
raise ObjectNotFoundError(
|
|
73
|
+
f"table {name!r} does not exist", kind=ObjectKind.TABLE, name=name
|
|
74
|
+
)
|
|
75
|
+
return info
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_query(schema: SchemaBackend, name: str) -> str | None:
|
|
79
|
+
"""The actual name of the query called ``name`` (case-insensitive), or ``None``."""
|
|
80
|
+
key = _key(name)
|
|
81
|
+
return next((q.name for q in schema.list_queries() if _key(q.name) == key), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_name_free(schema: SchemaBackend, name: str, *, ignore: str | None = None) -> None:
|
|
85
|
+
"""Raise :class:`ObjectExistsError` if a table or query already uses ``name``.
|
|
86
|
+
|
|
87
|
+
Tables and queries share one namespace in Access. ``ignore`` skips an object being renamed.
|
|
88
|
+
"""
|
|
89
|
+
key = _key(name)
|
|
90
|
+
if ignore is not None and _key(ignore) == key:
|
|
91
|
+
return
|
|
92
|
+
table = find_table(schema, name)
|
|
93
|
+
if table is not None:
|
|
94
|
+
raise ObjectExistsError(
|
|
95
|
+
f"a table named {table.name!r} already exists", kind=ObjectKind.TABLE, name=name
|
|
96
|
+
)
|
|
97
|
+
query = find_query(schema, name)
|
|
98
|
+
if query is not None:
|
|
99
|
+
raise ObjectExistsError(
|
|
100
|
+
f"a query named {query!r} already exists (tables and queries share one namespace)",
|
|
101
|
+
kind=ObjectKind.QUERY,
|
|
102
|
+
name=name,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _column(spec: TableSpec, name: str) -> ColumnBase:
|
|
107
|
+
try:
|
|
108
|
+
return spec.column(name)
|
|
109
|
+
except KeyError:
|
|
110
|
+
raise ObjectNotFoundError(
|
|
111
|
+
f"table {spec.name!r} has no column {name!r} (columns: {', '.join(spec.column_names)})",
|
|
112
|
+
kind=ObjectKind.FIELD,
|
|
113
|
+
name=name,
|
|
114
|
+
) from None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _lint_table(spec: TableSpec) -> None:
|
|
118
|
+
warn_name(spec.name, what="table name", stacklevel=5)
|
|
119
|
+
for column in spec.columns:
|
|
120
|
+
warn_name(column.name, what=f"column name in table {spec.name!r}", stacklevel=5)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -------------------------------------------------------------------------------------- tables
|
|
124
|
+
def _check_creatable(column: ColumnBase, table: str) -> None:
|
|
125
|
+
if isinstance(column, UnsupportedColumn):
|
|
126
|
+
raise SpecError(
|
|
127
|
+
f"cannot create column {column.name!r} of table {table!r}: {column.detail or 'this type'} "
|
|
128
|
+
"columns can be read but not created by PyAccessKit yet"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def create_table(schema: SchemaBackend, spec: TableSpec, *, lint: bool = True) -> TableSpec:
|
|
133
|
+
"""Create a table; returns the normalized spec that was created."""
|
|
134
|
+
for column in spec.columns:
|
|
135
|
+
_check_creatable(column, spec.name)
|
|
136
|
+
normalized = spec.normalized()
|
|
137
|
+
ensure_name_free(schema, normalized.name)
|
|
138
|
+
if lint:
|
|
139
|
+
_lint_table(normalized)
|
|
140
|
+
schema.create_table(normalized)
|
|
141
|
+
return normalized
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def drop_table(schema: SchemaBackend, name: str, *, drop_relationships: bool = False) -> None:
|
|
145
|
+
"""Delete a table (optionally deleting the relationships it takes part in first)."""
|
|
146
|
+
info = require_table(schema, name)
|
|
147
|
+
related = [
|
|
148
|
+
rel
|
|
149
|
+
for rel in schema.list_relationships()
|
|
150
|
+
if _key(rel.primary_table) == _key(info.name) or _key(rel.foreign_table) == _key(info.name)
|
|
151
|
+
]
|
|
152
|
+
if related and not drop_relationships:
|
|
153
|
+
names = ", ".join(repr(rel.effective_name) for rel in related)
|
|
154
|
+
raise SchemaError(
|
|
155
|
+
f"table {info.name!r} is part of relationship(s) {names}; delete them first or pass "
|
|
156
|
+
"drop_relationships=True"
|
|
157
|
+
)
|
|
158
|
+
for rel in related:
|
|
159
|
+
schema.drop_relationship(rel.effective_name)
|
|
160
|
+
schema.drop_table(info.name)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def rename_table(schema: SchemaBackend, old: str, new: str) -> str:
|
|
164
|
+
"""Rename a table; returns the new name."""
|
|
165
|
+
info = require_table(schema, old)
|
|
166
|
+
check_name(new, what="table name")
|
|
167
|
+
ensure_name_free(schema, new, ignore=info.name)
|
|
168
|
+
warn_name(new, what="table name", stacklevel=4)
|
|
169
|
+
schema.rename_table(info.name, new)
|
|
170
|
+
return new
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _index_budget(schema: SchemaBackend, table: str, extra: int) -> None:
|
|
174
|
+
used = schema.count_indexes(table)
|
|
175
|
+
if used + extra > MAX_INDEXES:
|
|
176
|
+
raise SchemaError(
|
|
177
|
+
f"table {table!r} would need {used + extra} indexes; Access allows {MAX_INDEXES} per table "
|
|
178
|
+
"(including hidden indexes of enforced relationships)"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def add_column(schema: SchemaBackend, table: str, column: ColumnSpec, *, lint: bool = True) -> None:
|
|
183
|
+
"""Append a column, creating any index requested with ``unique=``/``indexed=``/``primary_key=``."""
|
|
184
|
+
info = require_table(schema, table)
|
|
185
|
+
_check_creatable(column, info.name)
|
|
186
|
+
spec = schema.read_table(info.name)
|
|
187
|
+
if any(_key(c.name) == _key(column.name) for c in spec.columns):
|
|
188
|
+
raise ObjectExistsError(
|
|
189
|
+
f"table {spec.name!r} already has a column {column.name!r}",
|
|
190
|
+
kind=ObjectKind.FIELD,
|
|
191
|
+
name=column.name,
|
|
192
|
+
)
|
|
193
|
+
if column.data_type is DataType.AUTONUMBER and any(
|
|
194
|
+
c.data_type is DataType.AUTONUMBER for c in spec.columns
|
|
195
|
+
):
|
|
196
|
+
raise SchemaError(f"table {spec.name!r} already has an AutoNumber column")
|
|
197
|
+
new_indexes: list[IndexSpec] = []
|
|
198
|
+
if column.primary_key:
|
|
199
|
+
if spec.primary_index is not None:
|
|
200
|
+
raise SchemaError(f"table {spec.name!r} already has a primary key")
|
|
201
|
+
new_indexes.append(IndexSpec.primary_key(column.name))
|
|
202
|
+
elif column.unique or column.indexed:
|
|
203
|
+
new_indexes.append(
|
|
204
|
+
IndexSpec(
|
|
205
|
+
name=column.name, fields=(IndexField(name=column.name),), unique=column.unique
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
for index in new_indexes: # check before mutating: a clash would otherwise strand the column
|
|
209
|
+
if any(_key(i.name) == _key(index.name) for i in spec.indexes):
|
|
210
|
+
raise ObjectExistsError(
|
|
211
|
+
f"table {spec.name!r} already has an index named {index.name!r}; add the column "
|
|
212
|
+
"without the shorthand and create the index with an explicit name",
|
|
213
|
+
kind=ObjectKind.INDEX,
|
|
214
|
+
name=index.name,
|
|
215
|
+
)
|
|
216
|
+
if new_indexes:
|
|
217
|
+
_index_budget(schema, spec.name, len(new_indexes))
|
|
218
|
+
if lint:
|
|
219
|
+
warn_name(column.name, what=f"column name in table {spec.name!r}", stacklevel=4)
|
|
220
|
+
schema.add_column(spec.name, column.normalized())
|
|
221
|
+
created: list[str] = []
|
|
222
|
+
try:
|
|
223
|
+
for index in new_indexes:
|
|
224
|
+
schema.create_index(spec.name, index)
|
|
225
|
+
created.append(index.name)
|
|
226
|
+
except BaseException:
|
|
227
|
+
with contextlib.suppress(Exception):
|
|
228
|
+
for name in reversed(created):
|
|
229
|
+
schema.drop_index(spec.name, name)
|
|
230
|
+
schema.drop_column(spec.name, column.name)
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def drop_column(schema: SchemaBackend, table: str, column: str) -> None:
|
|
235
|
+
"""Delete a column (it must not be part of an index or relationship)."""
|
|
236
|
+
info = require_table(schema, table)
|
|
237
|
+
spec = schema.read_table(info.name)
|
|
238
|
+
target = _column(spec, column)
|
|
239
|
+
indexes = [
|
|
240
|
+
i.name for i in spec.indexes if any(_key(f) == _key(target.name) for f in i.field_names)
|
|
241
|
+
]
|
|
242
|
+
if indexes:
|
|
243
|
+
raise SchemaError(f"column {target.name!r} is part of index(es) {indexes}; drop them first")
|
|
244
|
+
for rel in schema.list_relationships():
|
|
245
|
+
sides: list[str] = []
|
|
246
|
+
if _key(rel.primary_table) == _key(spec.name):
|
|
247
|
+
sides.extend(rel.primary_columns)
|
|
248
|
+
if _key(rel.foreign_table) == _key(spec.name):
|
|
249
|
+
sides.extend(rel.foreign_columns)
|
|
250
|
+
if any(_key(c) == _key(target.name) for c in sides):
|
|
251
|
+
raise SchemaError(
|
|
252
|
+
f"column {target.name!r} is part of relationship {rel.effective_name!r}; drop it first"
|
|
253
|
+
)
|
|
254
|
+
if len(spec.columns) == 1:
|
|
255
|
+
raise SchemaError(f"cannot drop the last column of table {spec.name!r}")
|
|
256
|
+
schema.drop_column(spec.name, target.name)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def rename_column(schema: SchemaBackend, table: str, old: str, new: str) -> None:
|
|
260
|
+
"""Rename a column."""
|
|
261
|
+
info = require_table(schema, table)
|
|
262
|
+
spec = schema.read_table(info.name)
|
|
263
|
+
target = _column(spec, old)
|
|
264
|
+
check_name(new, what="column name")
|
|
265
|
+
if _key(old) != _key(new) and any(_key(c.name) == _key(new) for c in spec.columns):
|
|
266
|
+
raise ObjectExistsError(
|
|
267
|
+
f"table {spec.name!r} already has a column {new!r}", kind=ObjectKind.FIELD, name=new
|
|
268
|
+
)
|
|
269
|
+
warn_name(new, what=f"column name in table {spec.name!r}", stacklevel=4)
|
|
270
|
+
schema.rename_column(spec.name, target.name, new)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def create_index(schema: SchemaBackend, table: str, index: IndexSpec) -> None:
|
|
274
|
+
"""Create an index after checking columns, name and the 32-index budget."""
|
|
275
|
+
info = require_table(schema, table)
|
|
276
|
+
spec = schema.read_table(info.name)
|
|
277
|
+
for field_name in index.field_names:
|
|
278
|
+
column = _column(spec, field_name)
|
|
279
|
+
if not column.indexable:
|
|
280
|
+
raise SpecError(f"{column.data_type.value} column {column.name!r} cannot be indexed")
|
|
281
|
+
if index.primary and spec.primary_index is not None:
|
|
282
|
+
raise SchemaError(
|
|
283
|
+
f"table {spec.name!r} already has a primary key ({spec.primary_index.name!r})"
|
|
284
|
+
)
|
|
285
|
+
if any(_key(i.name) == _key(index.name) for i in spec.indexes):
|
|
286
|
+
raise ObjectExistsError(
|
|
287
|
+
f"table {spec.name!r} already has an index named {index.name!r}",
|
|
288
|
+
kind=ObjectKind.INDEX,
|
|
289
|
+
name=index.name,
|
|
290
|
+
)
|
|
291
|
+
_index_budget(schema, spec.name, 1)
|
|
292
|
+
schema.create_index(spec.name, index)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def drop_index(schema: SchemaBackend, table: str, name: str) -> None:
|
|
296
|
+
"""Delete an index."""
|
|
297
|
+
info = require_table(schema, table)
|
|
298
|
+
spec = schema.read_table(info.name)
|
|
299
|
+
match = next((i for i in spec.indexes if _key(i.name) == _key(name)), None)
|
|
300
|
+
if match is None:
|
|
301
|
+
raise ObjectNotFoundError(
|
|
302
|
+
f"table {spec.name!r} has no index {name!r}", kind=ObjectKind.INDEX, name=name
|
|
303
|
+
)
|
|
304
|
+
for rel in schema.list_relationships():
|
|
305
|
+
if (
|
|
306
|
+
match.unique
|
|
307
|
+
and _key(rel.primary_table) == _key(spec.name)
|
|
308
|
+
and {_key(c) for c in rel.primary_columns} == {_key(f) for f in match.field_names}
|
|
309
|
+
):
|
|
310
|
+
raise SchemaError(
|
|
311
|
+
f"index {match.name!r} is required by relationship {rel.effective_name!r}; drop the relationship first"
|
|
312
|
+
)
|
|
313
|
+
schema.drop_index(spec.name, match.name)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------------------- relationships
|
|
317
|
+
def _canonical_columns(spec: TableSpec, names: Iterable[str]) -> tuple[ColumnBase, ...]:
|
|
318
|
+
return tuple(_column(spec, name) for name in names)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def create_relationship(schema: SchemaBackend, spec: RelationshipSpec) -> RelationshipSpec:
|
|
322
|
+
"""Validate a relationship thoroughly, then create it. Returns the normalized spec used."""
|
|
323
|
+
primary_info = require_table(schema, spec.primary_table)
|
|
324
|
+
foreign_info = require_table(schema, spec.foreign_table)
|
|
325
|
+
primary = schema.read_table(primary_info.name)
|
|
326
|
+
foreign = schema.read_table(foreign_info.name)
|
|
327
|
+
primary_cols = _canonical_columns(primary, spec.primary_columns)
|
|
328
|
+
foreign_cols = _canonical_columns(foreign, spec.foreign_columns)
|
|
329
|
+
|
|
330
|
+
normalized = spec.model_copy(
|
|
331
|
+
update={
|
|
332
|
+
"primary_table": primary.name,
|
|
333
|
+
"foreign_table": foreign.name,
|
|
334
|
+
"primary_columns": tuple(c.name for c in primary_cols),
|
|
335
|
+
"foreign_columns": tuple(c.name for c in foreign_cols),
|
|
336
|
+
"name": spec.name or f"{primary.name}{foreign.name}",
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
name = normalized.effective_name
|
|
340
|
+
existing = schema.list_relationships()
|
|
341
|
+
if any(_key(rel.effective_name) == _key(name) for rel in existing):
|
|
342
|
+
hint = (
|
|
343
|
+
""
|
|
344
|
+
if spec.name
|
|
345
|
+
else "; pass name=... to create another relationship between these tables"
|
|
346
|
+
)
|
|
347
|
+
raise ObjectExistsError(
|
|
348
|
+
f"a relationship named {name!r} already exists{hint}",
|
|
349
|
+
kind=ObjectKind.RELATIONSHIP,
|
|
350
|
+
name=name,
|
|
351
|
+
)
|
|
352
|
+
if any(_key(i.name) == _key(name) for i in foreign.indexes):
|
|
353
|
+
raise ObjectExistsError(
|
|
354
|
+
f"relationship name {name!r} clashes with an index of table {foreign.name!r} (the relationship's "
|
|
355
|
+
"hidden index takes its name)",
|
|
356
|
+
kind=ObjectKind.RELATIONSHIP,
|
|
357
|
+
name=name,
|
|
358
|
+
)
|
|
359
|
+
for p_col, f_col in zip(primary_cols, foreign_cols, strict=True):
|
|
360
|
+
if not relationship_compatible(p_col, f_col):
|
|
361
|
+
raise RelationshipError(
|
|
362
|
+
f"cannot relate {primary.name}.{p_col.name} ({storage_type(p_col)}) to "
|
|
363
|
+
f"{foreign.name}.{f_col.name} ({storage_type(f_col)}): related columns must have the same "
|
|
364
|
+
"storage type (an AutoNumber pairs with a Number/Long Integer)"
|
|
365
|
+
)
|
|
366
|
+
wanted = {_key(c.name) for c in primary_cols}
|
|
367
|
+
if not any(i.unique and {_key(f) for f in i.field_names} == wanted for i in primary.indexes):
|
|
368
|
+
columns = ", ".join(c.name for c in primary_cols)
|
|
369
|
+
raise RelationshipError(
|
|
370
|
+
f"{primary.name}({columns}) must be the primary key or have a unique index to be referenced by "
|
|
371
|
+
"a relationship"
|
|
372
|
+
)
|
|
373
|
+
if normalized.enforce_integrity:
|
|
374
|
+
_index_budget(schema, foreign.name, 1)
|
|
375
|
+
warn_name(name, what="relationship name", stacklevel=4)
|
|
376
|
+
schema.create_relationship(normalized)
|
|
377
|
+
return normalized
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def drop_relationship(schema: SchemaBackend, name: str) -> None:
|
|
381
|
+
"""Delete a relationship."""
|
|
382
|
+
match = next(
|
|
383
|
+
(r for r in schema.list_relationships() if _key(r.effective_name) == _key(name)), None
|
|
384
|
+
)
|
|
385
|
+
if match is None:
|
|
386
|
+
raise ObjectNotFoundError(
|
|
387
|
+
f"relationship {name!r} does not exist", kind=ObjectKind.RELATIONSHIP, name=name
|
|
388
|
+
)
|
|
389
|
+
schema.drop_relationship(match.effective_name)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------------------------- queries
|
|
393
|
+
def create_or_replace_query(
|
|
394
|
+
schema: SchemaBackend, spec: QuerySpec, *, replace: bool = False
|
|
395
|
+
) -> bool:
|
|
396
|
+
"""Create a saved query, or replace its SQL when ``replace=True``.
|
|
397
|
+
|
|
398
|
+
Returns ``True`` if the query was created or changed, ``False`` if an identical query already existed.
|
|
399
|
+
"""
|
|
400
|
+
existing = find_query(schema, spec.name)
|
|
401
|
+
if existing is not None:
|
|
402
|
+
if not replace:
|
|
403
|
+
raise ObjectExistsError(
|
|
404
|
+
f"a query named {existing!r} already exists", kind=ObjectKind.QUERY, name=spec.name
|
|
405
|
+
)
|
|
406
|
+
current = schema.read_query(existing)
|
|
407
|
+
if current.pass_through != spec.pass_through:
|
|
408
|
+
_swap_query(schema, existing, spec.normalized())
|
|
409
|
+
return True
|
|
410
|
+
if spec.pass_through is not None:
|
|
411
|
+
# Server dialect: Access's SQL normalization (case folding...) does not apply.
|
|
412
|
+
unchanged = _line_endings(current.sql) == _line_endings(spec.sql)
|
|
413
|
+
else:
|
|
414
|
+
unchanged = sql_equivalent(current.sql, spec.sql)
|
|
415
|
+
if unchanged:
|
|
416
|
+
return False
|
|
417
|
+
schema.set_query_sql(existing, spec.normalized().sql)
|
|
418
|
+
return True
|
|
419
|
+
ensure_name_free(schema, spec.name)
|
|
420
|
+
warn_name(spec.name, what="query name", stacklevel=4)
|
|
421
|
+
schema.create_query(spec.normalized())
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _line_endings(sql: str) -> str:
|
|
426
|
+
return sql.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _swap_query(schema: SchemaBackend, existing: str, replacement: QuerySpec) -> None:
|
|
430
|
+
"""Replace a query whose type changes: build the new one first so a failure keeps the original."""
|
|
431
|
+
original = schema.read_query(existing)
|
|
432
|
+
temporary = f"~pak_tmp_{uuid.uuid4().hex[:8]}"
|
|
433
|
+
try:
|
|
434
|
+
schema.create_query(replacement.model_copy(update={"name": temporary}))
|
|
435
|
+
except BaseException:
|
|
436
|
+
with contextlib.suppress(Exception):
|
|
437
|
+
if find_query(schema, temporary) is not None:
|
|
438
|
+
schema.drop_query(temporary)
|
|
439
|
+
raise
|
|
440
|
+
try:
|
|
441
|
+
schema.drop_query(existing)
|
|
442
|
+
schema.rename_query(temporary, replacement.name)
|
|
443
|
+
except BaseException:
|
|
444
|
+
with contextlib.suppress(Exception):
|
|
445
|
+
if find_query(schema, existing) is None:
|
|
446
|
+
schema.create_query(original)
|
|
447
|
+
with contextlib.suppress(Exception):
|
|
448
|
+
schema.drop_query(temporary)
|
|
449
|
+
raise
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def rename_query(schema: SchemaBackend, old: str, new: str) -> str:
|
|
453
|
+
"""Rename a saved query; returns the new name."""
|
|
454
|
+
actual = find_query(schema, old)
|
|
455
|
+
if actual is None:
|
|
456
|
+
raise ObjectNotFoundError(f"query {old!r} does not exist", kind=ObjectKind.QUERY, name=old)
|
|
457
|
+
check_name(new, what="query name")
|
|
458
|
+
ensure_name_free(schema, new, ignore=actual)
|
|
459
|
+
warn_name(new, what="query name", stacklevel=4)
|
|
460
|
+
schema.rename_query(actual, new)
|
|
461
|
+
return new
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def drop_query(schema: SchemaBackend, name: str) -> None:
|
|
465
|
+
"""Delete a saved query."""
|
|
466
|
+
actual = find_query(schema, name)
|
|
467
|
+
if actual is None:
|
|
468
|
+
raise ObjectNotFoundError(
|
|
469
|
+
f"query {name!r} does not exist", kind=ObjectKind.QUERY, name=name
|
|
470
|
+
)
|
|
471
|
+
schema.drop_query(actual)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Session management (COM-free)."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""COM-free interfaces between the session and the engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal, Protocol
|
|
9
|
+
|
|
10
|
+
from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
|
|
11
|
+
from pyaccesskit.enums import Transport
|
|
12
|
+
from pyaccesskit.options import SessionOptions
|
|
13
|
+
|
|
14
|
+
__all__ = ["EngineHandle", "EnginePlan", "EnvironmentProbe", "RawKind"]
|
|
15
|
+
|
|
16
|
+
RawKind = Literal["dao", "dbengine", "access"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EnginePlan:
|
|
21
|
+
"""What engine to open, on which file, and how."""
|
|
22
|
+
|
|
23
|
+
kind: Literal["dao", "access"]
|
|
24
|
+
path: Path
|
|
25
|
+
create: bool
|
|
26
|
+
readonly: bool
|
|
27
|
+
exclusive: bool
|
|
28
|
+
password: str | None
|
|
29
|
+
design: bool
|
|
30
|
+
options: SessionOptions
|
|
31
|
+
on_created: Callable[[], None] | None = None
|
|
32
|
+
"""Called by the engine as soon as it has created ``path`` itself (so failures may delete it)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EngineHandle(Protocol):
|
|
36
|
+
"""An open engine: owns every COM root reference (and, for Access, the process)."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def transport(self) -> Transport:
|
|
40
|
+
"""How the database is currently reached."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def supports_design(self) -> bool:
|
|
45
|
+
"""Whether design features are possible without switching engines."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def schema(self) -> SchemaBackend:
|
|
49
|
+
"""The schema backend."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def design(self) -> DesignBackend:
|
|
53
|
+
"""The design backend (may upgrade to a design session, or raise ``CapabilityError``)."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def raw(self, which: str) -> Any:
|
|
57
|
+
"""Raw COM object for the escape hatch."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def close(self) -> list[BaseException]:
|
|
61
|
+
"""Release everything; returns the errors of failed cleanup steps."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def terminate(self) -> None:
|
|
65
|
+
"""Emergency stop from any thread (terminates an owned Access process)."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EnvironmentProbe(Protocol):
|
|
70
|
+
"""Answers "which engines can this process use?" (replaceable in tests)."""
|
|
71
|
+
|
|
72
|
+
def inproc_dao(self) -> tuple[bool, str]:
|
|
73
|
+
"""``(available, reason_if_not)`` for in-process DAO."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def access(self, progid: str) -> bool:
|
|
77
|
+
"""Whether Microsoft Access is registered."""
|
|
78
|
+
...
|