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,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)