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,805 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""DAO implementation of :class:`~pyaccesskit._backends.protocols.SchemaBackend`.
|
|
3
|
+
|
|
4
|
+
Works identically over every transport: the DAO ``Database`` is obtained through a callable, so the same
|
|
5
|
+
code runs against in-process DAO, Access-hosted DAO and ``CurrentDb()``.
|
|
6
|
+
|
|
7
|
+
Behaviours verified in ADR 0001:
|
|
8
|
+
|
|
9
|
+
* collections are refreshed before being read (``QueryDef.Type`` is 0 until ``QueryDefs.Refresh``);
|
|
10
|
+
* Decimal columns are created with ADO DDL (DAO cannot set precision/scale and silently creates a BigInt),
|
|
11
|
+
after which the original column order is restored with ``OrdinalPosition``;
|
|
12
|
+
* relationships among ``MSys*`` system tables (created by Access for the navigation pane) are ignored.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import pywintypes
|
|
25
|
+
|
|
26
|
+
from pyaccesskit._backends.dao import typemap as tm
|
|
27
|
+
from pyaccesskit._backends.protocols import (
|
|
28
|
+
DatabaseInfo,
|
|
29
|
+
FetchResult,
|
|
30
|
+
ParameterInfo,
|
|
31
|
+
PropertyTarget,
|
|
32
|
+
QueryInfo,
|
|
33
|
+
TableInfo,
|
|
34
|
+
)
|
|
35
|
+
from pyaccesskit._com.gateway import Com, get
|
|
36
|
+
from pyaccesskit._com.variants import normalize, to_variant
|
|
37
|
+
from pyaccesskit.enums import DataType, JoinType, ObjectKind, PropertyType, QueryKind, Transport
|
|
38
|
+
from pyaccesskit.errors import MissingParameterError, ObjectNotFoundError, SpecError
|
|
39
|
+
from pyaccesskit.schema import (
|
|
40
|
+
AutoNumberColumn,
|
|
41
|
+
ColumnBase,
|
|
42
|
+
ColumnSpec,
|
|
43
|
+
DecimalColumn,
|
|
44
|
+
IndexField,
|
|
45
|
+
IndexSpec,
|
|
46
|
+
PassThroughOptions,
|
|
47
|
+
PropertyValue,
|
|
48
|
+
QuerySpec,
|
|
49
|
+
RelationshipSpec,
|
|
50
|
+
TableSpec,
|
|
51
|
+
quote_identifier,
|
|
52
|
+
)
|
|
53
|
+
from pyaccesskit.schema.queries import DAO_QUERY_KINDS
|
|
54
|
+
|
|
55
|
+
__all__ = ["DaoSchemaBackend"]
|
|
56
|
+
|
|
57
|
+
PROPERTY_NOT_FOUND = 3270
|
|
58
|
+
DB_FAIL_ON_ERROR = 128
|
|
59
|
+
DB_OPEN_SNAPSHOT = 4
|
|
60
|
+
DB_SYSTEM_OBJECT = 0x80000000
|
|
61
|
+
DB_HIDDEN_OBJECT = 1
|
|
62
|
+
DB_ATTACHED_TABLE = 0x40000000
|
|
63
|
+
DB_ATTACHED_ODBC = 0x20000000
|
|
64
|
+
REL_UNIQUE, REL_DONT_ENFORCE, REL_INHERITED = 1, 2, 4
|
|
65
|
+
REL_UPDATE_CASCADE, REL_DELETE_CASCADE = 256, 4096
|
|
66
|
+
REL_LEFT, REL_RIGHT = 16777216, 33554432
|
|
67
|
+
PLACEHOLDER = "__pak_placeholder__"
|
|
68
|
+
FETCH_BATCH = 500
|
|
69
|
+
_CONTAINERS = {
|
|
70
|
+
ObjectKind.FORM: "Forms",
|
|
71
|
+
ObjectKind.REPORT: "Reports",
|
|
72
|
+
ObjectKind.MACRO: "Scripts",
|
|
73
|
+
ObjectKind.MODULE: "Modules",
|
|
74
|
+
}
|
|
75
|
+
_PARAMETER_TYPES = {
|
|
76
|
+
tm.DB_BOOLEAN: DataType.YES_NO,
|
|
77
|
+
tm.DB_BYTE: DataType.NUMBER,
|
|
78
|
+
tm.DB_INTEGER: DataType.NUMBER,
|
|
79
|
+
tm.DB_LONG: DataType.NUMBER,
|
|
80
|
+
tm.DB_SINGLE: DataType.NUMBER,
|
|
81
|
+
tm.DB_DOUBLE: DataType.NUMBER,
|
|
82
|
+
tm.DB_CURRENCY: DataType.CURRENCY,
|
|
83
|
+
tm.DB_DATE: DataType.DATE_TIME,
|
|
84
|
+
tm.DB_TEXT: DataType.TEXT,
|
|
85
|
+
tm.DB_MEMO: DataType.LONG_TEXT,
|
|
86
|
+
tm.DB_GUID: DataType.NUMBER,
|
|
87
|
+
tm.DB_DECIMAL: DataType.DECIMAL,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _error_number(exc: pywintypes.com_error) -> int | None:
|
|
92
|
+
excepinfo = exc.args[2] if len(exc.args) > 2 else None
|
|
93
|
+
scode = excepinfo[5] if excepinfo else None
|
|
94
|
+
return None if scode is None else scode & 0xFFFF
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _prop(obj: Any, name: str) -> Any:
|
|
98
|
+
"""Value of a DAO property, or ``None`` if the object does not have it."""
|
|
99
|
+
try:
|
|
100
|
+
return obj.Properties(name).Value
|
|
101
|
+
except pywintypes.com_error as exc:
|
|
102
|
+
if _error_number(exc) == PROPERTY_NOT_FOUND:
|
|
103
|
+
return None
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _set_prop(obj: Any, name: str, dao_type: int, value: Any) -> None:
|
|
108
|
+
"""Set a DAO property, creating it (Access-style) when it does not exist yet."""
|
|
109
|
+
value = to_variant(value)
|
|
110
|
+
try:
|
|
111
|
+
obj.Properties(name).Value = value
|
|
112
|
+
except pywintypes.com_error as exc:
|
|
113
|
+
if _error_number(exc) != PROPERTY_NOT_FOUND:
|
|
114
|
+
raise
|
|
115
|
+
obj.Properties.Append(obj.CreateProperty(name, dao_type, value))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _param_value(value: Any) -> Any:
|
|
119
|
+
return to_variant(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_BINARY = (bytes, bytearray, memoryview)
|
|
123
|
+
_PARAMETERS_CLAUSE = re.compile(r"^\s*PARAMETERS\s+", re.IGNORECASE)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _declare_binary_parameters(sql: str, params: Mapping[str, Any] | None) -> str:
|
|
127
|
+
"""Declare bytes-valued parameters as ``LongBinary``: implicit parameters are text (ADR 0002)."""
|
|
128
|
+
names = [key.strip("[]") for key, value in (params or {}).items() if isinstance(value, _BINARY)]
|
|
129
|
+
match = _PARAMETERS_CLAUSE.match(sql)
|
|
130
|
+
declared = sql[: sql.find(";")].casefold() if match and ";" in sql else ""
|
|
131
|
+
names = [name for name in names if f"[{name.casefold()}]" not in declared]
|
|
132
|
+
if not names:
|
|
133
|
+
return sql
|
|
134
|
+
declaration = ", ".join(f"[{name}] LongBinary" for name in names)
|
|
135
|
+
if match:
|
|
136
|
+
return f"{sql[: match.end()]}{declaration}, {sql[match.end() :]}"
|
|
137
|
+
return f"PARAMETERS {declaration};\n{sql}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _item(collection: Any, key: Any) -> Any:
|
|
141
|
+
"""``collection.Item(key)`` through an exact ``IDispatch.Invoke``.
|
|
142
|
+
|
|
143
|
+
pywin32's dynamic dispatch resolves ``Item`` as a property on some DAO collections (e.g. an index's
|
|
144
|
+
fields) and returns the wrong thing; invoking the DISPID directly always works.
|
|
145
|
+
"""
|
|
146
|
+
return get(collection, "Item", key)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _index_fields(idx: Any) -> tuple[IndexField, ...]:
|
|
150
|
+
"""Fields of a DAO index.
|
|
151
|
+
|
|
152
|
+
For an *appended* index, late-bound ``Index.Fields`` returns a string such as ``"+CustomerID;-OrderDate"``
|
|
153
|
+
(``+`` ascending, ``-`` descending) rather than a collection; unappended indexes return the collection.
|
|
154
|
+
"""
|
|
155
|
+
fields = idx.Fields
|
|
156
|
+
if isinstance(fields, str):
|
|
157
|
+
result: list[IndexField] = []
|
|
158
|
+
for part in fields.split(";"):
|
|
159
|
+
item = part.strip()
|
|
160
|
+
if not item:
|
|
161
|
+
continue
|
|
162
|
+
descending = item.startswith("-")
|
|
163
|
+
name = item[1:] if item[0] in "+-" else item
|
|
164
|
+
result.append(IndexField(name=_strip_brackets(name), descending=descending))
|
|
165
|
+
return tuple(result)
|
|
166
|
+
return tuple(
|
|
167
|
+
IndexField(
|
|
168
|
+
name=str(_item(fields, i).Name),
|
|
169
|
+
descending=bool(int(_item(fields, i).Attributes) & tm.DB_DESCENDING),
|
|
170
|
+
)
|
|
171
|
+
for i in range(fields.Count)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _strip_brackets(name: str) -> str:
|
|
176
|
+
return name[1:-1] if name.startswith("[") and name.endswith("]") else name
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DaoSchemaBackend:
|
|
180
|
+
"""Schema operations over a DAO ``Database``."""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
com: Com,
|
|
186
|
+
database: Callable[[], Any],
|
|
187
|
+
path: Path,
|
|
188
|
+
transport: Callable[[], Transport],
|
|
189
|
+
run_ddl: Callable[[str], None],
|
|
190
|
+
) -> None:
|
|
191
|
+
self._com_ref = com
|
|
192
|
+
self._database = database
|
|
193
|
+
self._path = path
|
|
194
|
+
self._transport = transport
|
|
195
|
+
self._run_ddl = run_ddl
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def _com(self) -> Com:
|
|
199
|
+
return self._com_ref
|
|
200
|
+
|
|
201
|
+
def _db(self) -> Any:
|
|
202
|
+
return self._database()
|
|
203
|
+
|
|
204
|
+
def database_info(self) -> DatabaseInfo:
|
|
205
|
+
with self._com.op("read database information", path=self._path):
|
|
206
|
+
return DatabaseInfo(self._path, str(self._db().Version), self._transport())
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------------------------ tables
|
|
209
|
+
def list_tables(self) -> list[TableInfo]:
|
|
210
|
+
with self._com.op("list tables"):
|
|
211
|
+
tabledefs = self._db().TableDefs
|
|
212
|
+
tabledefs.Refresh()
|
|
213
|
+
tables: list[TableInfo] = []
|
|
214
|
+
for index in range(tabledefs.Count):
|
|
215
|
+
tdf = _item(tabledefs, index)
|
|
216
|
+
name = str(tdf.Name)
|
|
217
|
+
attributes = int(tdf.Attributes) & 0xFFFFFFFF
|
|
218
|
+
connect = str(tdf.Connect or "")
|
|
219
|
+
linked = bool(connect) or bool(attributes & (DB_ATTACHED_TABLE | DB_ATTACHED_ODBC))
|
|
220
|
+
tables.append(
|
|
221
|
+
TableInfo(
|
|
222
|
+
name=name,
|
|
223
|
+
is_linked=linked,
|
|
224
|
+
is_system=bool(attributes & DB_SYSTEM_OBJECT)
|
|
225
|
+
or name.casefold().startswith("msys"),
|
|
226
|
+
is_hidden=bool(attributes & DB_HIDDEN_OBJECT) or name.startswith("~"),
|
|
227
|
+
connect=connect or None,
|
|
228
|
+
source_table=str(tdf.SourceTableName or "") or None if linked else None,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
return tables
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _read_field(fld: Any) -> tm.FieldRead:
|
|
235
|
+
properties: dict[str, Any] = {}
|
|
236
|
+
names = [
|
|
237
|
+
"Description",
|
|
238
|
+
"Caption",
|
|
239
|
+
"Format",
|
|
240
|
+
"InputMask",
|
|
241
|
+
"DecimalPlaces",
|
|
242
|
+
"UnicodeCompression",
|
|
243
|
+
"TextFormat",
|
|
244
|
+
]
|
|
245
|
+
dao_type = int(fld.Type)
|
|
246
|
+
if dao_type == tm.DB_DECIMAL:
|
|
247
|
+
names += ["Precision", "Scale"]
|
|
248
|
+
for name in names:
|
|
249
|
+
value = _prop(fld, name)
|
|
250
|
+
if value is not None:
|
|
251
|
+
properties[name.casefold()] = value
|
|
252
|
+
expression = ""
|
|
253
|
+
with contextlib.suppress(pywintypes.com_error, AttributeError):
|
|
254
|
+
expression = str(fld.Expression or "")
|
|
255
|
+
append_only = False
|
|
256
|
+
with contextlib.suppress(pywintypes.com_error, AttributeError):
|
|
257
|
+
append_only = bool(fld.AppendOnly)
|
|
258
|
+
return tm.FieldRead(
|
|
259
|
+
name=str(fld.Name),
|
|
260
|
+
dao_type=dao_type,
|
|
261
|
+
size=int(fld.Size),
|
|
262
|
+
attributes=int(fld.Attributes),
|
|
263
|
+
required=bool(fld.Required),
|
|
264
|
+
allow_zero_length=bool(fld.AllowZeroLength),
|
|
265
|
+
default=str(fld.DefaultValue or ""),
|
|
266
|
+
validation_rule=str(fld.ValidationRule or ""),
|
|
267
|
+
validation_text=str(fld.ValidationText or ""),
|
|
268
|
+
append_only=append_only,
|
|
269
|
+
expression=expression,
|
|
270
|
+
properties=properties,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def read_table(self, name: str) -> TableSpec:
|
|
274
|
+
with self._com.op(f"read table {name!r}", kind=ObjectKind.TABLE, name=name):
|
|
275
|
+
db = self._db()
|
|
276
|
+
db.TableDefs.Refresh()
|
|
277
|
+
tdf = db.TableDefs(name)
|
|
278
|
+
fields = tdf.Fields
|
|
279
|
+
fields.Refresh()
|
|
280
|
+
reads = []
|
|
281
|
+
for index in range(fields.Count):
|
|
282
|
+
fld = _item(fields, index)
|
|
283
|
+
reads.append((int(fld.OrdinalPosition), index, self._read_field(fld)))
|
|
284
|
+
columns = [
|
|
285
|
+
tm.column_from_field(read)
|
|
286
|
+
for _, _, read in sorted(reads, key=lambda item: item[:2])
|
|
287
|
+
]
|
|
288
|
+
indexes: list[IndexSpec] = []
|
|
289
|
+
dao_indexes = tdf.Indexes
|
|
290
|
+
dao_indexes.Refresh()
|
|
291
|
+
for index in range(dao_indexes.Count):
|
|
292
|
+
idx = _item(dao_indexes, index)
|
|
293
|
+
if bool(idx.Foreign):
|
|
294
|
+
continue # hidden index owned by a relationship
|
|
295
|
+
indexes.append(
|
|
296
|
+
IndexSpec(
|
|
297
|
+
name=str(idx.Name),
|
|
298
|
+
fields=_index_fields(idx),
|
|
299
|
+
primary=bool(idx.Primary),
|
|
300
|
+
unique=bool(idx.Unique),
|
|
301
|
+
required=bool(idx.Required),
|
|
302
|
+
ignore_nulls=bool(idx.IgnoreNulls),
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
rule = str(tdf.ValidationRule or "") or None
|
|
306
|
+
spec = TableSpec(
|
|
307
|
+
name=str(tdf.Name),
|
|
308
|
+
columns=tuple(columns), # type: ignore[arg-type]
|
|
309
|
+
indexes=tuple(indexes),
|
|
310
|
+
description=_prop(tdf, "Description") or None,
|
|
311
|
+
validation_rule=rule,
|
|
312
|
+
validation_text=(str(tdf.ValidationText or "") or None) if rule else None,
|
|
313
|
+
)
|
|
314
|
+
return spec.normalized()
|
|
315
|
+
|
|
316
|
+
def count_indexes(self, table: str) -> int:
|
|
317
|
+
with self._com.op(f"count indexes of {table!r}", kind=ObjectKind.TABLE, name=table):
|
|
318
|
+
indexes = self._db().TableDefs(table).Indexes
|
|
319
|
+
indexes.Refresh()
|
|
320
|
+
return int(indexes.Count)
|
|
321
|
+
|
|
322
|
+
def _make_field(self, tdf: Any, column: ColumnBase, plan: tm.FieldPlan) -> Any:
|
|
323
|
+
if plan.size is not None:
|
|
324
|
+
fld = tdf.CreateField(column.name, plan.dao_type, plan.size)
|
|
325
|
+
else:
|
|
326
|
+
fld = tdf.CreateField(column.name, plan.dao_type)
|
|
327
|
+
fld.Attributes = plan.attributes
|
|
328
|
+
self._configure_field(fld, column, plan)
|
|
329
|
+
if plan.append_only:
|
|
330
|
+
fld.AppendOnly = True
|
|
331
|
+
return fld
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def _configure_field(fld: Any, column: ColumnBase, plan: tm.FieldPlan) -> None:
|
|
335
|
+
if not isinstance(column, AutoNumberColumn):
|
|
336
|
+
fld.Required = column.required
|
|
337
|
+
if plan.allow_zero_length is not None:
|
|
338
|
+
fld.AllowZeroLength = plan.allow_zero_length
|
|
339
|
+
if plan.default is not None:
|
|
340
|
+
fld.DefaultValue = plan.default
|
|
341
|
+
if column.validation_rule:
|
|
342
|
+
fld.ValidationRule = column.validation_rule
|
|
343
|
+
if column.validation_text:
|
|
344
|
+
fld.ValidationText = column.validation_text
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _append_index(tdf: Any, index: IndexSpec) -> None:
|
|
348
|
+
idx = tdf.CreateIndex(index.name)
|
|
349
|
+
idx.Primary = index.primary
|
|
350
|
+
idx.Unique = index.unique
|
|
351
|
+
idx.Required = index.required
|
|
352
|
+
idx.IgnoreNulls = index.ignore_nulls
|
|
353
|
+
for field in index.fields:
|
|
354
|
+
idx_field = idx.CreateField(field.name)
|
|
355
|
+
if field.descending:
|
|
356
|
+
idx_field.Attributes = tm.DB_DESCENDING
|
|
357
|
+
idx.Fields.Append(idx_field)
|
|
358
|
+
tdf.Indexes.Append(idx)
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _set_table_validation(tdf: Any, spec: TableSpec) -> None:
|
|
362
|
+
tdf.ValidationRule = spec.validation_rule
|
|
363
|
+
if spec.validation_text:
|
|
364
|
+
tdf.ValidationText = spec.validation_text
|
|
365
|
+
|
|
366
|
+
def _decimal_ddl(self, table: str, column: DecimalColumn) -> None:
|
|
367
|
+
self._run_ddl(
|
|
368
|
+
f"ALTER TABLE {quote_identifier(table)} ADD COLUMN {quote_identifier(column.name)} "
|
|
369
|
+
f"DECIMAL({column.precision},{column.scale})"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def create_table(self, spec: TableSpec) -> None:
|
|
373
|
+
plans: list[tuple[ColumnBase, tm.FieldPlan]] = [
|
|
374
|
+
(column, tm.plan_field(column)) for column in spec.columns
|
|
375
|
+
]
|
|
376
|
+
dao_columns = [(column, plan) for column, plan in plans if not plan.ado_decimal]
|
|
377
|
+
decimal_columns = [(column, plan) for column, plan in plans if plan.ado_decimal]
|
|
378
|
+
with self._com.op(
|
|
379
|
+
f"create table {spec.name!r}", kind=ObjectKind.TABLE, name=spec.name, path=self._path
|
|
380
|
+
):
|
|
381
|
+
db = self._db()
|
|
382
|
+
tdf = db.CreateTableDef(spec.name)
|
|
383
|
+
if not dao_columns:
|
|
384
|
+
tdf.Fields.Append(tdf.CreateField(PLACEHOLDER, tm.DB_LONG))
|
|
385
|
+
for column, plan in dao_columns:
|
|
386
|
+
tdf.Fields.Append(self._make_field(tdf, column, plan))
|
|
387
|
+
if spec.validation_rule and not decimal_columns:
|
|
388
|
+
# With Decimal columns the rule waits until the ADO DDL has added them (_finish_table).
|
|
389
|
+
self._set_table_validation(tdf, spec)
|
|
390
|
+
db.TableDefs.Append(tdf)
|
|
391
|
+
del tdf, db
|
|
392
|
+
try:
|
|
393
|
+
self._finish_table(spec, plans, decimal_columns, placeholder=not dao_columns)
|
|
394
|
+
except BaseException:
|
|
395
|
+
with contextlib.suppress(Exception), self._com.op(f"roll back table {spec.name!r}"):
|
|
396
|
+
self._db().TableDefs.Delete(spec.name)
|
|
397
|
+
raise
|
|
398
|
+
|
|
399
|
+
def _finish_table(
|
|
400
|
+
self,
|
|
401
|
+
spec: TableSpec,
|
|
402
|
+
plans: list[tuple[ColumnBase, tm.FieldPlan]],
|
|
403
|
+
decimal_columns: list[tuple[ColumnBase, tm.FieldPlan]],
|
|
404
|
+
*,
|
|
405
|
+
placeholder: bool,
|
|
406
|
+
) -> None:
|
|
407
|
+
for column, _plan in decimal_columns:
|
|
408
|
+
assert isinstance(column, DecimalColumn)
|
|
409
|
+
self._decimal_ddl(spec.name, column)
|
|
410
|
+
with self._com.op(f"configure table {spec.name!r}", kind=ObjectKind.TABLE, name=spec.name):
|
|
411
|
+
db = self._db()
|
|
412
|
+
db.TableDefs.Refresh()
|
|
413
|
+
tdf = db.TableDefs(spec.name)
|
|
414
|
+
tdf.Fields.Refresh()
|
|
415
|
+
for column, plan in decimal_columns:
|
|
416
|
+
self._configure_field(tdf.Fields(column.name), column, plan)
|
|
417
|
+
if placeholder:
|
|
418
|
+
tdf.Fields.Delete(PLACEHOLDER)
|
|
419
|
+
if decimal_columns:
|
|
420
|
+
for position, column in enumerate(spec.columns):
|
|
421
|
+
tdf.Fields(column.name).OrdinalPosition = position
|
|
422
|
+
for index in spec.indexes:
|
|
423
|
+
self._append_index(tdf, index)
|
|
424
|
+
if spec.validation_rule and decimal_columns:
|
|
425
|
+
self._set_table_validation(tdf, spec)
|
|
426
|
+
if spec.description is not None:
|
|
427
|
+
_set_prop(
|
|
428
|
+
tdf,
|
|
429
|
+
"Description",
|
|
430
|
+
tm.property_type_for("Description", spec.description),
|
|
431
|
+
spec.description,
|
|
432
|
+
)
|
|
433
|
+
for name, value in spec.properties.items():
|
|
434
|
+
_set_prop(tdf, name, tm.property_type_for(name, value), value)
|
|
435
|
+
for column, plan in plans:
|
|
436
|
+
if plan.post_properties:
|
|
437
|
+
fld = tdf.Fields(column.name)
|
|
438
|
+
for name, dao_type, value in plan.post_properties:
|
|
439
|
+
_set_prop(fld, name, dao_type, value)
|
|
440
|
+
|
|
441
|
+
def drop_table(self, name: str) -> None:
|
|
442
|
+
with self._com.op(f"delete table {name!r}", kind=ObjectKind.TABLE, name=name):
|
|
443
|
+
self._db().TableDefs.Delete(name)
|
|
444
|
+
|
|
445
|
+
def rename_table(self, old: str, new: str) -> None:
|
|
446
|
+
with self._com.op(f"rename table {old!r} to {new!r}", kind=ObjectKind.TABLE, name=old):
|
|
447
|
+
self._db().TableDefs(old).Name = new
|
|
448
|
+
|
|
449
|
+
def add_column(self, table: str, column: ColumnSpec) -> None:
|
|
450
|
+
plan = tm.plan_field(column)
|
|
451
|
+
if plan.ado_decimal:
|
|
452
|
+
assert isinstance(column, DecimalColumn)
|
|
453
|
+
self._decimal_ddl(table, column)
|
|
454
|
+
with self._com.op(
|
|
455
|
+
f"add column {column.name!r} to {table!r}", kind=ObjectKind.FIELD, name=column.name
|
|
456
|
+
):
|
|
457
|
+
db = self._db()
|
|
458
|
+
db.TableDefs.Refresh()
|
|
459
|
+
tdf = db.TableDefs(table)
|
|
460
|
+
if plan.ado_decimal:
|
|
461
|
+
tdf.Fields.Refresh()
|
|
462
|
+
self._configure_field(tdf.Fields(column.name), column, plan)
|
|
463
|
+
else:
|
|
464
|
+
tdf.Fields.Append(self._make_field(tdf, column, plan))
|
|
465
|
+
if plan.post_properties:
|
|
466
|
+
fld = tdf.Fields(column.name)
|
|
467
|
+
for name, dao_type, value in plan.post_properties:
|
|
468
|
+
_set_prop(fld, name, dao_type, value)
|
|
469
|
+
|
|
470
|
+
def drop_column(self, table: str, column: str) -> None:
|
|
471
|
+
with self._com.op(
|
|
472
|
+
f"delete column {column!r} of {table!r}", kind=ObjectKind.FIELD, name=column
|
|
473
|
+
):
|
|
474
|
+
self._db().TableDefs(table).Fields.Delete(column)
|
|
475
|
+
|
|
476
|
+
def rename_column(self, table: str, old: str, new: str) -> None:
|
|
477
|
+
with self._com.op(f"rename column {old!r} of {table!r}", kind=ObjectKind.FIELD, name=old):
|
|
478
|
+
self._db().TableDefs(table).Fields(old).Name = new
|
|
479
|
+
|
|
480
|
+
def create_index(self, table: str, index: IndexSpec) -> None:
|
|
481
|
+
with self._com.op(
|
|
482
|
+
f"create index {index.name!r} on {table!r}", kind=ObjectKind.INDEX, name=index.name
|
|
483
|
+
):
|
|
484
|
+
self._append_index(self._db().TableDefs(table), index)
|
|
485
|
+
|
|
486
|
+
def drop_index(self, table: str, name: str) -> None:
|
|
487
|
+
with self._com.op(f"delete index {name!r} of {table!r}", kind=ObjectKind.INDEX, name=name):
|
|
488
|
+
self._db().TableDefs(table).Indexes.Delete(name)
|
|
489
|
+
|
|
490
|
+
# ----------------------------------------------------------------------------- relationships
|
|
491
|
+
def list_relationships(self) -> list[RelationshipSpec]:
|
|
492
|
+
with self._com.op("list relationships"):
|
|
493
|
+
relations = self._db().Relations
|
|
494
|
+
relations.Refresh()
|
|
495
|
+
result: list[RelationshipSpec] = []
|
|
496
|
+
for index in range(relations.Count):
|
|
497
|
+
rel = _item(relations, index)
|
|
498
|
+
attributes = int(rel.Attributes)
|
|
499
|
+
primary, foreign = str(rel.Table), str(rel.ForeignTable)
|
|
500
|
+
if (
|
|
501
|
+
attributes & REL_INHERITED
|
|
502
|
+
or primary.casefold().startswith("msys")
|
|
503
|
+
or foreign.casefold().startswith("msys")
|
|
504
|
+
):
|
|
505
|
+
continue
|
|
506
|
+
fields = rel.Fields
|
|
507
|
+
pairs = [
|
|
508
|
+
(str(_item(fields, i).Name), str(_item(fields, i).ForeignName))
|
|
509
|
+
for i in range(fields.Count)
|
|
510
|
+
]
|
|
511
|
+
join = (
|
|
512
|
+
JoinType.LEFT
|
|
513
|
+
if attributes & REL_LEFT
|
|
514
|
+
else JoinType.RIGHT
|
|
515
|
+
if attributes & REL_RIGHT
|
|
516
|
+
else JoinType.INNER
|
|
517
|
+
)
|
|
518
|
+
result.append(
|
|
519
|
+
RelationshipSpec(
|
|
520
|
+
name=str(rel.Name),
|
|
521
|
+
primary_table=primary,
|
|
522
|
+
primary_columns=tuple(p for p, _ in pairs),
|
|
523
|
+
foreign_table=foreign,
|
|
524
|
+
foreign_columns=tuple(f for _, f in pairs),
|
|
525
|
+
enforce_integrity=not attributes & REL_DONT_ENFORCE,
|
|
526
|
+
cascade_update=bool(attributes & REL_UPDATE_CASCADE),
|
|
527
|
+
cascade_delete=bool(attributes & REL_DELETE_CASCADE),
|
|
528
|
+
one_to_one=bool(attributes & REL_UNIQUE),
|
|
529
|
+
join=join,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
return result
|
|
533
|
+
|
|
534
|
+
def create_relationship(self, spec: RelationshipSpec) -> None:
|
|
535
|
+
attributes = 0
|
|
536
|
+
if spec.one_to_one:
|
|
537
|
+
attributes |= REL_UNIQUE
|
|
538
|
+
if not spec.enforce_integrity:
|
|
539
|
+
attributes |= REL_DONT_ENFORCE
|
|
540
|
+
if spec.cascade_update:
|
|
541
|
+
attributes |= REL_UPDATE_CASCADE
|
|
542
|
+
if spec.cascade_delete:
|
|
543
|
+
attributes |= REL_DELETE_CASCADE
|
|
544
|
+
if spec.join is JoinType.LEFT:
|
|
545
|
+
attributes |= REL_LEFT
|
|
546
|
+
elif spec.join is JoinType.RIGHT:
|
|
547
|
+
attributes |= REL_RIGHT
|
|
548
|
+
name = spec.effective_name
|
|
549
|
+
with self._com.op(f"create relationship {name!r}", kind=ObjectKind.RELATIONSHIP, name=name):
|
|
550
|
+
db = self._db()
|
|
551
|
+
rel = db.CreateRelation(name, spec.primary_table, spec.foreign_table, attributes)
|
|
552
|
+
for primary, foreign in zip(spec.primary_columns, spec.foreign_columns, strict=True):
|
|
553
|
+
fld = rel.CreateField(primary)
|
|
554
|
+
fld.ForeignName = foreign
|
|
555
|
+
rel.Fields.Append(fld)
|
|
556
|
+
db.Relations.Append(rel)
|
|
557
|
+
|
|
558
|
+
def drop_relationship(self, name: str) -> None:
|
|
559
|
+
with self._com.op(f"delete relationship {name!r}", kind=ObjectKind.RELATIONSHIP, name=name):
|
|
560
|
+
self._db().Relations.Delete(name)
|
|
561
|
+
|
|
562
|
+
# ----------------------------------------------------------------------------------- queries
|
|
563
|
+
def list_queries(self) -> list[QueryInfo]:
|
|
564
|
+
with self._com.op("list queries"):
|
|
565
|
+
querydefs = self._db().QueryDefs
|
|
566
|
+
querydefs.Refresh()
|
|
567
|
+
result: list[QueryInfo] = []
|
|
568
|
+
for index in range(querydefs.Count):
|
|
569
|
+
qd = _item(querydefs, index)
|
|
570
|
+
name = str(qd.Name)
|
|
571
|
+
kind = DAO_QUERY_KINDS.get(int(qd.Type), QueryKind.UNKNOWN)
|
|
572
|
+
result.append(QueryInfo(name, kind, name.startswith("~")))
|
|
573
|
+
return result
|
|
574
|
+
|
|
575
|
+
def read_query(self, name: str) -> QuerySpec:
|
|
576
|
+
with self._com.op(f"read query {name!r}", kind=ObjectKind.QUERY, name=name):
|
|
577
|
+
qd = self._db().QueryDefs(name)
|
|
578
|
+
connect = str(qd.Connect or "")
|
|
579
|
+
pass_through = None
|
|
580
|
+
if connect.upper().startswith("ODBC;"):
|
|
581
|
+
pass_through = PassThroughOptions(
|
|
582
|
+
connect=connect,
|
|
583
|
+
returns_records=bool(qd.ReturnsRecords),
|
|
584
|
+
timeout=int(qd.ODBCTimeout),
|
|
585
|
+
)
|
|
586
|
+
return QuerySpec(
|
|
587
|
+
name=str(qd.Name),
|
|
588
|
+
sql=str(qd.SQL),
|
|
589
|
+
description=_prop(qd, "Description") or None,
|
|
590
|
+
pass_through=pass_through,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def query_parameters(self, name: str) -> list[ParameterInfo]:
|
|
594
|
+
with self._com.op(f"read parameters of query {name!r}", kind=ObjectKind.QUERY, name=name):
|
|
595
|
+
params = self._db().QueryDefs(name).Parameters
|
|
596
|
+
return [
|
|
597
|
+
ParameterInfo(
|
|
598
|
+
_strip_brackets(str(_item(params, i).Name)),
|
|
599
|
+
_PARAMETER_TYPES.get(int(_item(params, i).Type), DataType.UNKNOWN),
|
|
600
|
+
)
|
|
601
|
+
for i in range(params.Count)
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
def create_query(self, spec: QuerySpec) -> None:
|
|
605
|
+
with self._com.op(
|
|
606
|
+
f"create query {spec.name!r}", kind=ObjectKind.QUERY, name=spec.name, sql=spec.sql
|
|
607
|
+
):
|
|
608
|
+
db = self._db()
|
|
609
|
+
if spec.pass_through is not None:
|
|
610
|
+
qd = db.CreateQueryDef(spec.name)
|
|
611
|
+
qd.Connect = spec.pass_through.connect
|
|
612
|
+
qd.SQL = spec.sql
|
|
613
|
+
qd.ReturnsRecords = spec.pass_through.returns_records
|
|
614
|
+
qd.ODBCTimeout = spec.pass_through.timeout
|
|
615
|
+
else:
|
|
616
|
+
qd = db.CreateQueryDef(spec.name, spec.sql)
|
|
617
|
+
if spec.description is not None:
|
|
618
|
+
_set_prop(
|
|
619
|
+
qd,
|
|
620
|
+
"Description",
|
|
621
|
+
tm.property_type_for("Description", spec.description),
|
|
622
|
+
spec.description,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def set_query_sql(self, name: str, sql: str) -> None:
|
|
626
|
+
with self._com.op(f"update query {name!r}", kind=ObjectKind.QUERY, name=name, sql=sql):
|
|
627
|
+
self._db().QueryDefs(name).SQL = sql
|
|
628
|
+
|
|
629
|
+
def rename_query(self, old: str, new: str) -> None:
|
|
630
|
+
with self._com.op(f"rename query {old!r} to {new!r}", kind=ObjectKind.QUERY, name=old):
|
|
631
|
+
self._db().QueryDefs(old).Name = new
|
|
632
|
+
|
|
633
|
+
def drop_query(self, name: str) -> None:
|
|
634
|
+
with self._com.op(f"delete query {name!r}", kind=ObjectKind.QUERY, name=name):
|
|
635
|
+
self._db().QueryDefs.Delete(name)
|
|
636
|
+
|
|
637
|
+
# -------------------------------------------------------------------------------- properties
|
|
638
|
+
def _target(self, target: PropertyTarget) -> Any:
|
|
639
|
+
db = self._db()
|
|
640
|
+
if target.kind == "database":
|
|
641
|
+
return db
|
|
642
|
+
if target.kind == "table":
|
|
643
|
+
return db.TableDefs(target.name)
|
|
644
|
+
if target.kind == "field":
|
|
645
|
+
return db.TableDefs(target.name).Fields(target.field)
|
|
646
|
+
return db.QueryDefs(target.name)
|
|
647
|
+
|
|
648
|
+
def get_property(self, target: PropertyTarget, name: str) -> PropertyValue:
|
|
649
|
+
with self._com.op(
|
|
650
|
+
f"read property {name!r} of {target.describe()}", kind=ObjectKind.PROPERTY, name=name
|
|
651
|
+
):
|
|
652
|
+
obj = self._target(target)
|
|
653
|
+
try:
|
|
654
|
+
return normalize(obj.Properties(name).Value)
|
|
655
|
+
except pywintypes.com_error as exc:
|
|
656
|
+
if _error_number(exc) == PROPERTY_NOT_FOUND:
|
|
657
|
+
raise ObjectNotFoundError(
|
|
658
|
+
f"{target.describe()} has no property {name!r}",
|
|
659
|
+
kind=ObjectKind.PROPERTY,
|
|
660
|
+
name=name,
|
|
661
|
+
) from None
|
|
662
|
+
raise
|
|
663
|
+
|
|
664
|
+
def set_property(
|
|
665
|
+
self,
|
|
666
|
+
target: PropertyTarget,
|
|
667
|
+
name: str,
|
|
668
|
+
value: PropertyValue,
|
|
669
|
+
type: PropertyType | None = None,
|
|
670
|
+
) -> None:
|
|
671
|
+
with self._com.op(
|
|
672
|
+
f"set property {name!r} of {target.describe()}", kind=ObjectKind.PROPERTY, name=name
|
|
673
|
+
):
|
|
674
|
+
_set_prop(self._target(target), name, tm.property_type_for(name, value, type), value)
|
|
675
|
+
|
|
676
|
+
def delete_property(self, target: PropertyTarget, name: str) -> None:
|
|
677
|
+
with self._com.op(
|
|
678
|
+
f"delete property {name!r} of {target.describe()}", kind=ObjectKind.PROPERTY, name=name
|
|
679
|
+
):
|
|
680
|
+
obj = self._target(target)
|
|
681
|
+
try:
|
|
682
|
+
obj.Properties.Delete(name)
|
|
683
|
+
except pywintypes.com_error as exc:
|
|
684
|
+
if _error_number(exc) in (PROPERTY_NOT_FOUND, 3265):
|
|
685
|
+
raise ObjectNotFoundError(
|
|
686
|
+
f"{target.describe()} has no property {name!r}",
|
|
687
|
+
kind=ObjectKind.PROPERTY,
|
|
688
|
+
name=name,
|
|
689
|
+
) from None
|
|
690
|
+
raise
|
|
691
|
+
|
|
692
|
+
def list_properties(self, target: PropertyTarget) -> dict[str, PropertyValue]:
|
|
693
|
+
with self._com.op(f"list properties of {target.describe()}"):
|
|
694
|
+
properties = self._target(target).Properties
|
|
695
|
+
result: dict[str, PropertyValue] = {}
|
|
696
|
+
for index in range(properties.Count):
|
|
697
|
+
prop = _item(properties, index)
|
|
698
|
+
with contextlib.suppress(pywintypes.com_error):
|
|
699
|
+
value = normalize(prop.Value)
|
|
700
|
+
if value is None or isinstance(value, (str, bool, int, float, datetime)):
|
|
701
|
+
result[str(prop.Name)] = value
|
|
702
|
+
return result
|
|
703
|
+
|
|
704
|
+
# -------------------------------------------------------------------------------------- data
|
|
705
|
+
@staticmethod
|
|
706
|
+
def _bind(qd: Any, params: Mapping[str, Any] | None, sql: str) -> None:
|
|
707
|
+
dao_params = qd.Parameters
|
|
708
|
+
available = {
|
|
709
|
+
_strip_brackets(str(_item(dao_params, i).Name)).casefold(): _item(dao_params, i)
|
|
710
|
+
for i in range(dao_params.Count)
|
|
711
|
+
}
|
|
712
|
+
given = {key.strip("[]").casefold(): value for key, value in (params or {}).items()}
|
|
713
|
+
unknown = sorted(set(given) - set(available))
|
|
714
|
+
if unknown:
|
|
715
|
+
known = ", ".join(sorted(available)) or "none"
|
|
716
|
+
raise SpecError(
|
|
717
|
+
f"unknown parameter(s) {unknown}; the statement's parameters are: {known}. (A [name] that matches "
|
|
718
|
+
"a column is a column reference, not a parameter.)"
|
|
719
|
+
)
|
|
720
|
+
missing = sorted(set(available) - set(given))
|
|
721
|
+
if missing:
|
|
722
|
+
raise MissingParameterError(f"no value supplied for parameter(s) {missing}", sql=sql)
|
|
723
|
+
for key, value in given.items():
|
|
724
|
+
parameter = available[key]
|
|
725
|
+
if isinstance(value, _BINARY) and int(parameter.Type) not in (
|
|
726
|
+
tm.DB_BINARY,
|
|
727
|
+
tm.DB_LONGBINARY,
|
|
728
|
+
):
|
|
729
|
+
# An implicit (text) parameter would silently corrupt the bytes (ADR 0002).
|
|
730
|
+
raise SpecError(
|
|
731
|
+
f"parameter [{key}] receives bytes but is not binary; declare it in the saved query, "
|
|
732
|
+
f"e.g. 'PARAMETERS [{key}] LongBinary;'"
|
|
733
|
+
)
|
|
734
|
+
parameter.Value = _param_value(value)
|
|
735
|
+
|
|
736
|
+
def _query(self, sql: str, name: str | None, params: Mapping[str, Any] | None = None) -> Any:
|
|
737
|
+
db = self._db()
|
|
738
|
+
if name is not None:
|
|
739
|
+
return db.QueryDefs(name)
|
|
740
|
+
return db.CreateQueryDef("", _declare_binary_parameters(sql, params))
|
|
741
|
+
|
|
742
|
+
def _run(self, sql: str, name: str | None, params: Mapping[str, Any] | None) -> int:
|
|
743
|
+
label = f"run query {name!r}" if name else "run SQL statement"
|
|
744
|
+
with self._com.op(label, kind=ObjectKind.QUERY if name else None, name=name, sql=sql):
|
|
745
|
+
qd = self._query(sql, name, params)
|
|
746
|
+
self._bind(qd, params, sql)
|
|
747
|
+
qd.Execute(DB_FAIL_ON_ERROR)
|
|
748
|
+
return int(qd.RecordsAffected)
|
|
749
|
+
|
|
750
|
+
def _fetch(
|
|
751
|
+
self, sql: str, name: str | None, params: Mapping[str, Any] | None, limit: int | None
|
|
752
|
+
) -> FetchResult:
|
|
753
|
+
label = f"read rows of query {name!r}" if name else "read rows"
|
|
754
|
+
with self._com.op(label, kind=ObjectKind.QUERY if name else None, name=name, sql=sql):
|
|
755
|
+
qd = self._query(sql, name, params)
|
|
756
|
+
self._bind(qd, params, sql)
|
|
757
|
+
rs = qd.OpenRecordset(DB_OPEN_SNAPSHOT)
|
|
758
|
+
try:
|
|
759
|
+
fields = rs.Fields
|
|
760
|
+
columns = tuple(str(_item(fields, i).Name) for i in range(fields.Count))
|
|
761
|
+
rows: list[tuple[Any, ...]] = []
|
|
762
|
+
while not rs.EOF and (limit is None or len(rows) < limit):
|
|
763
|
+
batch = FETCH_BATCH if limit is None else min(FETCH_BATCH, limit - len(rows))
|
|
764
|
+
data = rs.GetRows(batch)
|
|
765
|
+
if not data:
|
|
766
|
+
break
|
|
767
|
+
rows.extend(normalize(tuple(row)) for row in zip(*data, strict=True))
|
|
768
|
+
return FetchResult(columns, rows)
|
|
769
|
+
finally:
|
|
770
|
+
with contextlib.suppress(pywintypes.com_error):
|
|
771
|
+
rs.Close()
|
|
772
|
+
|
|
773
|
+
def execute(self, sql: str, params: Mapping[str, Any] | None = None) -> int:
|
|
774
|
+
return self._run(sql, None, params)
|
|
775
|
+
|
|
776
|
+
def fetch(
|
|
777
|
+
self, sql: str, params: Mapping[str, Any] | None = None, *, limit: int | None = None
|
|
778
|
+
) -> FetchResult:
|
|
779
|
+
return self._fetch(sql, None, params, limit)
|
|
780
|
+
|
|
781
|
+
def execute_saved(self, name: str, params: Mapping[str, Any] | None = None) -> int:
|
|
782
|
+
return self._run("", name, params)
|
|
783
|
+
|
|
784
|
+
def fetch_saved(
|
|
785
|
+
self, name: str, params: Mapping[str, Any] | None = None, *, limit: int | None = None
|
|
786
|
+
) -> FetchResult:
|
|
787
|
+
return self._fetch("", name, params, limit)
|
|
788
|
+
|
|
789
|
+
# --------------------------------------------------------------------------------- documents
|
|
790
|
+
def list_documents(self, kind: ObjectKind) -> list[str]:
|
|
791
|
+
container = _CONTAINERS.get(kind)
|
|
792
|
+
if container is None:
|
|
793
|
+
raise SpecError(f"{kind.value} objects are not stored in a DAO container")
|
|
794
|
+
with self._com.op(f"list {kind.value}s"):
|
|
795
|
+
containers = self._db().Containers
|
|
796
|
+
containers.Refresh()
|
|
797
|
+
available = {str(_item(containers, i).Name).casefold() for i in range(containers.Count)}
|
|
798
|
+
if container.casefold() not in available:
|
|
799
|
+
# Databases created by DAO get the Forms/Reports/Scripts/Modules containers only once
|
|
800
|
+
# Access has opened them: no container means no objects of that kind (ADR 0002).
|
|
801
|
+
return []
|
|
802
|
+
documents = _item(containers, container).Documents
|
|
803
|
+
documents.Refresh()
|
|
804
|
+
names = [str(_item(documents, i).Name) for i in range(documents.Count)]
|
|
805
|
+
return [name for name in names if not name.startswith("~")]
|