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,680 @@
|
|
|
1
|
+
"""An in-memory implementation of both backend protocols.
|
|
2
|
+
|
|
3
|
+
It models the semantics that matter to callers — case-insensitive names, the shared table/query
|
|
4
|
+
namespace, index and relationship rules, DAO property behaviour — closely enough that the same contract
|
|
5
|
+
test-suite passes against it and against real Access. It cannot execute SQL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pyaccesskit._backends.protocols import (
|
|
16
|
+
ControlInfo,
|
|
17
|
+
DatabaseInfo,
|
|
18
|
+
FetchResult,
|
|
19
|
+
ParameterInfo,
|
|
20
|
+
PropertyTarget,
|
|
21
|
+
QueryInfo,
|
|
22
|
+
TableInfo,
|
|
23
|
+
)
|
|
24
|
+
from pyaccesskit.enums import ControlKind, DataType, ObjectKind, PropertyType, Transport
|
|
25
|
+
from pyaccesskit.errors import (
|
|
26
|
+
CapabilityError,
|
|
27
|
+
ObjectExistsError,
|
|
28
|
+
ObjectNotFoundError,
|
|
29
|
+
RelationshipError,
|
|
30
|
+
SchemaError,
|
|
31
|
+
)
|
|
32
|
+
from pyaccesskit.forms.layout import ResolvedForm
|
|
33
|
+
from pyaccesskit.schema import (
|
|
34
|
+
ColumnBase,
|
|
35
|
+
ColumnSpec,
|
|
36
|
+
IndexField,
|
|
37
|
+
IndexSpec,
|
|
38
|
+
PropertyValue,
|
|
39
|
+
QuerySpec,
|
|
40
|
+
RelationshipSpec,
|
|
41
|
+
TableSpec,
|
|
42
|
+
detect_query_kind,
|
|
43
|
+
)
|
|
44
|
+
from pyaccesskit.schema.compat import relationship_compatible
|
|
45
|
+
from pyaccesskit.schema.expressions import parse_literal, render_literal
|
|
46
|
+
from pyaccesskit.schema.tables import MAX_COLUMNS, MAX_INDEXES
|
|
47
|
+
|
|
48
|
+
__all__ = ["FakeBackend"]
|
|
49
|
+
|
|
50
|
+
_DESIGN_KINDS = (ObjectKind.FORM, ObjectKind.REPORT, ObjectKind.MACRO, ObjectKind.MODULE)
|
|
51
|
+
_TABLE_PROPS = {
|
|
52
|
+
"description": "description",
|
|
53
|
+
"validationrule": "validation_rule",
|
|
54
|
+
"validationtext": "validation_text",
|
|
55
|
+
}
|
|
56
|
+
_FIELD_PROPS = {
|
|
57
|
+
"description": "description",
|
|
58
|
+
"caption": "caption",
|
|
59
|
+
"format": "format",
|
|
60
|
+
"inputmask": "input_mask",
|
|
61
|
+
"decimalplaces": "decimal_places",
|
|
62
|
+
"validationrule": "validation_rule",
|
|
63
|
+
"validationtext": "validation_text",
|
|
64
|
+
"required": "required",
|
|
65
|
+
"defaultvalue": "default",
|
|
66
|
+
"allowzerolength": "allow_zero_length",
|
|
67
|
+
"unicodecompression": "unicode_compression",
|
|
68
|
+
}
|
|
69
|
+
_QUERY_PROPS = {"description": "description"}
|
|
70
|
+
_DISPLAY_NAMES = {
|
|
71
|
+
name.casefold(): name
|
|
72
|
+
for name in (
|
|
73
|
+
"Description",
|
|
74
|
+
"Caption",
|
|
75
|
+
"Format",
|
|
76
|
+
"InputMask",
|
|
77
|
+
"DecimalPlaces",
|
|
78
|
+
"ValidationRule",
|
|
79
|
+
"ValidationText",
|
|
80
|
+
"Required",
|
|
81
|
+
"DefaultValue",
|
|
82
|
+
"AllowZeroLength",
|
|
83
|
+
"UnicodeCompression",
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
_PARAMETERS = re.compile(r"^\s*PARAMETERS\s+(?P<body>.*?);", re.IGNORECASE | re.DOTALL)
|
|
87
|
+
_PARAM_ITEM = re.compile(
|
|
88
|
+
r"\s*(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>[^\s,\[\]]+))\s+(?P<type>[A-Za-z]+)(?:\s*\([^)]*\))?\s*(?:,|$)"
|
|
89
|
+
)
|
|
90
|
+
_PARAM_TYPES = {
|
|
91
|
+
"long": DataType.NUMBER,
|
|
92
|
+
"integer": DataType.NUMBER,
|
|
93
|
+
"short": DataType.NUMBER,
|
|
94
|
+
"byte": DataType.NUMBER,
|
|
95
|
+
"single": DataType.NUMBER,
|
|
96
|
+
"double": DataType.NUMBER,
|
|
97
|
+
"currency": DataType.CURRENCY,
|
|
98
|
+
"datetime": DataType.DATE_TIME,
|
|
99
|
+
"bit": DataType.YES_NO,
|
|
100
|
+
"yesno": DataType.YES_NO,
|
|
101
|
+
"text": DataType.TEXT,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _key(name: str) -> str:
|
|
106
|
+
return name.casefold()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FakeBackend:
|
|
110
|
+
"""In-memory :class:`SchemaBackend` + :class:`DesignBackend`."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, path: Path | str = "memory.accdb") -> None:
|
|
113
|
+
self.path = Path(path)
|
|
114
|
+
self._tables: dict[str, TableSpec] = {}
|
|
115
|
+
self._relationships: dict[str, RelationshipSpec] = {}
|
|
116
|
+
self._queries: dict[str, QuerySpec] = {}
|
|
117
|
+
self._custom: dict[tuple[str, str, str], dict[str, tuple[str, PropertyValue]]] = {}
|
|
118
|
+
self._objects: dict[ObjectKind, dict[str, tuple[str, bytes]]] = {
|
|
119
|
+
kind: {} for kind in (*_DESIGN_KINDS, ObjectKind.QUERY)
|
|
120
|
+
}
|
|
121
|
+
self._forms: dict[str, ResolvedForm] = {}
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------------------------- basics
|
|
124
|
+
def database_info(self) -> DatabaseInfo:
|
|
125
|
+
return DatabaseInfo(self.path, "12.0", Transport.MEMORY)
|
|
126
|
+
|
|
127
|
+
def _table(self, name: str) -> TableSpec:
|
|
128
|
+
try:
|
|
129
|
+
return self._tables[_key(name)]
|
|
130
|
+
except KeyError:
|
|
131
|
+
raise ObjectNotFoundError(
|
|
132
|
+
f"table {name!r} does not exist", kind=ObjectKind.TABLE, name=name
|
|
133
|
+
) from None
|
|
134
|
+
|
|
135
|
+
def _query(self, name: str) -> QuerySpec:
|
|
136
|
+
try:
|
|
137
|
+
return self._queries[_key(name)]
|
|
138
|
+
except KeyError:
|
|
139
|
+
raise ObjectNotFoundError(
|
|
140
|
+
f"query {name!r} does not exist", kind=ObjectKind.QUERY, name=name
|
|
141
|
+
) from None
|
|
142
|
+
|
|
143
|
+
def _ensure_free(self, name: str) -> None:
|
|
144
|
+
key = _key(name)
|
|
145
|
+
if key in self._tables:
|
|
146
|
+
raise ObjectExistsError(
|
|
147
|
+
f"a table named {self._tables[key].name!r} already exists",
|
|
148
|
+
kind=ObjectKind.TABLE,
|
|
149
|
+
name=name,
|
|
150
|
+
)
|
|
151
|
+
if key in self._queries:
|
|
152
|
+
raise ObjectExistsError(
|
|
153
|
+
f"a query named {self._queries[key].name!r} already exists",
|
|
154
|
+
kind=ObjectKind.QUERY,
|
|
155
|
+
name=name,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _store(self, spec: TableSpec) -> None:
|
|
159
|
+
self._tables[_key(spec.name)] = spec
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _column(spec: TableSpec, name: str) -> ColumnBase:
|
|
163
|
+
try:
|
|
164
|
+
return spec.column(name)
|
|
165
|
+
except KeyError:
|
|
166
|
+
raise ObjectNotFoundError(
|
|
167
|
+
f"table {spec.name!r} has no column {name!r}", kind=ObjectKind.FIELD, name=name
|
|
168
|
+
) from None
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------------------------ tables
|
|
171
|
+
def list_tables(self) -> list[TableInfo]:
|
|
172
|
+
return [TableInfo(spec.name) for spec in self._tables.values()]
|
|
173
|
+
|
|
174
|
+
def read_table(self, name: str) -> TableSpec:
|
|
175
|
+
return self._table(name)
|
|
176
|
+
|
|
177
|
+
def count_indexes(self, table: str) -> int:
|
|
178
|
+
spec = self._table(table)
|
|
179
|
+
hidden = sum(
|
|
180
|
+
1
|
|
181
|
+
for rel in self._relationships.values()
|
|
182
|
+
if rel.enforce_integrity and _key(rel.foreign_table) == _key(spec.name)
|
|
183
|
+
)
|
|
184
|
+
return len(spec.indexes) + hidden
|
|
185
|
+
|
|
186
|
+
def create_table(self, spec: TableSpec) -> None:
|
|
187
|
+
self._ensure_free(spec.name)
|
|
188
|
+
self._store(spec.normalized())
|
|
189
|
+
|
|
190
|
+
def _relationships_of(self, table: str) -> list[RelationshipSpec]:
|
|
191
|
+
key = _key(table)
|
|
192
|
+
return [
|
|
193
|
+
rel
|
|
194
|
+
for rel in self._relationships.values()
|
|
195
|
+
if _key(rel.primary_table) == key or _key(rel.foreign_table) == key
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
def drop_table(self, name: str) -> None:
|
|
199
|
+
spec = self._table(name)
|
|
200
|
+
related = self._relationships_of(spec.name)
|
|
201
|
+
if related:
|
|
202
|
+
names = ", ".join(repr(rel.effective_name) for rel in related)
|
|
203
|
+
raise SchemaError(
|
|
204
|
+
f"table {spec.name!r} is part of relationship(s) {names}; delete them first"
|
|
205
|
+
)
|
|
206
|
+
del self._tables[_key(spec.name)]
|
|
207
|
+
for target in [
|
|
208
|
+
t for t in self._custom if t[1] == _key(spec.name) and t[0] in ("table", "field")
|
|
209
|
+
]:
|
|
210
|
+
del self._custom[target]
|
|
211
|
+
|
|
212
|
+
def rename_table(self, old: str, new: str) -> None:
|
|
213
|
+
spec = self._table(old)
|
|
214
|
+
if _key(old) != _key(new):
|
|
215
|
+
self._ensure_free(new)
|
|
216
|
+
del self._tables[_key(spec.name)]
|
|
217
|
+
self._store(spec.model_copy(update={"name": new}))
|
|
218
|
+
for key, rel in list(self._relationships.items()):
|
|
219
|
+
update: dict[str, Any] = {}
|
|
220
|
+
if _key(rel.primary_table) == _key(old):
|
|
221
|
+
update["primary_table"] = new
|
|
222
|
+
if _key(rel.foreign_table) == _key(old):
|
|
223
|
+
update["foreign_table"] = new
|
|
224
|
+
if update:
|
|
225
|
+
self._relationships[key] = rel.model_copy(update=update)
|
|
226
|
+
for target in [t for t in self._custom if t[1] == _key(old) and t[0] in ("table", "field")]:
|
|
227
|
+
self._custom[(target[0], _key(new), target[2])] = self._custom.pop(target)
|
|
228
|
+
|
|
229
|
+
def add_column(self, table: str, column: ColumnSpec) -> None:
|
|
230
|
+
spec = self._table(table)
|
|
231
|
+
if any(_key(c.name) == _key(column.name) for c in spec.columns):
|
|
232
|
+
raise ObjectExistsError(
|
|
233
|
+
f"table {spec.name!r} already has a column {column.name!r}",
|
|
234
|
+
kind=ObjectKind.FIELD,
|
|
235
|
+
name=column.name,
|
|
236
|
+
)
|
|
237
|
+
if len(spec.columns) >= MAX_COLUMNS:
|
|
238
|
+
raise SchemaError(f"table {spec.name!r} already has {MAX_COLUMNS} columns")
|
|
239
|
+
if column.data_type is DataType.AUTONUMBER and any(
|
|
240
|
+
c.data_type is DataType.AUTONUMBER for c in spec.columns
|
|
241
|
+
):
|
|
242
|
+
raise SchemaError(f"table {spec.name!r} already has an AutoNumber column")
|
|
243
|
+
self._store(spec.model_copy(update={"columns": (*spec.columns, column.normalized())}))
|
|
244
|
+
|
|
245
|
+
def drop_column(self, table: str, column: str) -> None:
|
|
246
|
+
spec = self._table(table)
|
|
247
|
+
target = self._column(spec, column)
|
|
248
|
+
indexes = [
|
|
249
|
+
i.name for i in spec.indexes if any(_key(f) == _key(target.name) for f in i.field_names)
|
|
250
|
+
]
|
|
251
|
+
if indexes:
|
|
252
|
+
raise SchemaError(
|
|
253
|
+
f"column {target.name!r} is part of index(es) {indexes}; drop them first"
|
|
254
|
+
)
|
|
255
|
+
for rel in self._relationships_of(spec.name):
|
|
256
|
+
columns = (
|
|
257
|
+
rel.primary_columns
|
|
258
|
+
if _key(rel.primary_table) == _key(spec.name)
|
|
259
|
+
else rel.foreign_columns
|
|
260
|
+
)
|
|
261
|
+
if any(_key(c) == _key(target.name) for c in columns):
|
|
262
|
+
raise SchemaError(
|
|
263
|
+
f"column {target.name!r} is part of relationship {rel.effective_name!r}; delete it first"
|
|
264
|
+
)
|
|
265
|
+
remaining = tuple(c for c in spec.columns if _key(c.name) != _key(target.name))
|
|
266
|
+
if not remaining:
|
|
267
|
+
raise SchemaError(f"cannot drop the last column of table {spec.name!r}")
|
|
268
|
+
self._store(spec.model_copy(update={"columns": remaining}))
|
|
269
|
+
self._custom.pop(("field", _key(spec.name), _key(target.name)), None)
|
|
270
|
+
|
|
271
|
+
def rename_column(self, table: str, old: str, new: str) -> None:
|
|
272
|
+
spec = self._table(table)
|
|
273
|
+
target = self._column(spec, old)
|
|
274
|
+
if _key(old) != _key(new) and any(_key(c.name) == _key(new) for c in spec.columns):
|
|
275
|
+
raise ObjectExistsError(
|
|
276
|
+
f"table {spec.name!r} already has a column {new!r}", kind=ObjectKind.FIELD, name=new
|
|
277
|
+
)
|
|
278
|
+
columns = tuple(
|
|
279
|
+
c.model_copy(update={"name": new}) if c is target else c for c in spec.columns
|
|
280
|
+
)
|
|
281
|
+
indexes = tuple(
|
|
282
|
+
i.model_copy(
|
|
283
|
+
update={
|
|
284
|
+
"fields": tuple(
|
|
285
|
+
IndexField(name=new, descending=f.descending)
|
|
286
|
+
if _key(f.name) == _key(old)
|
|
287
|
+
else f
|
|
288
|
+
for f in i.fields
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
for i in spec.indexes
|
|
293
|
+
)
|
|
294
|
+
self._store(spec.model_copy(update={"columns": columns, "indexes": indexes}))
|
|
295
|
+
for key, rel in list(self._relationships.items()):
|
|
296
|
+
update: dict[str, Any] = {}
|
|
297
|
+
if _key(rel.primary_table) == _key(spec.name):
|
|
298
|
+
update["primary_columns"] = tuple(
|
|
299
|
+
new if _key(c) == _key(old) else c for c in rel.primary_columns
|
|
300
|
+
)
|
|
301
|
+
if _key(rel.foreign_table) == _key(spec.name):
|
|
302
|
+
update["foreign_columns"] = tuple(
|
|
303
|
+
new if _key(c) == _key(old) else c for c in rel.foreign_columns
|
|
304
|
+
)
|
|
305
|
+
if update:
|
|
306
|
+
self._relationships[key] = rel.model_copy(update=update)
|
|
307
|
+
|
|
308
|
+
def create_index(self, table: str, index: IndexSpec) -> None:
|
|
309
|
+
spec = self._table(table)
|
|
310
|
+
taken = {_key(i.name) for i in spec.indexes} | {
|
|
311
|
+
_key(rel.effective_name)
|
|
312
|
+
for rel in self._relationships.values()
|
|
313
|
+
if _key(rel.foreign_table) == _key(spec.name)
|
|
314
|
+
}
|
|
315
|
+
if _key(index.name) in taken:
|
|
316
|
+
raise ObjectExistsError(
|
|
317
|
+
f"table {spec.name!r} already has an index named {index.name!r}",
|
|
318
|
+
kind=ObjectKind.INDEX,
|
|
319
|
+
name=index.name,
|
|
320
|
+
)
|
|
321
|
+
if index.primary and any(i.primary for i in spec.indexes):
|
|
322
|
+
raise SchemaError(f"table {spec.name!r} already has a primary key")
|
|
323
|
+
for field_name in index.field_names:
|
|
324
|
+
self._column(spec, field_name)
|
|
325
|
+
if self.count_indexes(spec.name) >= MAX_INDEXES:
|
|
326
|
+
raise SchemaError(
|
|
327
|
+
f"table {spec.name!r} already has {MAX_INDEXES} indexes (the Access maximum)"
|
|
328
|
+
)
|
|
329
|
+
self._store(spec.model_copy(update={"indexes": (*spec.indexes, index)}).normalized())
|
|
330
|
+
|
|
331
|
+
def drop_index(self, table: str, name: str) -> None:
|
|
332
|
+
spec = self._table(table)
|
|
333
|
+
remaining = tuple(i for i in spec.indexes if _key(i.name) != _key(name))
|
|
334
|
+
if len(remaining) == len(spec.indexes):
|
|
335
|
+
raise ObjectNotFoundError(
|
|
336
|
+
f"table {spec.name!r} has no index {name!r}", kind=ObjectKind.INDEX, name=name
|
|
337
|
+
)
|
|
338
|
+
self._store(spec.model_copy(update={"indexes": remaining}))
|
|
339
|
+
|
|
340
|
+
# ----------------------------------------------------------------------------- relationships
|
|
341
|
+
def list_relationships(self) -> list[RelationshipSpec]:
|
|
342
|
+
return list(self._relationships.values())
|
|
343
|
+
|
|
344
|
+
def create_relationship(self, spec: RelationshipSpec) -> None:
|
|
345
|
+
spec = spec.normalized()
|
|
346
|
+
name = spec.effective_name
|
|
347
|
+
if _key(name) in self._relationships:
|
|
348
|
+
raise ObjectExistsError(
|
|
349
|
+
f"a relationship named {name!r} already exists",
|
|
350
|
+
kind=ObjectKind.RELATIONSHIP,
|
|
351
|
+
name=name,
|
|
352
|
+
)
|
|
353
|
+
primary = self._table(spec.primary_table)
|
|
354
|
+
foreign = self._table(spec.foreign_table)
|
|
355
|
+
if any(_key(i.name) == _key(name) for i in foreign.indexes):
|
|
356
|
+
raise ObjectExistsError(
|
|
357
|
+
f"table {foreign.name!r} already has an index named {name!r}",
|
|
358
|
+
kind=ObjectKind.INDEX,
|
|
359
|
+
name=name,
|
|
360
|
+
)
|
|
361
|
+
for p_name, f_name in zip(spec.primary_columns, spec.foreign_columns, strict=True):
|
|
362
|
+
p_col, f_col = self._column(primary, p_name), self._column(foreign, f_name)
|
|
363
|
+
if not relationship_compatible(p_col, f_col):
|
|
364
|
+
raise RelationshipError(
|
|
365
|
+
"Relationship must be on the same number of fields with the same data types."
|
|
366
|
+
)
|
|
367
|
+
wanted = {_key(c) for c in spec.primary_columns}
|
|
368
|
+
if not any(
|
|
369
|
+
i.unique and {_key(f) for f in i.field_names} == wanted for i in primary.indexes
|
|
370
|
+
):
|
|
371
|
+
raise RelationshipError(
|
|
372
|
+
"No unique index found for the referenced field of the primary table."
|
|
373
|
+
)
|
|
374
|
+
if spec.enforce_integrity and self.count_indexes(foreign.name) >= MAX_INDEXES:
|
|
375
|
+
raise SchemaError(
|
|
376
|
+
f"table {foreign.name!r} already has {MAX_INDEXES} indexes (the Access maximum)"
|
|
377
|
+
)
|
|
378
|
+
self._relationships[_key(name)] = spec
|
|
379
|
+
|
|
380
|
+
def drop_relationship(self, name: str) -> None:
|
|
381
|
+
if self._relationships.pop(_key(name), None) is None:
|
|
382
|
+
raise ObjectNotFoundError(
|
|
383
|
+
f"relationship {name!r} does not exist", kind=ObjectKind.RELATIONSHIP, name=name
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# ----------------------------------------------------------------------------------- queries
|
|
387
|
+
def list_queries(self) -> list[QueryInfo]:
|
|
388
|
+
return [
|
|
389
|
+
QueryInfo(
|
|
390
|
+
q.name,
|
|
391
|
+
detect_query_kind(q.sql, pass_through=q.pass_through is not None),
|
|
392
|
+
q.name.startswith("~"),
|
|
393
|
+
)
|
|
394
|
+
for q in self._queries.values()
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
def read_query(self, name: str) -> QuerySpec:
|
|
398
|
+
return self._query(name)
|
|
399
|
+
|
|
400
|
+
def query_parameters(self, name: str) -> list[ParameterInfo]:
|
|
401
|
+
match = _PARAMETERS.match(self._query(name).sql)
|
|
402
|
+
if match is None:
|
|
403
|
+
return []
|
|
404
|
+
return [
|
|
405
|
+
ParameterInfo(
|
|
406
|
+
item.group("bracketed") or item.group("plain"),
|
|
407
|
+
_PARAM_TYPES.get(item.group("type").casefold(), DataType.UNKNOWN),
|
|
408
|
+
)
|
|
409
|
+
for item in _PARAM_ITEM.finditer(match.group("body"))
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
def create_query(self, spec: QuerySpec) -> None:
|
|
413
|
+
self._ensure_free(spec.name)
|
|
414
|
+
self._queries[_key(spec.name)] = spec.normalized()
|
|
415
|
+
|
|
416
|
+
def set_query_sql(self, name: str, sql: str) -> None:
|
|
417
|
+
query = self._query(name)
|
|
418
|
+
self._queries[_key(name)] = query.model_copy(update={"sql": sql}).normalized()
|
|
419
|
+
|
|
420
|
+
def rename_query(self, old: str, new: str) -> None:
|
|
421
|
+
query = self._query(old)
|
|
422
|
+
if _key(old) != _key(new):
|
|
423
|
+
self._ensure_free(new)
|
|
424
|
+
del self._queries[_key(old)]
|
|
425
|
+
self._queries[_key(new)] = query.model_copy(update={"name": new})
|
|
426
|
+
|
|
427
|
+
def drop_query(self, name: str) -> None:
|
|
428
|
+
self._query(name)
|
|
429
|
+
del self._queries[_key(name)]
|
|
430
|
+
|
|
431
|
+
# -------------------------------------------------------------------------------- properties
|
|
432
|
+
def _resolve(self, target: PropertyTarget) -> tuple[tuple[str, str, str], Any, dict[str, str]]:
|
|
433
|
+
if target.kind == "database":
|
|
434
|
+
return ("database", "", ""), None, {}
|
|
435
|
+
if target.kind == "table":
|
|
436
|
+
spec = self._table(target.name or "")
|
|
437
|
+
return ("table", _key(spec.name), ""), spec, _TABLE_PROPS
|
|
438
|
+
if target.kind == "field":
|
|
439
|
+
spec = self._table(target.name or "")
|
|
440
|
+
column = self._column(spec, target.field or "")
|
|
441
|
+
return ("field", _key(spec.name), _key(column.name)), column, _FIELD_PROPS
|
|
442
|
+
query = self._query(target.name or "")
|
|
443
|
+
return ("query", _key(query.name), ""), query, _QUERY_PROPS
|
|
444
|
+
|
|
445
|
+
def _replace(self, target: PropertyTarget, obj: Any, attr: str, value: Any) -> None:
|
|
446
|
+
updated = obj.model_validate({**obj.model_dump(), attr: value})
|
|
447
|
+
if target.kind == "table":
|
|
448
|
+
self._store(updated)
|
|
449
|
+
elif target.kind == "field":
|
|
450
|
+
spec = self._table(target.name or "")
|
|
451
|
+
self._store(
|
|
452
|
+
spec.model_copy(
|
|
453
|
+
update={"columns": tuple(updated if c is obj else c for c in spec.columns)}
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
self._queries[_key(obj.name)] = updated
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def _missing(target: PropertyTarget, name: str) -> ObjectNotFoundError:
|
|
461
|
+
return ObjectNotFoundError(
|
|
462
|
+
f"{target.describe()} has no property {name!r}", kind=ObjectKind.PROPERTY, name=name
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def get_property(self, target: PropertyTarget, name: str) -> PropertyValue:
|
|
466
|
+
store_key, obj, mapped = self._resolve(target)
|
|
467
|
+
attr = mapped.get(_key(name))
|
|
468
|
+
if attr is not None and hasattr(obj, attr):
|
|
469
|
+
value = getattr(obj, attr)
|
|
470
|
+
if attr == "default":
|
|
471
|
+
return render_literal(value) if value is not None else ""
|
|
472
|
+
if attr in ("validation_rule", "validation_text"):
|
|
473
|
+
return value or ""
|
|
474
|
+
if value is None:
|
|
475
|
+
raise self._missing(target, name)
|
|
476
|
+
return value
|
|
477
|
+
entry = self._custom.get(store_key, {}).get(_key(name))
|
|
478
|
+
if entry is None:
|
|
479
|
+
raise self._missing(target, name)
|
|
480
|
+
return entry[1]
|
|
481
|
+
|
|
482
|
+
def set_property(
|
|
483
|
+
self,
|
|
484
|
+
target: PropertyTarget,
|
|
485
|
+
name: str,
|
|
486
|
+
value: PropertyValue,
|
|
487
|
+
type: PropertyType | None = None,
|
|
488
|
+
) -> None:
|
|
489
|
+
store_key, obj, mapped = self._resolve(target)
|
|
490
|
+
attr = mapped.get(_key(name))
|
|
491
|
+
if attr is not None and hasattr(obj, attr):
|
|
492
|
+
new_value: Any = value
|
|
493
|
+
if attr == "default":
|
|
494
|
+
new_value = parse_literal(str(value)) if value not in (None, "") else None
|
|
495
|
+
elif (attr == "decimal_places" and value == 255) or (
|
|
496
|
+
attr in ("validation_rule", "validation_text") and value == ""
|
|
497
|
+
):
|
|
498
|
+
new_value = None
|
|
499
|
+
self._replace(target, obj, attr, new_value)
|
|
500
|
+
return
|
|
501
|
+
self._custom.setdefault(store_key, {})[_key(name)] = (name, value)
|
|
502
|
+
|
|
503
|
+
def delete_property(self, target: PropertyTarget, name: str) -> None:
|
|
504
|
+
store_key, obj, mapped = self._resolve(target)
|
|
505
|
+
attr = mapped.get(_key(name))
|
|
506
|
+
if attr is not None and hasattr(obj, attr):
|
|
507
|
+
if getattr(obj, attr) is None:
|
|
508
|
+
raise self._missing(target, name)
|
|
509
|
+
self._replace(target, obj, attr, None)
|
|
510
|
+
return
|
|
511
|
+
if self._custom.get(store_key, {}).pop(_key(name), None) is None:
|
|
512
|
+
raise self._missing(target, name)
|
|
513
|
+
|
|
514
|
+
def list_properties(self, target: PropertyTarget) -> dict[str, PropertyValue]:
|
|
515
|
+
store_key, obj, mapped = self._resolve(target)
|
|
516
|
+
result: dict[str, PropertyValue] = {}
|
|
517
|
+
for prop_key, attr in mapped.items():
|
|
518
|
+
if hasattr(obj, attr) and getattr(obj, attr) is not None:
|
|
519
|
+
result[_DISPLAY_NAMES[prop_key]] = self.get_property(target, prop_key)
|
|
520
|
+
result.update(dict(self._custom.get(store_key, {}).values()))
|
|
521
|
+
return result
|
|
522
|
+
|
|
523
|
+
# -------------------------------------------------------------------------------------- data
|
|
524
|
+
def _no_sql(self) -> CapabilityError:
|
|
525
|
+
return CapabilityError(
|
|
526
|
+
"the in-memory backend cannot run SQL; open a real database for data access"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def execute(self, sql: str, params: Mapping[str, Any] | None = None) -> int:
|
|
530
|
+
raise self._no_sql()
|
|
531
|
+
|
|
532
|
+
def fetch(
|
|
533
|
+
self, sql: str, params: Mapping[str, Any] | None = None, *, limit: int | None = None
|
|
534
|
+
) -> FetchResult:
|
|
535
|
+
raise self._no_sql()
|
|
536
|
+
|
|
537
|
+
def execute_saved(self, name: str, params: Mapping[str, Any] | None = None) -> int:
|
|
538
|
+
raise self._no_sql()
|
|
539
|
+
|
|
540
|
+
def fetch_saved(
|
|
541
|
+
self, name: str, params: Mapping[str, Any] | None = None, *, limit: int | None = None
|
|
542
|
+
) -> FetchResult:
|
|
543
|
+
raise self._no_sql()
|
|
544
|
+
|
|
545
|
+
def list_documents(self, kind: ObjectKind) -> list[str]:
|
|
546
|
+
return self.list_objects(kind)
|
|
547
|
+
|
|
548
|
+
# ------------------------------------------------------------------------------------ design
|
|
549
|
+
def _design_names(self, kind: ObjectKind) -> dict[str, str]:
|
|
550
|
+
names = {key: name for key, (name, _) in self._objects[kind].items()}
|
|
551
|
+
if kind is ObjectKind.FORM:
|
|
552
|
+
names.update({key: form.spec.name for key, form in self._forms.items()})
|
|
553
|
+
if kind is ObjectKind.QUERY: # like CurrentData.AllQueries: saved QueryDefs are listed too
|
|
554
|
+
names.update({key: query.name for key, query in self._queries.items()})
|
|
555
|
+
return names
|
|
556
|
+
|
|
557
|
+
def list_objects(self, kind: ObjectKind) -> list[str]:
|
|
558
|
+
return list(self._design_names(kind).values())
|
|
559
|
+
|
|
560
|
+
def _require_object(self, kind: ObjectKind, name: str) -> str:
|
|
561
|
+
names = self._design_names(kind)
|
|
562
|
+
if _key(name) not in names:
|
|
563
|
+
raise ObjectNotFoundError(f"{kind.value} {name!r} does not exist", kind=kind, name=name)
|
|
564
|
+
return names[_key(name)]
|
|
565
|
+
|
|
566
|
+
def delete_object(self, kind: ObjectKind, name: str) -> None:
|
|
567
|
+
self._require_object(kind, name)
|
|
568
|
+
self._objects[kind].pop(_key(name), None)
|
|
569
|
+
if kind is ObjectKind.FORM:
|
|
570
|
+
self._forms.pop(_key(name), None)
|
|
571
|
+
|
|
572
|
+
def rename_object(self, kind: ObjectKind, old: str, new: str) -> None:
|
|
573
|
+
self._require_object(kind, old)
|
|
574
|
+
if _key(old) != _key(new) and _key(new) in self._design_names(kind):
|
|
575
|
+
raise ObjectExistsError(
|
|
576
|
+
f"a {kind.value} named {new!r} already exists", kind=kind, name=new
|
|
577
|
+
)
|
|
578
|
+
if _key(old) in self._objects[kind]:
|
|
579
|
+
_, data = self._objects[kind].pop(_key(old))
|
|
580
|
+
self._objects[kind][_key(new)] = (new, data)
|
|
581
|
+
if kind is ObjectKind.FORM and _key(old) in self._forms:
|
|
582
|
+
form = self._forms.pop(_key(old))
|
|
583
|
+
self._forms[_key(new)] = ResolvedForm(
|
|
584
|
+
spec=form.spec.model_copy(update={"name": new}),
|
|
585
|
+
width=form.width,
|
|
586
|
+
detail_height=form.detail_height,
|
|
587
|
+
header_height=form.header_height,
|
|
588
|
+
footer_height=form.footer_height,
|
|
589
|
+
controls=form.controls,
|
|
590
|
+
events=form.events,
|
|
591
|
+
module_text=form.module_text,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def export_text(self, kind: ObjectKind, name: str) -> bytes:
|
|
595
|
+
actual = self._require_object(kind, name)
|
|
596
|
+
if _key(name) in self._objects[kind]:
|
|
597
|
+
return self._objects[kind][_key(name)][1]
|
|
598
|
+
form = self._forms[_key(name)]
|
|
599
|
+
lines = [
|
|
600
|
+
"Version =21",
|
|
601
|
+
"Begin Form",
|
|
602
|
+
f' Caption ="{form.spec.caption or actual}"',
|
|
603
|
+
"End",
|
|
604
|
+
]
|
|
605
|
+
return "\r\n".join(lines).encode("utf-16")
|
|
606
|
+
|
|
607
|
+
def import_text(self, kind: ObjectKind, name: str, data: bytes) -> None:
|
|
608
|
+
existing = self._design_names(kind).get(_key(name))
|
|
609
|
+
if kind is ObjectKind.FORM:
|
|
610
|
+
self._forms.pop(_key(name), None)
|
|
611
|
+
self._objects[kind][_key(name)] = (existing or name, data)
|
|
612
|
+
|
|
613
|
+
def build_form(self, form: ResolvedForm, *, replace: bool) -> None:
|
|
614
|
+
name = form.spec.name
|
|
615
|
+
if _key(name) in self._design_names(ObjectKind.FORM) and not replace:
|
|
616
|
+
raise ObjectExistsError(
|
|
617
|
+
f"a form named {name!r} already exists", kind=ObjectKind.FORM, name=name
|
|
618
|
+
)
|
|
619
|
+
self._objects[ObjectKind.FORM].pop(_key(name), None)
|
|
620
|
+
self._forms[_key(name)] = form
|
|
621
|
+
|
|
622
|
+
def form_controls(self, name: str) -> list[ControlInfo]:
|
|
623
|
+
self._require_object(ObjectKind.FORM, name)
|
|
624
|
+
form = self._forms.get(_key(name))
|
|
625
|
+
if form is None:
|
|
626
|
+
return []
|
|
627
|
+
infos: list[ControlInfo] = []
|
|
628
|
+
for control in form.controls:
|
|
629
|
+
source = getattr(control.spec, "field", None) or getattr(
|
|
630
|
+
control.spec, "control_source", None
|
|
631
|
+
)
|
|
632
|
+
caption = getattr(control.spec, "caption", None)
|
|
633
|
+
r = control.rect
|
|
634
|
+
infos.append(
|
|
635
|
+
ControlInfo(
|
|
636
|
+
control.name,
|
|
637
|
+
control.spec.control_kind,
|
|
638
|
+
control.section,
|
|
639
|
+
r.left,
|
|
640
|
+
r.top,
|
|
641
|
+
r.width,
|
|
642
|
+
r.height,
|
|
643
|
+
source,
|
|
644
|
+
caption,
|
|
645
|
+
None,
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
if control.label is not None:
|
|
649
|
+
lr = control.label.rect
|
|
650
|
+
infos.append(
|
|
651
|
+
ControlInfo(
|
|
652
|
+
control.label.name,
|
|
653
|
+
ControlKind.LABEL,
|
|
654
|
+
control.label.section,
|
|
655
|
+
lr.left,
|
|
656
|
+
lr.top,
|
|
657
|
+
lr.width,
|
|
658
|
+
lr.height,
|
|
659
|
+
None,
|
|
660
|
+
control.label.caption,
|
|
661
|
+
control.name,
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
return infos
|
|
665
|
+
|
|
666
|
+
def check_form_opens(self, name: str) -> None:
|
|
667
|
+
self._require_object(ObjectKind.FORM, name)
|
|
668
|
+
form = self._forms.get(_key(name))
|
|
669
|
+
if form is None or form.spec.record_source is None:
|
|
670
|
+
return
|
|
671
|
+
source = form.spec.record_source
|
|
672
|
+
if source.lstrip().upper().startswith("SELECT"):
|
|
673
|
+
return
|
|
674
|
+
if _key(source) in self._queries:
|
|
675
|
+
return
|
|
676
|
+
table = self._table(source)
|
|
677
|
+
for control in form.controls:
|
|
678
|
+
field = control.spec.bound_field
|
|
679
|
+
if field is not None:
|
|
680
|
+
self._column(table, field)
|