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