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,132 @@
|
|
|
1
|
+
# pyright: reportUnnecessaryIsInstance=false
|
|
2
|
+
# (parse_column_ref validates untyped user input at runtime)
|
|
3
|
+
"""Relationship specifications."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from typing import Any, Self
|
|
9
|
+
|
|
10
|
+
from pydantic import Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from pyaccesskit.enums import JoinType
|
|
13
|
+
from pyaccesskit.errors import SpecError
|
|
14
|
+
from pyaccesskit.schema._base import Items, SpecModel, build
|
|
15
|
+
from pyaccesskit.schema.names import check_name
|
|
16
|
+
|
|
17
|
+
__all__ = ["ColumnRef", "RelationshipSpec", "parse_column_ref"]
|
|
18
|
+
|
|
19
|
+
ColumnRef = str | tuple[str, str | Sequence[str]]
|
|
20
|
+
"""A relationship end: ``"Table.Column"``, ``("Table", "Column")`` or ``("Table", ["Col1", "Col2"])``."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_column_ref(ref: ColumnRef, *, what: str) -> tuple[str, tuple[str, ...]]:
|
|
24
|
+
"""Split a :data:`ColumnRef` into ``(table, columns)``.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
SpecError: If the reference is malformed.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(ref, str):
|
|
30
|
+
table, sep, column = ref.partition(".")
|
|
31
|
+
if not sep or not table or not column or "." in column:
|
|
32
|
+
raise SpecError(f"{what} must look like 'Table.Column', got {ref!r}")
|
|
33
|
+
return check_name(table, what=f"{what} table"), (check_name(column, what=f"{what} column"),)
|
|
34
|
+
if isinstance(ref, tuple) and len(ref) == 2:
|
|
35
|
+
table, columns = ref
|
|
36
|
+
names = (columns,) if isinstance(columns, str) else tuple(columns)
|
|
37
|
+
if not names:
|
|
38
|
+
raise SpecError(f"{what} needs at least one column")
|
|
39
|
+
return check_name(table, what=f"{what} table"), tuple(
|
|
40
|
+
check_name(c, what=f"{what} column") for c in names
|
|
41
|
+
)
|
|
42
|
+
raise SpecError(f"{what} must be 'Table.Column' or (table, columns), got {ref!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RelationshipSpec(SpecModel):
|
|
46
|
+
"""A relationship between a primary ("one") table and a foreign ("many") table.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
name: Relationship name. Defaults to Access's convention: primary table name + foreign table name.
|
|
50
|
+
primary_table: The table on the "one" side (its columns need a primary key or unique index).
|
|
51
|
+
primary_columns: Referenced columns of the primary table.
|
|
52
|
+
foreign_table: The table on the "many" side.
|
|
53
|
+
foreign_columns: Referencing columns of the foreign table (same count and compatible types).
|
|
54
|
+
enforce_integrity: Enforce referential integrity (the default; creates a hidden index).
|
|
55
|
+
cascade_update: Cascade updates of the primary key to related rows.
|
|
56
|
+
cascade_delete: Cascade deletes to related rows.
|
|
57
|
+
one_to_one: Declare a one-to-one relationship.
|
|
58
|
+
join: Default join type used by the query designer.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str | None = None
|
|
62
|
+
primary_table: str
|
|
63
|
+
primary_columns: Items[str] = Field(min_length=1, max_length=10)
|
|
64
|
+
foreign_table: str
|
|
65
|
+
foreign_columns: Items[str] = Field(min_length=1, max_length=10)
|
|
66
|
+
enforce_integrity: bool = True
|
|
67
|
+
cascade_update: bool = False
|
|
68
|
+
cascade_delete: bool = False
|
|
69
|
+
one_to_one: bool = False
|
|
70
|
+
join: JoinType = JoinType.INNER
|
|
71
|
+
|
|
72
|
+
@field_validator("name")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _check_name(cls, value: str | None) -> str | None:
|
|
75
|
+
return None if value is None else check_name(value, what="relationship name")
|
|
76
|
+
|
|
77
|
+
@field_validator("primary_table", "foreign_table")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _check_table(cls, value: str) -> str:
|
|
80
|
+
return check_name(value, what="table name")
|
|
81
|
+
|
|
82
|
+
@field_validator("primary_columns", "foreign_columns", mode="before")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _coerce_columns(cls, value: Any) -> Any:
|
|
85
|
+
return (value,) if isinstance(value, str) else value
|
|
86
|
+
|
|
87
|
+
@field_validator("primary_columns", "foreign_columns")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _check_columns(cls, value: Sequence[str]) -> tuple[str, ...]:
|
|
90
|
+
return tuple(check_name(column, what="column name") for column in value)
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def _check_relationship(self) -> Self:
|
|
94
|
+
if len(self.primary_columns) != len(self.foreign_columns):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"primary side has {len(self.primary_columns)} column(s) but foreign side has "
|
|
97
|
+
f"{len(self.foreign_columns)}; relationships pair columns one to one"
|
|
98
|
+
)
|
|
99
|
+
if (self.cascade_update or self.cascade_delete) and not self.enforce_integrity:
|
|
100
|
+
raise ValueError("cascade_update/cascade_delete require enforce_integrity=True")
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def effective_name(self) -> str:
|
|
105
|
+
"""``name`` or Access's default name (primary table + foreign table)."""
|
|
106
|
+
return self.name if self.name is not None else f"{self.primary_table}{self.foreign_table}"
|
|
107
|
+
|
|
108
|
+
def normalized(self) -> RelationshipSpec:
|
|
109
|
+
"""Canonical form with the name filled in."""
|
|
110
|
+
if self.name is not None:
|
|
111
|
+
return self
|
|
112
|
+
return self.model_copy(update={"name": self.effective_name})
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def between(cls, primary: ColumnRef, foreign: ColumnRef, **options: Any) -> RelationshipSpec:
|
|
116
|
+
"""Build a relationship from ``"Table.Column"`` references.
|
|
117
|
+
|
|
118
|
+
Example::
|
|
119
|
+
|
|
120
|
+
RelationshipSpec.between("Customers.CustomerID", "Orders.CustomerID", cascade_delete=True)
|
|
121
|
+
"""
|
|
122
|
+
primary_table, primary_columns = parse_column_ref(primary, what="primary")
|
|
123
|
+
foreign_table, foreign_columns = parse_column_ref(foreign, what="foreign")
|
|
124
|
+
return build(
|
|
125
|
+
cls,
|
|
126
|
+
f"relationship {primary_table}->{foreign_table}",
|
|
127
|
+
primary_table=primary_table,
|
|
128
|
+
primary_columns=primary_columns,
|
|
129
|
+
foreign_table=foreign_table,
|
|
130
|
+
foreign_columns=foreign_columns,
|
|
131
|
+
**options,
|
|
132
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Table specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from pyaccesskit.enums import DataType
|
|
10
|
+
from pyaccesskit.schema._base import Items, PropertyValue, SpecModel
|
|
11
|
+
from pyaccesskit.schema.columns import ColumnBase, ColumnSpec
|
|
12
|
+
from pyaccesskit.schema.indexes import PRIMARY_KEY_NAME, IndexField, IndexSpec
|
|
13
|
+
from pyaccesskit.schema.names import check_name
|
|
14
|
+
|
|
15
|
+
__all__ = ["MAX_COLUMNS", "MAX_INDEXES", "TableSpec"]
|
|
16
|
+
|
|
17
|
+
MAX_COLUMNS = 255
|
|
18
|
+
MAX_INDEXES = 32
|
|
19
|
+
"""Access limit per table, *including* the hidden indexes created for enforced relationships."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TableSpec(SpecModel):
|
|
23
|
+
"""A local Access table.
|
|
24
|
+
|
|
25
|
+
Indexes can be declared explicitly in ``indexes`` or with shorthands: ``primary_key=`` here, or
|
|
26
|
+
``primary_key=``/``unique=``/``indexed=`` on individual columns. :meth:`normalized` expands every
|
|
27
|
+
shorthand into explicit :class:`IndexSpec` objects; that canonical form is what introspection returns.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
name: Table name.
|
|
31
|
+
columns: Columns in order (1-255).
|
|
32
|
+
indexes: Explicit indexes.
|
|
33
|
+
primary_key: Shorthand for a (possibly composite) primary key.
|
|
34
|
+
description: Table *Description* property.
|
|
35
|
+
validation_rule: Table-level *Validation Rule* (can reference several columns).
|
|
36
|
+
validation_text: Message shown when the table validation rule fails.
|
|
37
|
+
properties: Other Access/DAO table properties to set verbatim (escape hatch).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
columns: Items[ColumnSpec] = Field(min_length=1, max_length=MAX_COLUMNS)
|
|
42
|
+
indexes: Items[IndexSpec] = ()
|
|
43
|
+
primary_key: Items[str] | None = None
|
|
44
|
+
description: str | None = None
|
|
45
|
+
validation_rule: str | None = None
|
|
46
|
+
validation_text: str | None = None
|
|
47
|
+
properties: dict[str, PropertyValue] = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@field_validator("name")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _check_name(cls, value: str) -> str:
|
|
52
|
+
return check_name(value, what="table name")
|
|
53
|
+
|
|
54
|
+
@field_validator("primary_key", mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _coerce_primary_key(cls, value: object) -> object:
|
|
57
|
+
return (value,) if isinstance(value, str) else value
|
|
58
|
+
|
|
59
|
+
@model_validator(mode="after")
|
|
60
|
+
def _check_table(self) -> Self:
|
|
61
|
+
names: dict[str, str] = {}
|
|
62
|
+
for column in self.columns:
|
|
63
|
+
key = column.name.casefold()
|
|
64
|
+
if key in names:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"duplicate column name {column.name!r} (names are case-insensitive)"
|
|
67
|
+
)
|
|
68
|
+
names[key] = column.name
|
|
69
|
+
autonumbers = [c.name for c in self.columns if c.data_type is DataType.AUTONUMBER]
|
|
70
|
+
if len(autonumbers) > 1:
|
|
71
|
+
raise ValueError(f"a table can have only one AutoNumber column, got {autonumbers}")
|
|
72
|
+
if self.validation_text is not None and self.validation_rule is None:
|
|
73
|
+
raise ValueError("validation_text requires a validation_rule")
|
|
74
|
+
|
|
75
|
+
indexes = self._expand_indexes()
|
|
76
|
+
if len(indexes) > MAX_INDEXES:
|
|
77
|
+
raise ValueError(f"a table can have at most {MAX_INDEXES} indexes, got {len(indexes)}")
|
|
78
|
+
index_names: set[str] = set()
|
|
79
|
+
for index in indexes:
|
|
80
|
+
key = index.name.casefold()
|
|
81
|
+
if key in index_names:
|
|
82
|
+
raise ValueError(f"duplicate index name {index.name!r}")
|
|
83
|
+
index_names.add(key)
|
|
84
|
+
for field_name in index.field_names:
|
|
85
|
+
column = self._find(field_name)
|
|
86
|
+
if column is None:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"index {index.name!r} refers to unknown column {field_name!r}"
|
|
89
|
+
)
|
|
90
|
+
if not column.indexable:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{column.data_type.value} column {column.name!r} cannot be indexed"
|
|
93
|
+
)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------------------------ helpers
|
|
97
|
+
def _find(self, name: str) -> ColumnBase | None:
|
|
98
|
+
key = name.casefold()
|
|
99
|
+
return next((c for c in self.columns if c.name.casefold() == key), None)
|
|
100
|
+
|
|
101
|
+
def column(self, name: str) -> ColumnBase:
|
|
102
|
+
"""Return the column called ``name`` (case-insensitive).
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
KeyError: If there is no such column.
|
|
106
|
+
"""
|
|
107
|
+
column = self._find(name)
|
|
108
|
+
if column is None:
|
|
109
|
+
raise KeyError(name)
|
|
110
|
+
return column
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def column_names(self) -> tuple[str, ...]:
|
|
114
|
+
"""Column names in order."""
|
|
115
|
+
return tuple(column.name for column in self.columns)
|
|
116
|
+
|
|
117
|
+
def _expand_indexes(self) -> tuple[IndexSpec, ...]:
|
|
118
|
+
explicit = list(self.indexes)
|
|
119
|
+
primary_sources = [
|
|
120
|
+
label
|
|
121
|
+
for label, present in (
|
|
122
|
+
("indexes", any(index.primary for index in explicit)),
|
|
123
|
+
("primary_key=", self.primary_key is not None),
|
|
124
|
+
("column primary_key=True", any(column.primary_key for column in self.columns)),
|
|
125
|
+
)
|
|
126
|
+
if present
|
|
127
|
+
]
|
|
128
|
+
if len(primary_sources) > 1:
|
|
129
|
+
raise ValueError(f"primary key declared more than once ({', '.join(primary_sources)})")
|
|
130
|
+
if sum(1 for index in explicit if index.primary) > 1:
|
|
131
|
+
raise ValueError("a table can have only one primary key")
|
|
132
|
+
|
|
133
|
+
derived: list[IndexSpec] = []
|
|
134
|
+
pk_columns = tuple(self.primary_key or (c.name for c in self.columns if c.primary_key))
|
|
135
|
+
if pk_columns:
|
|
136
|
+
derived.append(
|
|
137
|
+
IndexSpec(
|
|
138
|
+
name=PRIMARY_KEY_NAME,
|
|
139
|
+
fields=tuple(IndexField(name=name) for name in pk_columns),
|
|
140
|
+
primary=True,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
pk_keys = {name.casefold() for name in pk_columns}
|
|
144
|
+
for primary in (index for index in explicit if index.primary):
|
|
145
|
+
pk_keys = {name.casefold() for name in primary.field_names}
|
|
146
|
+
single_pk = len(pk_keys) == 1
|
|
147
|
+
for column in self.columns:
|
|
148
|
+
if not (column.unique or column.indexed):
|
|
149
|
+
continue
|
|
150
|
+
if single_pk and column.name.casefold() in pk_keys:
|
|
151
|
+
continue # the primary key already indexes it uniquely
|
|
152
|
+
derived.append(
|
|
153
|
+
IndexSpec(
|
|
154
|
+
name=column.name, fields=(IndexField(name=column.name),), unique=column.unique
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
combined = explicit + derived
|
|
159
|
+
return tuple(sorted(combined, key=lambda index: (not index.primary, index.name.casefold())))
|
|
160
|
+
|
|
161
|
+
def effective_indexes(self) -> tuple[IndexSpec, ...]:
|
|
162
|
+
"""All indexes with shorthands expanded, primary key first, then by name."""
|
|
163
|
+
return self._expand_indexes()
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def primary_index(self) -> IndexSpec | None:
|
|
167
|
+
"""The primary-key index, if any (shorthands included)."""
|
|
168
|
+
return next((index for index in self._expand_indexes() if index.primary), None)
|
|
169
|
+
|
|
170
|
+
def normalized(self) -> TableSpec:
|
|
171
|
+
"""The canonical form: shorthands expanded into ``indexes`` and cleared from columns."""
|
|
172
|
+
return self.model_copy(
|
|
173
|
+
update={
|
|
174
|
+
"columns": tuple(column.normalized() for column in self.columns),
|
|
175
|
+
"indexes": self._expand_indexes(),
|
|
176
|
+
"primary_key": None,
|
|
177
|
+
}
|
|
178
|
+
)
|
pyaccesskit/tables.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Tables, fields and indexes: the ``db.tables`` collection and its handles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, overload
|
|
7
|
+
|
|
8
|
+
from pyaccesskit._backends.protocols import PropertyTarget, TableInfo
|
|
9
|
+
from pyaccesskit._ops import schema as ops
|
|
10
|
+
from pyaccesskit.enums import DataType, ObjectKind
|
|
11
|
+
from pyaccesskit.errors import ObjectNotFoundError
|
|
12
|
+
from pyaccesskit.properties import PropertyBag
|
|
13
|
+
from pyaccesskit.schema import (
|
|
14
|
+
ColumnBase,
|
|
15
|
+
ColumnSpec,
|
|
16
|
+
IndexSpec,
|
|
17
|
+
PropertyValue,
|
|
18
|
+
TableSpec,
|
|
19
|
+
quote_identifier,
|
|
20
|
+
)
|
|
21
|
+
from pyaccesskit.schema._base import build
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pyaccesskit._session.session import Session
|
|
25
|
+
|
|
26
|
+
__all__ = ["Field", "FieldCollection", "Table", "TableCollection"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Field:
|
|
30
|
+
"""A column of a table (a live, name-based handle)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, session: Session, table: Table, name: str) -> None:
|
|
33
|
+
self._session = session
|
|
34
|
+
self._table = table
|
|
35
|
+
self._name = name
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
"""The field name."""
|
|
40
|
+
return self._name
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def spec(self) -> ColumnBase:
|
|
44
|
+
"""The current definition of the field."""
|
|
45
|
+
return self._table.to_spec().column(self._name)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def data_type(self) -> DataType:
|
|
49
|
+
"""The Access data type."""
|
|
50
|
+
return self.spec.data_type
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def size(self) -> int | None:
|
|
54
|
+
"""Text length for Short Text fields, else ``None``."""
|
|
55
|
+
length: int | None = getattr(self.spec, "length", None)
|
|
56
|
+
return length
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def required(self) -> bool:
|
|
60
|
+
"""Whether Null is disallowed."""
|
|
61
|
+
return self.spec.required
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def properties(self) -> PropertyBag:
|
|
65
|
+
"""The field's DAO properties."""
|
|
66
|
+
return PropertyBag(self._session, PropertyTarget.column(self._table.name, self._name))
|
|
67
|
+
|
|
68
|
+
def rename(self, new_name: str) -> None:
|
|
69
|
+
"""Rename the field."""
|
|
70
|
+
self._session.check_writable(f"rename column {self._name!r}")
|
|
71
|
+
ops.rename_column(self._session.schema(), self._table.name, self._name, new_name)
|
|
72
|
+
self._name = new_name
|
|
73
|
+
|
|
74
|
+
def drop(self) -> None:
|
|
75
|
+
"""Delete the field."""
|
|
76
|
+
self._session.check_writable(f"drop column {self._name!r}")
|
|
77
|
+
ops.drop_column(self._session.schema(), self._table.name, self._name)
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"<Field {self._table.name}.{self._name}>"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FieldCollection:
|
|
84
|
+
"""The fields of a table, in order."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, session: Session, table: Table) -> None:
|
|
87
|
+
self._session = session
|
|
88
|
+
self._table = table
|
|
89
|
+
|
|
90
|
+
def names(self) -> list[str]:
|
|
91
|
+
"""Field names in order."""
|
|
92
|
+
return list(self._table.to_spec().column_names)
|
|
93
|
+
|
|
94
|
+
def __iter__(self) -> Iterator[Field]:
|
|
95
|
+
return iter([Field(self._session, self._table, name) for name in self.names()])
|
|
96
|
+
|
|
97
|
+
def __len__(self) -> int:
|
|
98
|
+
return len(self.names())
|
|
99
|
+
|
|
100
|
+
def __contains__(self, name: object) -> bool:
|
|
101
|
+
return isinstance(name, str) and any(n.casefold() == name.casefold() for n in self.names())
|
|
102
|
+
|
|
103
|
+
def __getitem__(self, name: str) -> Field:
|
|
104
|
+
for actual in self.names():
|
|
105
|
+
if actual.casefold() == name.casefold():
|
|
106
|
+
return Field(self._session, self._table, actual)
|
|
107
|
+
raise ObjectNotFoundError(
|
|
108
|
+
f"table {self._table.name!r} has no field {name!r}", kind=ObjectKind.FIELD, name=name
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Table:
|
|
113
|
+
"""A table (a live, name-based handle). ``to_spec()`` returns an immutable snapshot."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, session: Session, name: str) -> None:
|
|
116
|
+
self._session = session
|
|
117
|
+
self._name = name
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def name(self) -> str:
|
|
121
|
+
"""The table name."""
|
|
122
|
+
return self._name
|
|
123
|
+
|
|
124
|
+
def _info(self) -> TableInfo:
|
|
125
|
+
return ops.require_table(self._session.schema(), self._name)
|
|
126
|
+
|
|
127
|
+
def to_spec(self) -> TableSpec:
|
|
128
|
+
"""The table's current definition as a normalized :class:`TableSpec`."""
|
|
129
|
+
return self._session.schema().read_table(self._name)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def fields(self) -> FieldCollection:
|
|
133
|
+
"""The table's fields."""
|
|
134
|
+
return FieldCollection(self._session, self)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def indexes(self) -> tuple[IndexSpec, ...]:
|
|
138
|
+
"""The table's indexes (excluding the hidden ones owned by relationships)."""
|
|
139
|
+
return tuple(self.to_spec().indexes)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def primary_key(self) -> IndexSpec | None:
|
|
143
|
+
"""The primary-key index, if any."""
|
|
144
|
+
return self.to_spec().primary_index
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def description(self) -> str | None:
|
|
148
|
+
"""The table *Description*."""
|
|
149
|
+
return self.to_spec().description
|
|
150
|
+
|
|
151
|
+
@description.setter
|
|
152
|
+
def description(self, value: str | None) -> None:
|
|
153
|
+
if value is None:
|
|
154
|
+
if "Description" in self.properties:
|
|
155
|
+
self.properties.delete("Description")
|
|
156
|
+
else:
|
|
157
|
+
self.properties["Description"] = value
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def is_linked(self) -> bool:
|
|
161
|
+
"""Whether this is a linked table."""
|
|
162
|
+
return self._info().is_linked
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def connect(self) -> str | None:
|
|
166
|
+
"""A linked table's connection string (``None`` for local tables). It may contain credentials."""
|
|
167
|
+
return self._info().connect
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def source_table(self) -> str | None:
|
|
171
|
+
"""A linked table's name in its source database (``None`` for local tables)."""
|
|
172
|
+
return self._info().source_table
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_system(self) -> bool:
|
|
176
|
+
"""Whether this is a system table (``MSys*``)."""
|
|
177
|
+
return self._info().is_system
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def properties(self) -> PropertyBag:
|
|
181
|
+
"""The table's DAO properties."""
|
|
182
|
+
return PropertyBag(self._session, PropertyTarget.table(self._name))
|
|
183
|
+
|
|
184
|
+
def record_count(self) -> int:
|
|
185
|
+
"""Number of rows (runs ``SELECT COUNT(*)``)."""
|
|
186
|
+
rows = self._session.schema().fetch(
|
|
187
|
+
f"SELECT COUNT(*) AS N FROM {quote_identifier(self._name)}"
|
|
188
|
+
)
|
|
189
|
+
return int(rows.rows[0][0])
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------------------- mutation
|
|
192
|
+
def add_column(self, column: ColumnSpec) -> Field:
|
|
193
|
+
"""Append a column (``unique=``/``indexed=``/``primary_key=`` also create the index)."""
|
|
194
|
+
self._session.check_writable(f"add column to {self._name!r}")
|
|
195
|
+
ops.add_column(self._session.schema(), self._name, column)
|
|
196
|
+
return Field(self._session, self, column.name)
|
|
197
|
+
|
|
198
|
+
def drop_column(self, name: str) -> None:
|
|
199
|
+
"""Delete a column (drop its indexes and relationships first)."""
|
|
200
|
+
self.fields[name].drop()
|
|
201
|
+
|
|
202
|
+
def rename_column(self, old: str, new: str) -> None:
|
|
203
|
+
"""Rename a column."""
|
|
204
|
+
self.fields[old].rename(new)
|
|
205
|
+
|
|
206
|
+
def create_index(self, index: IndexSpec) -> None:
|
|
207
|
+
"""Create an index."""
|
|
208
|
+
self._session.check_writable(f"create index on {self._name!r}")
|
|
209
|
+
ops.create_index(self._session.schema(), self._name, index)
|
|
210
|
+
|
|
211
|
+
def drop_index(self, name: str) -> None:
|
|
212
|
+
"""Delete an index."""
|
|
213
|
+
self._session.check_writable(f"drop index of {self._name!r}")
|
|
214
|
+
ops.drop_index(self._session.schema(), self._name, name)
|
|
215
|
+
|
|
216
|
+
def rename(self, new_name: str) -> None:
|
|
217
|
+
"""Rename the table."""
|
|
218
|
+
self._session.check_writable(f"rename table {self._name!r}")
|
|
219
|
+
self._name = ops.rename_table(self._session.schema(), self._name, new_name)
|
|
220
|
+
|
|
221
|
+
def drop(self, *, drop_relationships: bool = False) -> None:
|
|
222
|
+
"""Delete the table (with ``drop_relationships=True``, its relationships first)."""
|
|
223
|
+
self._session.check_writable(f"drop table {self._name!r}")
|
|
224
|
+
ops.drop_table(self._session.schema(), self._name, drop_relationships=drop_relationships)
|
|
225
|
+
|
|
226
|
+
def __repr__(self) -> str:
|
|
227
|
+
return f"<Table {self._name!r}>"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TableCollection:
|
|
231
|
+
"""``db.tables``: every local and linked table (system tables are hidden unless asked for)."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, session: Session) -> None:
|
|
234
|
+
self._session = session
|
|
235
|
+
|
|
236
|
+
def _infos(self, include_system: bool) -> list[TableInfo]:
|
|
237
|
+
return [
|
|
238
|
+
info
|
|
239
|
+
for info in self._session.schema().list_tables()
|
|
240
|
+
if include_system or not (info.is_system or info.is_hidden)
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
def names(self, *, include_system: bool = False) -> list[str]:
|
|
244
|
+
"""Table names."""
|
|
245
|
+
return [info.name for info in self._infos(include_system)]
|
|
246
|
+
|
|
247
|
+
def __iter__(self) -> Iterator[Table]:
|
|
248
|
+
return iter([Table(self._session, name) for name in self.names()])
|
|
249
|
+
|
|
250
|
+
def __len__(self) -> int:
|
|
251
|
+
return len(self.names())
|
|
252
|
+
|
|
253
|
+
def __contains__(self, name: object) -> bool:
|
|
254
|
+
return isinstance(name, str) and self.get(name) is not None
|
|
255
|
+
|
|
256
|
+
def get(self, name: str) -> Table | None:
|
|
257
|
+
"""The table called ``name`` (case-insensitive), or ``None``."""
|
|
258
|
+
info = ops.find_table(self._session.schema(), name)
|
|
259
|
+
return Table(self._session, info.name) if info is not None else None
|
|
260
|
+
|
|
261
|
+
def __getitem__(self, name: str) -> Table:
|
|
262
|
+
table = self.get(name)
|
|
263
|
+
if table is None:
|
|
264
|
+
raise ObjectNotFoundError(
|
|
265
|
+
f"table {name!r} does not exist", kind=ObjectKind.TABLE, name=name
|
|
266
|
+
)
|
|
267
|
+
return table
|
|
268
|
+
|
|
269
|
+
@overload
|
|
270
|
+
def create(self, spec: TableSpec, /) -> Table: ...
|
|
271
|
+
|
|
272
|
+
@overload
|
|
273
|
+
def create(
|
|
274
|
+
self,
|
|
275
|
+
name: str,
|
|
276
|
+
/,
|
|
277
|
+
*,
|
|
278
|
+
columns: Sequence[ColumnSpec],
|
|
279
|
+
indexes: Sequence[IndexSpec] = (),
|
|
280
|
+
primary_key: Sequence[str] | str | None = None,
|
|
281
|
+
description: str | None = None,
|
|
282
|
+
validation_rule: str | None = None,
|
|
283
|
+
validation_text: str | None = None,
|
|
284
|
+
properties: Mapping[str, PropertyValue] | None = None,
|
|
285
|
+
) -> Table: ...
|
|
286
|
+
|
|
287
|
+
def create(
|
|
288
|
+
self,
|
|
289
|
+
spec_or_name: TableSpec | str,
|
|
290
|
+
/,
|
|
291
|
+
*,
|
|
292
|
+
columns: Sequence[ColumnSpec] | None = None,
|
|
293
|
+
indexes: Sequence[IndexSpec] = (),
|
|
294
|
+
primary_key: Sequence[str] | str | None = None,
|
|
295
|
+
description: str | None = None,
|
|
296
|
+
validation_rule: str | None = None,
|
|
297
|
+
validation_text: str | None = None,
|
|
298
|
+
properties: Mapping[str, PropertyValue] | None = None,
|
|
299
|
+
) -> Table:
|
|
300
|
+
"""Create a table from a :class:`TableSpec` or from keyword arguments.
|
|
301
|
+
|
|
302
|
+
Creation is atomic: if any step fails, the partially created table is removed.
|
|
303
|
+
"""
|
|
304
|
+
if isinstance(spec_or_name, TableSpec):
|
|
305
|
+
spec = spec_or_name
|
|
306
|
+
else:
|
|
307
|
+
spec = build(
|
|
308
|
+
TableSpec,
|
|
309
|
+
f"table {spec_or_name!r}",
|
|
310
|
+
name=spec_or_name,
|
|
311
|
+
columns=tuple(columns or ()),
|
|
312
|
+
indexes=tuple(indexes),
|
|
313
|
+
primary_key=primary_key,
|
|
314
|
+
description=description,
|
|
315
|
+
validation_rule=validation_rule,
|
|
316
|
+
validation_text=validation_text,
|
|
317
|
+
properties=dict(properties or {}),
|
|
318
|
+
)
|
|
319
|
+
self._session.check_writable(f"create table {spec.name!r}")
|
|
320
|
+
created = ops.create_table(self._session.schema(), spec)
|
|
321
|
+
return Table(self._session, created.name)
|
|
322
|
+
|
|
323
|
+
def drop(self, name: str, *, drop_relationships: bool = False) -> None:
|
|
324
|
+
"""Delete a table."""
|
|
325
|
+
self[name].drop(drop_relationships=drop_relationships)
|
|
326
|
+
|
|
327
|
+
def specs(self) -> list[TableSpec]:
|
|
328
|
+
"""Specs of every (non-system, non-linked) table."""
|
|
329
|
+
return [
|
|
330
|
+
self._session.schema().read_table(info.name)
|
|
331
|
+
for info in self._infos(False)
|
|
332
|
+
if not info.is_linked
|
|
333
|
+
]
|