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,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
+ ]