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,609 @@
1
+ """Column (field) specifications and the :class:`Column` factory.
2
+
3
+ Each Access data type has its own immutable spec class (``TextColumn``, ``NumberColumn``...) so that only
4
+ options meaningful for that type are accepted. The classes form a discriminated union on ``type``
5
+ (:data:`ColumnSpec`), which is also how columns appear in JSON/YAML::
6
+
7
+ {"type": "text", "name": "Email", "length": 255}
8
+
9
+ Most code uses the :class:`Column` factory, which mirrors the Access table designer::
10
+
11
+ Column.autonumber("CustomerID", primary_key=True)
12
+ Column.text("CustomerName", length=200, required=True)
13
+ Column.number("Quantity", size=NumberSize.INTEGER)
14
+ Column.currency("UnitPrice", default=0)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Mapping
20
+ from datetime import date, datetime, time
21
+ from decimal import Decimal, InvalidOperation
22
+ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Self, TypedDict, cast
23
+
24
+ from pydantic import Field, field_validator, model_validator
25
+
26
+ from pyaccesskit.enums import DataType, NumberSize
27
+ from pyaccesskit.schema._base import PropertyValue, SpecModel, build
28
+ from pyaccesskit.schema.expressions import DefaultValue, Expr, parse_literal
29
+ from pyaccesskit.schema.names import check_name
30
+
31
+ if TYPE_CHECKING:
32
+ from typing_extensions import Unpack
33
+
34
+ __all__ = [
35
+ "COLUMN_CLASSES",
36
+ "AutoNumberColumn",
37
+ "Column",
38
+ "ColumnBase",
39
+ "ColumnOptions",
40
+ "ColumnSpec",
41
+ "CurrencyColumn",
42
+ "DateTimeColumn",
43
+ "DecimalColumn",
44
+ "HyperlinkColumn",
45
+ "LongTextColumn",
46
+ "NumberColumn",
47
+ "OleObjectColumn",
48
+ "OleObjectOptions",
49
+ "TextColumn",
50
+ "UnsupportedColumn",
51
+ "YesNoColumn",
52
+ ]
53
+
54
+
55
+ # ------------------------------------------------------------------------------------ default coercion
56
+ # Strings (e.g. from YAML/JSON) are accepted as *literals* of the column's type only. Anything that is not
57
+ # a literal must be an explicit Expr(...) so that a typo never silently becomes an expression.
58
+ def _literal(value: Any, expected: str) -> Any:
59
+ if not isinstance(value, str):
60
+ return value
61
+ parsed = parse_literal(value)
62
+ if isinstance(parsed, Expr) or parsed is None:
63
+ raise ValueError(f"default {value!r} is not {expected}; wrap expressions in Expr(...)")
64
+ return parsed
65
+
66
+
67
+ def _as_int(value: Any) -> int | Expr:
68
+ value = _literal(value, "an integer")
69
+ if isinstance(value, Expr):
70
+ return value
71
+ if isinstance(value, bool):
72
+ raise ValueError("booleans are not valid defaults for integer columns (use 0 or 1)")
73
+ if isinstance(value, int):
74
+ return value
75
+ if isinstance(value, (float, Decimal)) and value == int(value):
76
+ return int(value)
77
+ raise ValueError(f"default {value!r} is not an integer; wrap expressions in Expr(...)")
78
+
79
+
80
+ def _as_float(value: Any) -> float | Expr:
81
+ value = _literal(value, "a number")
82
+ if isinstance(value, Expr):
83
+ return value
84
+ if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
85
+ raise ValueError(f"default {value!r} is not a number; wrap expressions in Expr(...)")
86
+ return float(value)
87
+
88
+
89
+ def _as_decimal(value: Any) -> Decimal | Expr:
90
+ value = _literal(value, "a number")
91
+ if isinstance(value, Expr):
92
+ return value
93
+ if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
94
+ raise ValueError(f"default {value!r} is not a number; wrap expressions in Expr(...)")
95
+ try:
96
+ return value if isinstance(value, Decimal) else Decimal(str(value))
97
+ except InvalidOperation as exc: # pragma: no cover - finite floats always convert
98
+ raise ValueError(f"default {value!r} is not a valid decimal") from exc
99
+
100
+
101
+ def _as_bool(value: Any) -> bool | Expr:
102
+ value = _literal(value, "a Yes/No value (True/False)")
103
+ if isinstance(value, (bool, Expr)):
104
+ return value
105
+ if isinstance(value, int) and value in (0, 1, -1):
106
+ return value != 0
107
+ raise ValueError(f"default {value!r} is not a Yes/No value; use True/False")
108
+
109
+
110
+ def _as_datetime(value: Any) -> datetime | date | time | Expr:
111
+ if isinstance(value, str):
112
+ text = value.strip()
113
+ try:
114
+ parsed = datetime.fromisoformat(text)
115
+ except ValueError:
116
+ try:
117
+ value = time.fromisoformat(text)
118
+ except ValueError:
119
+ value = _literal(text, "a date/time (use an ISO date, #date#, or Expr('Now()'))")
120
+ else:
121
+ value = parsed.date() if len(text) <= 10 else parsed
122
+ if isinstance(value, (datetime, date, time, Expr)):
123
+ return value
124
+ raise ValueError(f"default {value!r} is not a date/time; use datetime/date or Expr('Now()')")
125
+
126
+
127
+ # ------------------------------------------------------------------------------------------ base class
128
+ class ColumnBase(SpecModel):
129
+ """Options shared by every column type.
130
+
131
+ Attributes:
132
+ name: Field name (≤64 characters; see Access naming rules).
133
+ required: Disallow Null values (*Required* property).
134
+ default: Default value: a Python literal (rendered as an Access literal) or an ``Expr``.
135
+ validation_rule: *Validation Rule* expression, e.g. ``">0"``.
136
+ validation_text: Message shown when the validation rule fails.
137
+ description: *Description* shown in the table designer.
138
+ caption: *Caption* used as the default label text on forms and datasheet headers.
139
+ format: *Format* property, e.g. ``"Short Date"`` or ``"Currency"``.
140
+ primary_key: Shorthand: include this column in the table's primary key.
141
+ unique: Shorthand: create a unique single-column index named after the column.
142
+ indexed: Shorthand: create a non-unique single-column index named after the column.
143
+ properties: Other Access/DAO field properties to set verbatim (escape hatch).
144
+ """
145
+
146
+ data_type: ClassVar[DataType]
147
+ supports_default: ClassVar[bool] = True
148
+ indexable: ClassVar[bool] = True
149
+
150
+ name: str
151
+ required: bool = False
152
+ default: DefaultValue | None = None
153
+ validation_rule: str | None = None
154
+ validation_text: str | None = None
155
+ description: str | None = None
156
+ caption: str | None = None
157
+ format: str | None = None
158
+ primary_key: bool = False
159
+ unique: bool = False
160
+ indexed: bool = False
161
+ properties: dict[str, PropertyValue] = Field(default_factory=dict)
162
+
163
+ @field_validator("name")
164
+ @classmethod
165
+ def _check_name(cls, value: str) -> str:
166
+ return check_name(value, what="column name")
167
+
168
+ @model_validator(mode="before")
169
+ @classmethod
170
+ def _coerce_default_input(cls, data: Any) -> Any:
171
+ if not isinstance(data, Mapping):
172
+ return data
173
+ fields = cast("Mapping[str, Any]", data)
174
+ value = fields.get("default")
175
+ if value is None or isinstance(value, Expr):
176
+ return fields
177
+ if isinstance(value, Mapping):
178
+ payload = cast("Mapping[str, Any]", value)
179
+ if set(payload) == {"expr"}: # the JSON form of an Expr
180
+ return {**fields, "default": Expr(str(payload["expr"]))}
181
+ return {**fields, "default": cls._coerce_default(value, fields)}
182
+
183
+ @classmethod
184
+ def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
185
+ """Convert a user-supplied default to this column type's canonical Python type."""
186
+ return value
187
+
188
+ @model_validator(mode="after")
189
+ def _check_common(self) -> Self:
190
+ if self.default is not None and not self.supports_default:
191
+ raise ValueError(f"{self.data_type.value} columns cannot have a default value")
192
+ if (self.primary_key or self.unique or self.indexed) and not self.indexable:
193
+ raise ValueError(f"{self.data_type.value} columns cannot be indexed")
194
+ if self.validation_text is not None and self.validation_rule is None:
195
+ raise ValueError("validation_text requires a validation_rule")
196
+ return self
197
+
198
+ def normalized(self) -> Self:
199
+ """Canonical form: the index shorthands are cleared (they live in ``TableSpec.indexes``)."""
200
+ if not (self.primary_key or self.unique or self.indexed):
201
+ return self
202
+ return self.model_copy(update={"primary_key": False, "unique": False, "indexed": False})
203
+
204
+
205
+ # ------------------------------------------------------------------------------------------ text types
206
+ class TextColumn(ColumnBase):
207
+ """Short Text: up to 255 characters."""
208
+
209
+ data_type: ClassVar[DataType] = DataType.TEXT
210
+ type: Literal["text"] = "text"
211
+ length: int = Field(default=255, ge=1, le=255)
212
+ allow_zero_length: bool = False
213
+ unicode_compression: bool = True
214
+ input_mask: str | None = None
215
+
216
+
217
+ class LongTextColumn(ColumnBase):
218
+ """Long Text (Memo): up to ~1 GB; optionally rich text and append-only."""
219
+
220
+ data_type: ClassVar[DataType] = DataType.LONG_TEXT
221
+ type: Literal["long_text"] = "long_text"
222
+ rich_text: bool = False
223
+ append_only: bool = False
224
+ allow_zero_length: bool = False
225
+ unicode_compression: bool = True
226
+
227
+
228
+ class HyperlinkColumn(ColumnBase):
229
+ """Hyperlink: a Long Text field flagged as a hyperlink."""
230
+
231
+ data_type: ClassVar[DataType] = DataType.HYPERLINK
232
+ type: Literal["hyperlink"] = "hyperlink"
233
+ allow_zero_length: bool = False
234
+ unicode_compression: bool = True
235
+
236
+
237
+ # ---------------------------------------------------------------------------------------- number types
238
+ class NumberColumn(ColumnBase):
239
+ """Number with a *Field Size* of Byte, Integer (16-bit), Long Integer, Single, Double or Replication ID."""
240
+
241
+ data_type: ClassVar[DataType] = DataType.NUMBER
242
+ type: Literal["number"] = "number"
243
+ size: NumberSize = NumberSize.LONG_INTEGER
244
+ decimal_places: int | None = Field(default=None, ge=0, le=15)
245
+ """``None`` means *Auto*."""
246
+ input_mask: str | None = None
247
+
248
+ @classmethod
249
+ def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
250
+ size = NumberSize(data.get("size", NumberSize.LONG_INTEGER))
251
+ if size in (NumberSize.SINGLE, NumberSize.DOUBLE):
252
+ return _as_float(value)
253
+ if size is NumberSize.REPLICATION_ID:
254
+ raise ValueError("Replication ID columns only accept Expr(...) defaults")
255
+ return _as_int(value)
256
+
257
+ @model_validator(mode="after")
258
+ def _check_replication_default(self) -> Self:
259
+ # Access defines an AutoNumber (Replication ID) as exactly this; one spelling keeps specs canonical.
260
+ if (
261
+ self.size is NumberSize.REPLICATION_ID
262
+ and isinstance(self.default, Expr)
263
+ and self.default.expr.replace(" ", "").casefold() == "genguid()"
264
+ ):
265
+ raise ValueError(
266
+ "a Replication ID number defaulting to GenGUID() is an AutoNumber; "
267
+ "use Column.autonumber(name, replication_id=True)"
268
+ )
269
+ return self
270
+
271
+
272
+ class DecimalColumn(ColumnBase):
273
+ """Number with *Field Size* = Decimal (exact, with precision and scale)."""
274
+
275
+ data_type: ClassVar[DataType] = DataType.DECIMAL
276
+ type: Literal["decimal"] = "decimal"
277
+ precision: int = Field(default=18, ge=1, le=28)
278
+ scale: int = Field(default=0, ge=0, le=28)
279
+ decimal_places: int | None = Field(default=None, ge=0, le=15)
280
+ input_mask: str | None = None
281
+
282
+ @classmethod
283
+ def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
284
+ return _as_decimal(value)
285
+
286
+ @model_validator(mode="after")
287
+ def _check_scale(self) -> Self:
288
+ if self.scale > self.precision:
289
+ raise ValueError(f"scale ({self.scale}) cannot exceed precision ({self.precision})")
290
+ return self
291
+
292
+
293
+ class CurrencyColumn(ColumnBase):
294
+ """Currency: fixed-point, 4 decimal places, no rounding surprises."""
295
+
296
+ data_type: ClassVar[DataType] = DataType.CURRENCY
297
+ type: Literal["currency"] = "currency"
298
+ decimal_places: int | None = Field(default=None, ge=0, le=15)
299
+ input_mask: str | None = None
300
+
301
+ @classmethod
302
+ def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
303
+ return _as_decimal(value)
304
+
305
+
306
+ class AutoNumberColumn(ColumnBase):
307
+ """AutoNumber: an incrementing Long Integer, or a Replication ID (GUID).
308
+
309
+ *Random* AutoNumbers are not offered: DAO silently ignores the setting, so PyAccessKit cannot create
310
+ them reliably (see ADR 0002).
311
+ """
312
+
313
+ data_type: ClassVar[DataType] = DataType.AUTONUMBER
314
+ supports_default: ClassVar[bool] = False
315
+ type: Literal["autonumber"] = "autonumber"
316
+ replication_id: bool = False
317
+
318
+ @model_validator(mode="after")
319
+ def _check_autonumber(self) -> Self:
320
+ if self.required:
321
+ raise ValueError("AutoNumber columns are always populated; 'required' does not apply")
322
+ return self
323
+
324
+
325
+ # ------------------------------------------------------------------------------------------ other types
326
+ class DateTimeColumn(ColumnBase):
327
+ """Date/Time."""
328
+
329
+ data_type: ClassVar[DataType] = DataType.DATE_TIME
330
+ type: Literal["date_time"] = "date_time"
331
+ input_mask: str | None = None
332
+
333
+ @classmethod
334
+ def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
335
+ return _as_datetime(value)
336
+
337
+
338
+ class YesNoColumn(ColumnBase):
339
+ """Yes/No (Boolean). Shown as a check box in datasheets and bound forms."""
340
+
341
+ data_type: ClassVar[DataType] = DataType.YES_NO
342
+ type: Literal["yes_no"] = "yes_no"
343
+
344
+ @classmethod
345
+ def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
346
+ return _as_bool(value)
347
+
348
+
349
+ class OleObjectColumn(ColumnBase):
350
+ """OLE Object (long binary data)."""
351
+
352
+ data_type: ClassVar[DataType] = DataType.OLE_OBJECT
353
+ supports_default: ClassVar[bool] = False
354
+ indexable: ClassVar[bool] = False
355
+ type: Literal["ole_object"] = "ole_object"
356
+
357
+
358
+ class UnsupportedColumn(ColumnBase):
359
+ """A column PyAccessKit can read but not create yet (Attachment, Calculated, Large Number...).
360
+
361
+ It appears when introspecting existing databases so that ``to_spec()`` never fails; creating a table
362
+ with one raises :class:`~pyaccesskit.errors.SpecError`.
363
+ """
364
+
365
+ data_type: ClassVar[DataType] = DataType.UNKNOWN
366
+ type: Literal["unsupported"] = "unsupported"
367
+ dao_type: int
368
+ detail: str = ""
369
+
370
+
371
+ ColumnSpec = Annotated[
372
+ TextColumn
373
+ | LongTextColumn
374
+ | NumberColumn
375
+ | DecimalColumn
376
+ | CurrencyColumn
377
+ | AutoNumberColumn
378
+ | DateTimeColumn
379
+ | YesNoColumn
380
+ | HyperlinkColumn
381
+ | OleObjectColumn
382
+ | UnsupportedColumn,
383
+ Field(discriminator="type"),
384
+ ]
385
+ """Any column spec (a Pydantic discriminated union on ``type``)."""
386
+
387
+ COLUMN_CLASSES: tuple[type[ColumnBase], ...] = (
388
+ TextColumn,
389
+ LongTextColumn,
390
+ NumberColumn,
391
+ DecimalColumn,
392
+ CurrencyColumn,
393
+ AutoNumberColumn,
394
+ DateTimeColumn,
395
+ YesNoColumn,
396
+ HyperlinkColumn,
397
+ OleObjectColumn,
398
+ UnsupportedColumn,
399
+ )
400
+
401
+
402
+ # ---------------------------------------------------------------------------------------------- factory
403
+ class ColumnOptions(TypedDict, total=False):
404
+ """Keyword options accepted by every :class:`Column` constructor (see :class:`ColumnBase`)."""
405
+
406
+ required: bool
407
+ default: DefaultValue | None
408
+ validation_rule: str | None
409
+ validation_text: str | None
410
+ description: str | None
411
+ caption: str | None
412
+ format: str | None
413
+ primary_key: bool
414
+ unique: bool
415
+ indexed: bool
416
+ properties: dict[str, PropertyValue]
417
+
418
+
419
+ class AutoNumberOptions(TypedDict, total=False):
420
+ """Keyword options accepted by :meth:`Column.autonumber` (no default/required)."""
421
+
422
+ validation_rule: str | None
423
+ validation_text: str | None
424
+ description: str | None
425
+ caption: str | None
426
+ format: str | None
427
+ primary_key: bool
428
+ unique: bool
429
+ indexed: bool
430
+ properties: dict[str, PropertyValue]
431
+
432
+
433
+ class OleObjectOptions(AutoNumberOptions, total=False):
434
+ """Keyword options accepted by :meth:`Column.ole_object` (no default value)."""
435
+
436
+ required: bool
437
+
438
+
439
+ class Column:
440
+ """Factory for column specs, mirroring the data types of the Access table designer.
441
+
442
+ Every constructor raises :class:`~pyaccesskit.errors.SpecError` for invalid options.
443
+ """
444
+
445
+ def __init__(self) -> None:
446
+ raise TypeError(
447
+ "Column is a factory namespace; call Column.text(...), Column.number(...), etc."
448
+ )
449
+
450
+ @staticmethod
451
+ def text(
452
+ name: str,
453
+ *,
454
+ length: int = 255,
455
+ allow_zero_length: bool = False,
456
+ unicode_compression: bool = True,
457
+ input_mask: str | None = None,
458
+ **options: Unpack[ColumnOptions],
459
+ ) -> TextColumn:
460
+ """Short Text (``length`` 1-255, default 255)."""
461
+ return build(
462
+ TextColumn,
463
+ f"text column {name!r}",
464
+ name=name,
465
+ length=length,
466
+ allow_zero_length=allow_zero_length,
467
+ unicode_compression=unicode_compression,
468
+ input_mask=input_mask,
469
+ **options,
470
+ )
471
+
472
+ @staticmethod
473
+ def long_text(
474
+ name: str,
475
+ *,
476
+ rich_text: bool = False,
477
+ append_only: bool = False,
478
+ allow_zero_length: bool = False,
479
+ unicode_compression: bool = True,
480
+ **options: Unpack[ColumnOptions],
481
+ ) -> LongTextColumn:
482
+ """Long Text (Memo)."""
483
+ return build(
484
+ LongTextColumn,
485
+ f"long text column {name!r}",
486
+ name=name,
487
+ rich_text=rich_text,
488
+ append_only=append_only,
489
+ allow_zero_length=allow_zero_length,
490
+ unicode_compression=unicode_compression,
491
+ **options,
492
+ )
493
+
494
+ @staticmethod
495
+ def number(
496
+ name: str,
497
+ *,
498
+ size: NumberSize | str = NumberSize.LONG_INTEGER,
499
+ decimal_places: int | None = None,
500
+ input_mask: str | None = None,
501
+ **options: Unpack[ColumnOptions],
502
+ ) -> NumberColumn:
503
+ """Number. ``size`` defaults to Long Integer (32-bit), exactly like Access."""
504
+ return build(
505
+ NumberColumn,
506
+ f"number column {name!r}",
507
+ name=name,
508
+ size=size,
509
+ decimal_places=decimal_places,
510
+ input_mask=input_mask,
511
+ **options,
512
+ )
513
+
514
+ @staticmethod
515
+ def decimal(
516
+ name: str,
517
+ *,
518
+ precision: int = 18,
519
+ scale: int = 0,
520
+ decimal_places: int | None = None,
521
+ input_mask: str | None = None,
522
+ **options: Unpack[ColumnOptions],
523
+ ) -> DecimalColumn:
524
+ """Number with Field Size = Decimal(``precision``, ``scale``)."""
525
+ return build(
526
+ DecimalColumn,
527
+ f"decimal column {name!r}",
528
+ name=name,
529
+ precision=precision,
530
+ scale=scale,
531
+ decimal_places=decimal_places,
532
+ input_mask=input_mask,
533
+ **options,
534
+ )
535
+
536
+ @staticmethod
537
+ def currency(
538
+ name: str,
539
+ *,
540
+ decimal_places: int | None = None,
541
+ input_mask: str | None = None,
542
+ **options: Unpack[ColumnOptions],
543
+ ) -> CurrencyColumn:
544
+ """Currency."""
545
+ return build(
546
+ CurrencyColumn,
547
+ f"currency column {name!r}",
548
+ name=name,
549
+ decimal_places=decimal_places,
550
+ input_mask=input_mask,
551
+ **options,
552
+ )
553
+
554
+ @staticmethod
555
+ def autonumber(
556
+ name: str,
557
+ *,
558
+ replication_id: bool = False,
559
+ **options: Unpack[AutoNumberOptions],
560
+ ) -> AutoNumberColumn:
561
+ """AutoNumber (Long Integer by default; ``replication_id=True`` for a GUID)."""
562
+ return build(
563
+ AutoNumberColumn,
564
+ f"autonumber column {name!r}",
565
+ name=name,
566
+ replication_id=replication_id,
567
+ **options,
568
+ )
569
+
570
+ @staticmethod
571
+ def date_time(
572
+ name: str, *, input_mask: str | None = None, **options: Unpack[ColumnOptions]
573
+ ) -> DateTimeColumn:
574
+ """Date/Time."""
575
+ return build(
576
+ DateTimeColumn,
577
+ f"date/time column {name!r}",
578
+ name=name,
579
+ input_mask=input_mask,
580
+ **options,
581
+ )
582
+
583
+ @staticmethod
584
+ def yes_no(name: str, **options: Unpack[ColumnOptions]) -> YesNoColumn:
585
+ """Yes/No (Boolean)."""
586
+ return build(YesNoColumn, f"yes/no column {name!r}", name=name, **options)
587
+
588
+ @staticmethod
589
+ def hyperlink(
590
+ name: str,
591
+ *,
592
+ allow_zero_length: bool = False,
593
+ unicode_compression: bool = True,
594
+ **options: Unpack[ColumnOptions],
595
+ ) -> HyperlinkColumn:
596
+ """Hyperlink."""
597
+ return build(
598
+ HyperlinkColumn,
599
+ f"hyperlink column {name!r}",
600
+ name=name,
601
+ allow_zero_length=allow_zero_length,
602
+ unicode_compression=unicode_compression,
603
+ **options,
604
+ )
605
+
606
+ @staticmethod
607
+ def ole_object(name: str, **options: Unpack[OleObjectOptions]) -> OleObjectColumn:
608
+ """OLE Object (long binary)."""
609
+ return build(OleObjectColumn, f"OLE object column {name!r}", name=name, **options)
@@ -0,0 +1,57 @@
1
+ """Storage-type rules shared by pre-validation and the in-memory backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyaccesskit.enums import NumberSize
6
+ from pyaccesskit.schema.columns import (
7
+ AutoNumberColumn,
8
+ ColumnBase,
9
+ CurrencyColumn,
10
+ DateTimeColumn,
11
+ DecimalColumn,
12
+ HyperlinkColumn,
13
+ LongTextColumn,
14
+ NumberColumn,
15
+ OleObjectColumn,
16
+ TextColumn,
17
+ YesNoColumn,
18
+ )
19
+
20
+ __all__ = ["relationship_compatible", "storage_type"]
21
+
22
+ _NUMBER_STORAGE = {
23
+ NumberSize.BYTE: "byte",
24
+ NumberSize.INTEGER: "integer",
25
+ NumberSize.LONG_INTEGER: "long",
26
+ NumberSize.SINGLE: "single",
27
+ NumberSize.DOUBLE: "double",
28
+ NumberSize.REPLICATION_ID: "guid",
29
+ }
30
+
31
+
32
+ def storage_type(column: ColumnBase) -> str:
33
+ """The engine-level storage type of a column (AutoNumber stores a Long Integer, etc.)."""
34
+ if isinstance(column, AutoNumberColumn):
35
+ return "guid" if column.replication_id else "long"
36
+ if isinstance(column, NumberColumn):
37
+ return _NUMBER_STORAGE[column.size]
38
+ simple: tuple[tuple[type[ColumnBase], str], ...] = (
39
+ (TextColumn, "text"),
40
+ (LongTextColumn, "memo"),
41
+ (HyperlinkColumn, "memo"),
42
+ (CurrencyColumn, "currency"),
43
+ (DecimalColumn, "decimal"),
44
+ (DateTimeColumn, "datetime"),
45
+ (YesNoColumn, "boolean"),
46
+ (OleObjectColumn, "binary"),
47
+ )
48
+ for cls, kind in simple:
49
+ if isinstance(column, cls):
50
+ return kind
51
+ return "unknown" # pragma: no cover - every column class is listed above
52
+
53
+
54
+ def relationship_compatible(primary: ColumnBase, foreign: ColumnBase) -> bool:
55
+ """Whether two columns can be paired in a relationship (same storage type, indexable)."""
56
+ kind = storage_type(primary)
57
+ return kind == storage_type(foreign) and kind not in ("memo", "binary", "unknown")