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,162 @@
|
|
|
1
|
+
# pyright: reportUnnecessaryIsInstance=false
|
|
2
|
+
# (dataclass __post_init__ and render_literal validate untyped input at runtime)
|
|
3
|
+
"""Access expression literals: rendering Python values and parsing them back.
|
|
4
|
+
|
|
5
|
+
Access stores properties such as ``DefaultValue`` as *expression text*. A classic mistake is setting a text
|
|
6
|
+
default to ``Unknown`` instead of ``"Unknown"`` (Access then looks for a field or function called Unknown).
|
|
7
|
+
PyAccessKit takes Python values and renders the right literal; raw expressions are passed with
|
|
8
|
+
:class:`Expr`::
|
|
9
|
+
|
|
10
|
+
Column.text("Status", default="Unknown") # stored as "Unknown"
|
|
11
|
+
Column.date_time("CreatedAt", default=Expr("Now()"))
|
|
12
|
+
Column.currency("Total", default=0) # stored as 0
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import date, datetime, time
|
|
21
|
+
from decimal import Decimal, InvalidOperation
|
|
22
|
+
|
|
23
|
+
from pyaccesskit.errors import SpecError
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DefaultValue",
|
|
27
|
+
"Expr",
|
|
28
|
+
"parse_literal",
|
|
29
|
+
"render_literal",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Expr:
|
|
35
|
+
"""A raw Access expression, passed through verbatim (e.g. ``Expr("Now()")``, ``Expr("[Qty]*2")``)."""
|
|
36
|
+
|
|
37
|
+
expr: str
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not isinstance(self.expr, str) or not self.expr.strip():
|
|
41
|
+
raise SpecError("Expr requires a non-empty expression string")
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return self.expr
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DefaultValue = Expr | bool | int | float | Decimal | datetime | date | time | str
|
|
48
|
+
"""Types accepted as a column default: a Python literal or an :class:`Expr`."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_literal(value: DefaultValue) -> str:
|
|
52
|
+
"""Render a Python value as Access expression text.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
SpecError: For values Access cannot represent (NaN, time-zone aware datetimes...).
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(value, Expr):
|
|
58
|
+
return value.expr
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return "True" if value else "False"
|
|
61
|
+
if isinstance(value, int):
|
|
62
|
+
return str(value)
|
|
63
|
+
if isinstance(value, float):
|
|
64
|
+
if not math.isfinite(value):
|
|
65
|
+
raise SpecError(f"cannot store non-finite number {value!r} in Access")
|
|
66
|
+
return repr(value)
|
|
67
|
+
if isinstance(value, Decimal):
|
|
68
|
+
if not value.is_finite():
|
|
69
|
+
raise SpecError(f"cannot store non-finite number {value!r} in Access")
|
|
70
|
+
return format(value, "f")
|
|
71
|
+
if isinstance(value, datetime):
|
|
72
|
+
if value.tzinfo is not None:
|
|
73
|
+
raise SpecError("Access date/times have no time zone; pass a naive datetime")
|
|
74
|
+
if value.microsecond:
|
|
75
|
+
raise SpecError("Access date/time literals have whole-second precision")
|
|
76
|
+
return f"#{value.year:04d}-{value:%m-%d %H:%M:%S}#" # %Y is unpadded on Linux
|
|
77
|
+
if isinstance(value, date):
|
|
78
|
+
return f"#{value.year:04d}-{value:%m-%d}#"
|
|
79
|
+
if isinstance(value, time):
|
|
80
|
+
if value.tzinfo is not None or value.microsecond:
|
|
81
|
+
raise SpecError("Access time literals are naive and have whole-second precision")
|
|
82
|
+
return f"#{value:%H:%M:%S}#"
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
return '"' + value.replace('"', '""') + '"'
|
|
85
|
+
raise SpecError(f"unsupported literal type {type(value).__name__}") # pragma: no cover
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_NUMBER = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$")
|
|
89
|
+
_BOOLEAN_WORDS = {"true": True, "yes": True, "on": True, "false": False, "no": False, "off": False}
|
|
90
|
+
_DATE_FORMATS = (
|
|
91
|
+
"%Y-%m-%d %H:%M:%S",
|
|
92
|
+
"%Y-%m-%d %H:%M",
|
|
93
|
+
"%Y-%m-%d",
|
|
94
|
+
"%m/%d/%Y %H:%M:%S",
|
|
95
|
+
"%m/%d/%Y %I:%M:%S %p",
|
|
96
|
+
"%m/%d/%Y %H:%M",
|
|
97
|
+
"%m/%d/%Y",
|
|
98
|
+
)
|
|
99
|
+
_TIME_FORMATS = ("%H:%M:%S", "%H:%M", "%I:%M:%S %p", "%I:%M %p")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unquote(text: str) -> str | None:
|
|
103
|
+
"""Return the value of a single quoted string literal, or ``None`` if ``text`` is not exactly one."""
|
|
104
|
+
if len(text) < 2 or text[0] not in "\"'" or text[-1] != text[0]:
|
|
105
|
+
return None
|
|
106
|
+
quote = text[0]
|
|
107
|
+
body = text[1:-1]
|
|
108
|
+
# Every quote inside the body must be doubled; otherwise this is an expression like "a" & "b".
|
|
109
|
+
stripped = body.replace(quote * 2, "")
|
|
110
|
+
if quote in stripped:
|
|
111
|
+
return None
|
|
112
|
+
return body.replace(quote * 2, quote)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_date_literal(body: str) -> datetime | date | time | None:
|
|
116
|
+
body = body.strip()
|
|
117
|
+
for fmt in _DATE_FORMATS:
|
|
118
|
+
try:
|
|
119
|
+
parsed = datetime.strptime(body, fmt)
|
|
120
|
+
except ValueError:
|
|
121
|
+
continue
|
|
122
|
+
has_time = "%H" in fmt or "%I" in fmt
|
|
123
|
+
return parsed if has_time else parsed.date()
|
|
124
|
+
for fmt in _TIME_FORMATS:
|
|
125
|
+
try:
|
|
126
|
+
return datetime.strptime(body, fmt).time()
|
|
127
|
+
except ValueError:
|
|
128
|
+
continue
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_literal(text: str | None) -> DefaultValue | None:
|
|
133
|
+
"""Parse Access expression text into a Python literal, or an :class:`Expr` if it is not a literal.
|
|
134
|
+
|
|
135
|
+
Returns ``None`` for empty text. Understands quoted strings, numbers, ``True``/``False``/``Yes``/``No``/
|
|
136
|
+
``On``/``Off`` and ``#date#`` literals (ISO or US ``m/d/yyyy`` order).
|
|
137
|
+
"""
|
|
138
|
+
if text is None:
|
|
139
|
+
return None
|
|
140
|
+
stripped = text.strip()
|
|
141
|
+
if not stripped:
|
|
142
|
+
return None
|
|
143
|
+
unquoted = _unquote(stripped)
|
|
144
|
+
if unquoted is not None:
|
|
145
|
+
return unquoted
|
|
146
|
+
if _NUMBER.match(stripped):
|
|
147
|
+
if re.search(r"[.eE]", stripped) is None:
|
|
148
|
+
return int(stripped)
|
|
149
|
+
if "e" in stripped.lower():
|
|
150
|
+
return float(stripped)
|
|
151
|
+
try:
|
|
152
|
+
return Decimal(stripped)
|
|
153
|
+
except InvalidOperation: # pragma: no cover - guarded by the regex
|
|
154
|
+
return Expr(stripped)
|
|
155
|
+
boolean = _BOOLEAN_WORDS.get(stripped.lower())
|
|
156
|
+
if boolean is not None:
|
|
157
|
+
return boolean
|
|
158
|
+
if len(stripped) >= 2 and stripped[0] == "#" and stripped[-1] == "#":
|
|
159
|
+
parsed = _parse_date_literal(stripped[1:-1])
|
|
160
|
+
if parsed is not None:
|
|
161
|
+
return parsed
|
|
162
|
+
return Expr(stripped)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Index specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any, Self, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from pyaccesskit.schema._base import Items, SpecModel, build
|
|
11
|
+
from pyaccesskit.schema.names import check_name
|
|
12
|
+
|
|
13
|
+
__all__ = ["PRIMARY_KEY_NAME", "IndexField", "IndexSpec"]
|
|
14
|
+
|
|
15
|
+
PRIMARY_KEY_NAME = "PrimaryKey"
|
|
16
|
+
"""The name Access gives primary-key indexes."""
|
|
17
|
+
|
|
18
|
+
MAX_FIELDS_PER_INDEX = 10
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IndexField(SpecModel):
|
|
22
|
+
"""One column of an index, optionally in descending order."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
descending: bool = False
|
|
26
|
+
|
|
27
|
+
@field_validator("name")
|
|
28
|
+
@classmethod
|
|
29
|
+
def _check_name(cls, value: str) -> str:
|
|
30
|
+
return check_name(value, what="index column name")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _coerce_field(value: Any) -> Any:
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
return {"name": value}
|
|
36
|
+
if isinstance(value, tuple):
|
|
37
|
+
pair = cast("tuple[Any, ...]", value)
|
|
38
|
+
if len(pair) == 2:
|
|
39
|
+
return {"name": pair[0], "descending": str(pair[1]).lower() in ("desc", "descending")}
|
|
40
|
+
return pair
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IndexSpec(SpecModel):
|
|
45
|
+
"""An index on one or more columns.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
name: Index name (Access names the primary key ``PrimaryKey``).
|
|
49
|
+
fields: Indexed columns; plain strings are accepted (``["LastName", "FirstName"]``) as are
|
|
50
|
+
``("Col", "desc")`` pairs.
|
|
51
|
+
primary: This is the table's primary key (implies ``unique`` and ``required``).
|
|
52
|
+
unique: No two rows may have the same key.
|
|
53
|
+
required: Null values are not allowed in the indexed columns.
|
|
54
|
+
ignore_nulls: Rows with Null keys are left out of the index.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
fields: Items[IndexField] = Field(min_length=1, max_length=MAX_FIELDS_PER_INDEX)
|
|
59
|
+
primary: bool = False
|
|
60
|
+
unique: bool = False
|
|
61
|
+
required: bool = False
|
|
62
|
+
ignore_nulls: bool = False
|
|
63
|
+
|
|
64
|
+
@field_validator("name")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _check_name(cls, value: str) -> str:
|
|
67
|
+
return check_name(value, what="index name")
|
|
68
|
+
|
|
69
|
+
@field_validator("fields", mode="before")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _coerce_fields(cls, value: Any) -> Any:
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return (_coerce_field(value),)
|
|
74
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
75
|
+
items = cast("Sequence[Any]", value)
|
|
76
|
+
return tuple(_coerce_field(item) for item in items)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
@model_validator(mode="before")
|
|
80
|
+
@classmethod
|
|
81
|
+
def _primary_implies_unique(cls, data: Any) -> Any:
|
|
82
|
+
if isinstance(data, Mapping):
|
|
83
|
+
fields = cast("Mapping[str, Any]", data)
|
|
84
|
+
if fields.get("primary"):
|
|
85
|
+
return {**fields, "unique": True, "required": True}
|
|
86
|
+
return fields
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
@model_validator(mode="after")
|
|
90
|
+
def _check_duplicates(self) -> Self:
|
|
91
|
+
seen: set[str] = set()
|
|
92
|
+
for field in self.fields:
|
|
93
|
+
key = field.name.casefold()
|
|
94
|
+
if key in seen:
|
|
95
|
+
raise ValueError(f"index {self.name!r} lists column {field.name!r} twice")
|
|
96
|
+
seen.add(key)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def field_names(self) -> tuple[str, ...]:
|
|
101
|
+
"""The indexed column names, in order."""
|
|
102
|
+
return tuple(field.name for field in self.fields)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def primary_key(cls, *columns: str, name: str = PRIMARY_KEY_NAME) -> IndexSpec:
|
|
106
|
+
"""A primary-key index on ``columns`` (named ``PrimaryKey`` like Access does)."""
|
|
107
|
+
return build(cls, f"primary key {name!r}", name=name, fields=columns, primary=True)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def on(
|
|
111
|
+
cls, name: str, *columns: str | tuple[str, str], unique: bool = False, **options: bool
|
|
112
|
+
) -> IndexSpec:
|
|
113
|
+
"""Convenience constructor: ``IndexSpec.on("ix_Name", "LastName", "FirstName", unique=True)``."""
|
|
114
|
+
return build(cls, f"index {name!r}", name=name, fields=columns, unique=unique, **options)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Access naming rules, name comparison, identifier quoting and name linting.
|
|
2
|
+
|
|
3
|
+
Hard rules (violations raise :class:`~pyaccesskit.errors.SpecError`) follow Microsoft's *Guidelines for
|
|
4
|
+
naming fields, controls, and objects*: at most 64 characters; no period, exclamation point, accent grave or
|
|
5
|
+
brackets; no leading space; no control characters.
|
|
6
|
+
|
|
7
|
+
Soft rules (violations emit :class:`~pyaccesskit.errors.AccessNameWarning`) flag names that are legal but
|
|
8
|
+
cause trouble later: reserved words, spaces/special characters, names that shadow common form properties.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
from pyaccesskit.errors import AccessNameWarning, SpecError
|
|
17
|
+
from pyaccesskit.schema._reserved_words import RESERVED_WORDS
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MAX_NAME_LENGTH",
|
|
21
|
+
"check_name",
|
|
22
|
+
"lint_name",
|
|
23
|
+
"name_key",
|
|
24
|
+
"names_equal",
|
|
25
|
+
"quote_identifier",
|
|
26
|
+
"warn_name",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
MAX_NAME_LENGTH = 64
|
|
30
|
+
_FORBIDDEN_CHARACTERS = frozenset(".!`[]")
|
|
31
|
+
_PLAIN_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
|
32
|
+
_FORM_PROPERTY_NAMES = frozenset(
|
|
33
|
+
name.casefold()
|
|
34
|
+
for name in (
|
|
35
|
+
"Name",
|
|
36
|
+
"Caption",
|
|
37
|
+
"Text",
|
|
38
|
+
"Value",
|
|
39
|
+
"Section",
|
|
40
|
+
"Tag",
|
|
41
|
+
"Visible",
|
|
42
|
+
"Width",
|
|
43
|
+
"Height",
|
|
44
|
+
"Top",
|
|
45
|
+
"Left",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_name(name: object, *, what: str = "name") -> str:
|
|
51
|
+
"""Validate ``name`` against Access's hard naming rules and return it unchanged.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
name: The candidate name.
|
|
55
|
+
what: What is being named, used in error messages (``"table name"``...).
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
SpecError: If the name is not a legal Access object name.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(name, str):
|
|
61
|
+
raise SpecError(f"{what} must be a string, got {type(name).__name__}")
|
|
62
|
+
problem = _hard_rule_violation(name)
|
|
63
|
+
if problem is not None:
|
|
64
|
+
raise SpecError(f"invalid {what} {name!r}: {problem}")
|
|
65
|
+
return name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _hard_rule_violation(name: str) -> str | None:
|
|
69
|
+
if not name:
|
|
70
|
+
return "names cannot be empty"
|
|
71
|
+
if len(name) > MAX_NAME_LENGTH:
|
|
72
|
+
return f"names are limited to {MAX_NAME_LENGTH} characters (this one has {len(name)})"
|
|
73
|
+
if name[0] == " ":
|
|
74
|
+
return "names cannot start with a space"
|
|
75
|
+
bad = sorted({ch for ch in name if ch in _FORBIDDEN_CHARACTERS})
|
|
76
|
+
if bad:
|
|
77
|
+
return "names cannot contain " + ", ".join(repr(ch) for ch in bad)
|
|
78
|
+
if any(ord(ch) < 32 for ch in name):
|
|
79
|
+
return "names cannot contain control characters"
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def lint_name(name: str, *, what: str = "name") -> list[str]:
|
|
84
|
+
"""Return human-readable warnings for a *legal* name that is likely to cause problems."""
|
|
85
|
+
messages: list[str] = []
|
|
86
|
+
key = name.casefold()
|
|
87
|
+
if key in RESERVED_WORDS:
|
|
88
|
+
messages.append(
|
|
89
|
+
f"{what} {name!r} is an Access reserved word; it must be written as [{name}] in SQL and "
|
|
90
|
+
f"expressions and can clash with built-in properties"
|
|
91
|
+
)
|
|
92
|
+
elif key in _FORM_PROPERTY_NAMES:
|
|
93
|
+
messages.append(f"{what} {name!r} shadows a common form/control property of the same name")
|
|
94
|
+
if name != name.rstrip():
|
|
95
|
+
messages.append(f"{what} {name!r} ends with whitespace")
|
|
96
|
+
elif not _PLAIN_IDENTIFIER.match(name):
|
|
97
|
+
messages.append(
|
|
98
|
+
f"{what} {name!r} contains spaces or special characters; it must be bracketed in SQL and VBA"
|
|
99
|
+
)
|
|
100
|
+
return messages
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def warn_name(name: str, *, what: str = "name", stacklevel: int = 3) -> None:
|
|
104
|
+
"""Emit an :class:`AccessNameWarning` for every soft-rule problem found by :func:`lint_name`."""
|
|
105
|
+
for message in lint_name(name, what=what):
|
|
106
|
+
warnings.warn(message, AccessNameWarning, stacklevel=stacklevel)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def name_key(name: str) -> str:
|
|
110
|
+
"""Key for case-insensitive name comparison (Access object names are case-insensitive)."""
|
|
111
|
+
return name.casefold()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def names_equal(left: str, right: str) -> bool:
|
|
115
|
+
"""Whether two Access names refer to the same object."""
|
|
116
|
+
return left.casefold() == right.casefold()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def quote_identifier(name: str) -> str:
|
|
120
|
+
"""Bracket-quote a name for Access SQL and expressions: ``Order Details`` → ``[Order Details]``."""
|
|
121
|
+
check_name(name)
|
|
122
|
+
return f"[{name}]"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Saved query specifications and Access SQL helpers.
|
|
2
|
+
|
|
3
|
+
Access rewrites SQL when it saves a query (keywords upper-cased, one clause per line, a trailing ``;``,
|
|
4
|
+
``DELETE`` becoming ``DELETE *``...). :func:`sql_equivalent` compares SQL modulo those cosmetic changes, which
|
|
5
|
+
is what change detection needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
from pydantic import Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
from pyaccesskit.enums import QueryKind
|
|
16
|
+
from pyaccesskit.schema._base import SpecModel
|
|
17
|
+
from pyaccesskit.schema.names import check_name
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DAO_QUERY_KINDS",
|
|
21
|
+
"MAX_SQL_LENGTH",
|
|
22
|
+
"PassThroughOptions",
|
|
23
|
+
"QuerySpec",
|
|
24
|
+
"detect_query_kind",
|
|
25
|
+
"normalize_sql",
|
|
26
|
+
"sql_equivalent",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
MAX_SQL_LENGTH = 64_000
|
|
30
|
+
"""Approximate Access limit on the length of a SQL statement."""
|
|
31
|
+
|
|
32
|
+
DAO_QUERY_KINDS: dict[int, QueryKind] = {
|
|
33
|
+
0: QueryKind.SELECT,
|
|
34
|
+
16: QueryKind.CROSSTAB,
|
|
35
|
+
32: QueryKind.DELETE,
|
|
36
|
+
48: QueryKind.UPDATE,
|
|
37
|
+
64: QueryKind.APPEND,
|
|
38
|
+
80: QueryKind.MAKE_TABLE,
|
|
39
|
+
96: QueryKind.DDL,
|
|
40
|
+
112: QueryKind.PASS_THROUGH,
|
|
41
|
+
128: QueryKind.UNION,
|
|
42
|
+
144: QueryKind.PASS_THROUGH_BULK,
|
|
43
|
+
160: QueryKind.COMPOUND,
|
|
44
|
+
224: QueryKind.PROCEDURE,
|
|
45
|
+
240: QueryKind.ACTION,
|
|
46
|
+
}
|
|
47
|
+
"""DAO ``QueryDefTypeEnum`` values (verified against the ACEDAO type library) → :class:`QueryKind`."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PassThroughOptions(SpecModel):
|
|
51
|
+
"""Settings of an ODBC pass-through query.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
connect: ODBC connection string; must start with ``ODBC;``.
|
|
55
|
+
returns_records: Whether the statement returns rows.
|
|
56
|
+
timeout: ODBC timeout in seconds (0 = no timeout).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
connect: str
|
|
60
|
+
returns_records: bool = True
|
|
61
|
+
timeout: int = Field(default=60, ge=0)
|
|
62
|
+
|
|
63
|
+
@field_validator("connect")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _check_connect(cls, value: str) -> str:
|
|
66
|
+
if not value.upper().startswith("ODBC;"):
|
|
67
|
+
raise ValueError("pass-through connect strings must start with 'ODBC;'")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class QuerySpec(SpecModel):
|
|
72
|
+
"""A saved query (Access QueryDef).
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
name: Query name (tables and queries share one namespace).
|
|
76
|
+
sql: The SQL text (Access SQL, or the server's dialect for pass-through queries).
|
|
77
|
+
description: Query *Description* property.
|
|
78
|
+
pass_through: Set for ODBC pass-through queries.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
sql: str = Field(min_length=1, max_length=MAX_SQL_LENGTH)
|
|
83
|
+
description: str | None = None
|
|
84
|
+
pass_through: PassThroughOptions | None = None
|
|
85
|
+
|
|
86
|
+
@field_validator("name")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _check_name(cls, value: str) -> str:
|
|
89
|
+
return check_name(value, what="query name")
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _check_sql(self) -> Self:
|
|
93
|
+
if not self.sql.strip():
|
|
94
|
+
raise ValueError("sql cannot be blank")
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def kind(self) -> QueryKind:
|
|
99
|
+
"""The query kind as implied by the SQL text (Access reports the authoritative kind after saving)."""
|
|
100
|
+
return detect_query_kind(self.sql, pass_through=self.pass_through is not None)
|
|
101
|
+
|
|
102
|
+
def normalized(self) -> QuerySpec:
|
|
103
|
+
"""Canonical form (SQL line endings normalized to CRLF, as Access stores them)."""
|
|
104
|
+
sql = self.sql.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
|
|
105
|
+
return self if sql == self.sql else self.model_copy(update={"sql": sql})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -------------------------------------------------------------------------------------- SQL helpers
|
|
109
|
+
_TOKEN = re.compile(
|
|
110
|
+
r"""
|
|
111
|
+
(?P<string>'(?:[^']|'')*'|"(?:[^"]|"")*")
|
|
112
|
+
| (?P<bracket>\[[^\]]*\])
|
|
113
|
+
| (?P<date>\#[^#\r\n]*\#)
|
|
114
|
+
| (?P<space>\s+)
|
|
115
|
+
| (?P<punct>[(),;=<>+\-*/&^\\])
|
|
116
|
+
| (?P<word>[^\s'"\[\#(),;=<>+\-*/&^\\]+)
|
|
117
|
+
""",
|
|
118
|
+
re.VERBOSE,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _tokens(sql: str) -> list[tuple[str, str]]:
|
|
123
|
+
tokens: list[tuple[str, str]] = []
|
|
124
|
+
for match in _TOKEN.finditer(sql):
|
|
125
|
+
kind = match.lastgroup or "word"
|
|
126
|
+
if kind == "space":
|
|
127
|
+
continue
|
|
128
|
+
tokens.append((kind, match.group()))
|
|
129
|
+
return tokens
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def normalize_sql(sql: str) -> str:
|
|
133
|
+
"""A canonical single-line form of ``sql`` for comparisons.
|
|
134
|
+
|
|
135
|
+
Whitespace is collapsed and dropped around punctuation, words and bracketed identifiers are
|
|
136
|
+
upper-cased (Access identifiers are case-insensitive), string and date literals are preserved, trailing
|
|
137
|
+
semicolons are removed, and Access's ``DELETE * FROM`` rewrite is undone.
|
|
138
|
+
"""
|
|
139
|
+
parts: list[str] = []
|
|
140
|
+
for kind, text in _tokens(sql):
|
|
141
|
+
parts.append(text if kind in ("string", "date") else text.upper())
|
|
142
|
+
while parts and parts[-1] == ";":
|
|
143
|
+
parts.pop()
|
|
144
|
+
if len(parts) >= 3 and parts[0] == "DELETE" and parts[1] == "*" and parts[2] == "FROM":
|
|
145
|
+
del parts[1]
|
|
146
|
+
return " ".join(parts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def sql_equivalent(left: str, right: str) -> bool:
|
|
150
|
+
"""Whether two SQL texts differ only cosmetically (see :func:`normalize_sql`)."""
|
|
151
|
+
return normalize_sql(left) == normalize_sql(right)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def detect_query_kind(sql: str, *, pass_through: bool = False) -> QueryKind:
|
|
155
|
+
"""Infer the kind of query from its SQL text (used before Access has classified a saved query)."""
|
|
156
|
+
if pass_through:
|
|
157
|
+
return QueryKind.PASS_THROUGH
|
|
158
|
+
words = [text.upper() if kind == "word" else text for kind, text in _tokens(sql)]
|
|
159
|
+
if words and words[0] == "PARAMETERS":
|
|
160
|
+
words = words[words.index(";") + 1 :] if ";" in words else []
|
|
161
|
+
if not words:
|
|
162
|
+
return QueryKind.UNKNOWN
|
|
163
|
+
first = words[0].lstrip("(")
|
|
164
|
+
simple = {
|
|
165
|
+
"TRANSFORM": QueryKind.CROSSTAB,
|
|
166
|
+
"INSERT": QueryKind.APPEND,
|
|
167
|
+
"UPDATE": QueryKind.UPDATE,
|
|
168
|
+
"DELETE": QueryKind.DELETE,
|
|
169
|
+
"CREATE": QueryKind.DDL,
|
|
170
|
+
"ALTER": QueryKind.DDL,
|
|
171
|
+
"DROP": QueryKind.DDL,
|
|
172
|
+
"PROCEDURE": QueryKind.PROCEDURE,
|
|
173
|
+
}
|
|
174
|
+
if first in simple:
|
|
175
|
+
return simple[first]
|
|
176
|
+
if first not in ("SELECT", "("):
|
|
177
|
+
return QueryKind.UNKNOWN
|
|
178
|
+
depth = 0
|
|
179
|
+
seen_from = False
|
|
180
|
+
for word in words:
|
|
181
|
+
if word == "(":
|
|
182
|
+
depth += 1
|
|
183
|
+
elif word == ")":
|
|
184
|
+
depth -= 1
|
|
185
|
+
elif depth == 0:
|
|
186
|
+
if word == "UNION":
|
|
187
|
+
return QueryKind.UNION
|
|
188
|
+
if word == "FROM":
|
|
189
|
+
seen_from = True
|
|
190
|
+
elif word == "INTO" and not seen_from:
|
|
191
|
+
return QueryKind.MAKE_TABLE
|
|
192
|
+
return QueryKind.SELECT
|