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.
Files changed (86) hide show
  1. pyaccesskit/AGENT_GUIDE.md +455 -0
  2. pyaccesskit/__init__.py +167 -0
  3. pyaccesskit/__main__.py +6 -0
  4. pyaccesskit/_backends/__init__.py +0 -0
  5. pyaccesskit/_backends/access/__init__.py +1 -0
  6. pyaccesskit/_backends/access/design.py +415 -0
  7. pyaccesskit/_backends/dao/__init__.py +1 -0
  8. pyaccesskit/_backends/dao/profile.py +40 -0
  9. pyaccesskit/_backends/dao/schema.py +805 -0
  10. pyaccesskit/_backends/dao/typemap.py +390 -0
  11. pyaccesskit/_backends/fake/__init__.py +3 -0
  12. pyaccesskit/_backends/fake/backend.py +680 -0
  13. pyaccesskit/_backends/protocols.py +339 -0
  14. pyaccesskit/_com/__init__.py +1 -0
  15. pyaccesskit/_com/constants.py +394 -0
  16. pyaccesskit/_com/dispatch.py +50 -0
  17. pyaccesskit/_com/errors.py +184 -0
  18. pyaccesskit/_com/gateway.py +199 -0
  19. pyaccesskit/_com/raw.py +164 -0
  20. pyaccesskit/_com/runtime.py +39 -0
  21. pyaccesskit/_com/variants.py +72 -0
  22. pyaccesskit/_engines/__init__.py +48 -0
  23. pyaccesskit/_engines/access.py +300 -0
  24. pyaccesskit/_engines/inproc.py +148 -0
  25. pyaccesskit/_engines/probe.py +231 -0
  26. pyaccesskit/_ledger.py +158 -0
  27. pyaccesskit/_ops/__init__.py +0 -0
  28. pyaccesskit/_ops/design.py +127 -0
  29. pyaccesskit/_ops/schema.py +471 -0
  30. pyaccesskit/_session/__init__.py +1 -0
  31. pyaccesskit/_session/protocols.py +78 -0
  32. pyaccesskit/_session/session.py +354 -0
  33. pyaccesskit/_text/__init__.py +0 -0
  34. pyaccesskit/_text/codec.py +114 -0
  35. pyaccesskit/_version.py +3 -0
  36. pyaccesskit/_win/__init__.py +1 -0
  37. pyaccesskit/_win/access_process.py +348 -0
  38. pyaccesskit/_win/console.py +56 -0
  39. pyaccesskit/_win/inspector.py +53 -0
  40. pyaccesskit/_win/job.py +65 -0
  41. pyaccesskit/_win/processes.py +159 -0
  42. pyaccesskit/_win/watchdog.py +253 -0
  43. pyaccesskit/cli/__init__.py +10 -0
  44. pyaccesskit/cli/_output.py +101 -0
  45. pyaccesskit/cli/agent.py +99 -0
  46. pyaccesskit/cli/app.py +54 -0
  47. pyaccesskit/cli/cleanup.py +56 -0
  48. pyaccesskit/cli/doctor.py +101 -0
  49. pyaccesskit/cli/inspection.py +223 -0
  50. pyaccesskit/database.py +296 -0
  51. pyaccesskit/diagnostics.py +319 -0
  52. pyaccesskit/enums.py +258 -0
  53. pyaccesskit/errors.py +407 -0
  54. pyaccesskit/forms/__init__.py +45 -0
  55. pyaccesskit/forms/builder.py +295 -0
  56. pyaccesskit/forms/collection.py +117 -0
  57. pyaccesskit/forms/controls.py +157 -0
  58. pyaccesskit/forms/layout.py +300 -0
  59. pyaccesskit/forms/spec.py +169 -0
  60. pyaccesskit/forms/vba.py +138 -0
  61. pyaccesskit/maintenance.py +32 -0
  62. pyaccesskit/modules.py +101 -0
  63. pyaccesskit/objects.py +81 -0
  64. pyaccesskit/options.py +40 -0
  65. pyaccesskit/properties.py +74 -0
  66. pyaccesskit/py.typed +0 -0
  67. pyaccesskit/queries.py +190 -0
  68. pyaccesskit/relationships.py +143 -0
  69. pyaccesskit/schema/__init__.py +73 -0
  70. pyaccesskit/schema/_base.py +55 -0
  71. pyaccesskit/schema/_reserved_words.py +55 -0
  72. pyaccesskit/schema/columns.py +609 -0
  73. pyaccesskit/schema/compat.py +57 -0
  74. pyaccesskit/schema/expressions.py +162 -0
  75. pyaccesskit/schema/indexes.py +114 -0
  76. pyaccesskit/schema/names.py +122 -0
  77. pyaccesskit/schema/queries.py +192 -0
  78. pyaccesskit/schema/relationships.py +132 -0
  79. pyaccesskit/schema/tables.py +178 -0
  80. pyaccesskit/tables.py +333 -0
  81. pyaccesskit/units.py +301 -0
  82. pyaccesskit-0.1.0.dist-info/METADATA +201 -0
  83. pyaccesskit-0.1.0.dist-info/RECORD +86 -0
  84. pyaccesskit-0.1.0.dist-info/WHEEL +4 -0
  85. pyaccesskit-0.1.0.dist-info/entry_points.txt +2 -0
  86. 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("~")]