pyquickbase 0.1.0a1__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.
- pyquickbase/__init__.py +73 -0
- pyquickbase/_async/__init__.py +0 -0
- pyquickbase/_async/_client.py +151 -0
- pyquickbase/_async/_resources.py +511 -0
- pyquickbase/_async/_transport.py +74 -0
- pyquickbase/_auth.py +19 -0
- pyquickbase/_config.py +93 -0
- pyquickbase/_models.py +241 -0
- pyquickbase/_query.py +315 -0
- pyquickbase/_response.py +90 -0
- pyquickbase/_retry.py +66 -0
- pyquickbase/_schema.py +35 -0
- pyquickbase/_sync/__init__.py +0 -0
- pyquickbase/_sync/_client.py +152 -0
- pyquickbase/_sync/_resources.py +506 -0
- pyquickbase/_sync/_transport.py +75 -0
- pyquickbase/_version.py +1 -0
- pyquickbase/_writes.py +104 -0
- pyquickbase/errors.py +110 -0
- pyquickbase/py.typed +0 -0
- pyquickbase-0.1.0a1.dist-info/METADATA +223 -0
- pyquickbase-0.1.0a1.dist-info/RECORD +24 -0
- pyquickbase-0.1.0a1.dist-info/WHEEL +4 -0
- pyquickbase-0.1.0a1.dist-info/licenses/LICENSE +21 -0
pyquickbase/_config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Validation and normalization of client settings. No network access."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Collection
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .errors import ConfigurationError, ReadOnlyError
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.quickbase.com/v1"
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
ENV_REALM = "QB_REALM"
|
|
16
|
+
ENV_TOKEN = "QB_USER_TOKEN"
|
|
17
|
+
|
|
18
|
+
_HOSTNAME = re.compile(r"[a-z0-9-]+(\.[a-z0-9-]+)+")
|
|
19
|
+
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
20
|
+
_READ_ONLY_POSTS = {"/records/query"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_realm(realm: str) -> str:
|
|
24
|
+
"""Return the full realm hostname from `acme`, a hostname, or a realm URL."""
|
|
25
|
+
host = re.sub(r"^https?://", "", realm.strip().lower()).split("/", 1)[0]
|
|
26
|
+
if host and "." not in host:
|
|
27
|
+
host = f"{host}.quickbase.com"
|
|
28
|
+
if not _HOSTNAME.fullmatch(host):
|
|
29
|
+
raise ConfigurationError(f"Invalid realm {realm!r}. Expected e.g. 'acme.quickbase.com'.")
|
|
30
|
+
return host
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_token(token: str) -> str:
|
|
34
|
+
"""Reject values that can't be a token. The token is never included in the error."""
|
|
35
|
+
token = token.strip()
|
|
36
|
+
if not token or any(c.isspace() or not c.isprintable() for c in token):
|
|
37
|
+
raise ConfigurationError("The token is empty or contains whitespace or control characters.")
|
|
38
|
+
return token
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_base_url(base_url: str) -> str:
|
|
42
|
+
"""Require HTTPS so a token can't be sent in clear text, except to the local machine."""
|
|
43
|
+
parts = urlsplit(base_url)
|
|
44
|
+
if parts.scheme != "https" and not (
|
|
45
|
+
parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
|
|
46
|
+
):
|
|
47
|
+
raise ConfigurationError(f"base_url must use https, got {base_url!r}.")
|
|
48
|
+
return base_url.rstrip("/")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_url(base_url: str, path: str) -> str:
|
|
52
|
+
"""Join a path to the base URL, refusing anything that could send the token to another host."""
|
|
53
|
+
if not path.startswith("/") or path.startswith("//") or any(c in path for c in "?#\\"):
|
|
54
|
+
raise ValueError(f"path must look like '/tables/abc', without a query string: {path!r}")
|
|
55
|
+
return f"{base_url}{path}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_allowed_writes(
|
|
59
|
+
read_only: bool, allowed_writes: Collection[tuple[str, str]] | None
|
|
60
|
+
) -> frozenset[tuple[str, str]] | None:
|
|
61
|
+
"""Return the writes a client may send, or None if it may send anything."""
|
|
62
|
+
if read_only and allowed_writes is not None:
|
|
63
|
+
raise ConfigurationError("Use read_only or allowed_writes, not both.")
|
|
64
|
+
if allowed_writes is not None:
|
|
65
|
+
return frozenset((method.upper(), path) for method, path in allowed_writes)
|
|
66
|
+
return frozenset() if read_only else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_allowed(method: str, path: str, allowed_writes: frozenset[tuple[str, str]]) -> None:
|
|
70
|
+
"""Allow only known reads and listed writes. Queries are POSTs, so method alone isn't enough."""
|
|
71
|
+
if method == "GET" or (method == "POST" and path in _READ_ONLY_POSTS):
|
|
72
|
+
return
|
|
73
|
+
if (method, path) not in allowed_writes:
|
|
74
|
+
limit = "limited to reads and specific writes" if allowed_writes else "read-only"
|
|
75
|
+
raise ReadOnlyError(f"This client is {limit}, so it won't send {method} {path}.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_user_agent(prefix: str | None) -> str:
|
|
79
|
+
agent = (
|
|
80
|
+
f"pyquickbase/{__version__} python/{platform.python_version()} httpx/{httpx.__version__}"
|
|
81
|
+
)
|
|
82
|
+
return f"{prefix} {agent}" if prefix else agent
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_env(environ: dict[str, str] | None = None) -> tuple[str, str]:
|
|
86
|
+
"""Read the realm and token from the environment, naming any that are missing."""
|
|
87
|
+
import os
|
|
88
|
+
|
|
89
|
+
env = os.environ if environ is None else environ
|
|
90
|
+
missing = [name for name in (ENV_REALM, ENV_TOKEN) if not env.get(name, "").strip()]
|
|
91
|
+
if missing:
|
|
92
|
+
raise ConfigurationError(f"Missing environment variables: {', '.join(missing)}")
|
|
93
|
+
return env[ENV_REALM], env[ENV_TOKEN]
|
pyquickbase/_models.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Typed views of Quickbase metadata and records. No network access."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from .errors import InvalidResponseError, UnknownFieldError
|
|
10
|
+
|
|
11
|
+
RECORD_ID_FIELD = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse(parser: Callable[[Any], T], payload: Any, what: str) -> T:
|
|
18
|
+
"""Run a parser, reporting a response of the wrong shape as a Quickbase-level error."""
|
|
19
|
+
try:
|
|
20
|
+
return parser(payload)
|
|
21
|
+
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
|
22
|
+
raise InvalidResponseError(f"Quickbase returned an unexpected {what} response.") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
26
|
+
class AppInfo:
|
|
27
|
+
"""An app's metadata. `raw` holds everything Quickbase returned."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
time_zone: str | None
|
|
33
|
+
date_format: str | None
|
|
34
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_api(cls, data: Mapping[str, Any]) -> "AppInfo":
|
|
38
|
+
return cls(
|
|
39
|
+
id=data["id"],
|
|
40
|
+
name=data["name"],
|
|
41
|
+
description=data.get("description") or "",
|
|
42
|
+
time_zone=data.get("timeZone"),
|
|
43
|
+
date_format=data.get("dateFormat"),
|
|
44
|
+
raw=data,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
49
|
+
class TableInfo:
|
|
50
|
+
"""A table's metadata. `raw` holds everything Quickbase returned."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
key_field_id: int | None
|
|
56
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_api(cls, data: Mapping[str, Any]) -> "TableInfo":
|
|
60
|
+
return cls(
|
|
61
|
+
id=data["id"],
|
|
62
|
+
name=data["name"],
|
|
63
|
+
description=data.get("description") or "",
|
|
64
|
+
key_field_id=data.get("keyFieldId"),
|
|
65
|
+
raw=data,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
70
|
+
class Field:
|
|
71
|
+
"""A field's metadata. `mode` is `formula`, `lookup`, `summary`, or None for plain fields."""
|
|
72
|
+
|
|
73
|
+
id: int
|
|
74
|
+
label: str
|
|
75
|
+
type: str
|
|
76
|
+
mode: str | None
|
|
77
|
+
required: bool
|
|
78
|
+
unique: bool
|
|
79
|
+
appears_by_default: bool
|
|
80
|
+
choices: tuple[str, ...]
|
|
81
|
+
allow_new_choices: bool
|
|
82
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_api(cls, data: Mapping[str, Any]) -> "Field":
|
|
86
|
+
properties: Mapping[str, Any] = data.get("properties") or {}
|
|
87
|
+
return cls(
|
|
88
|
+
id=data["id"],
|
|
89
|
+
label=data["label"],
|
|
90
|
+
type=data["fieldType"],
|
|
91
|
+
mode=data.get("mode") or None,
|
|
92
|
+
required=bool(data.get("required")),
|
|
93
|
+
unique=bool(data.get("unique")),
|
|
94
|
+
appears_by_default=bool(data.get("appearsByDefault")),
|
|
95
|
+
choices=tuple(properties.get("choices") or ()),
|
|
96
|
+
allow_new_choices=bool(properties.get("allowNewChoices")),
|
|
97
|
+
raw=data,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
102
|
+
class Relationship:
|
|
103
|
+
"""A parent-child link between two tables."""
|
|
104
|
+
|
|
105
|
+
id: int
|
|
106
|
+
parent_table_id: str
|
|
107
|
+
child_table_id: str
|
|
108
|
+
foreign_key_field_id: int | None
|
|
109
|
+
lookup_field_ids: tuple[int, ...]
|
|
110
|
+
is_cross_app: bool
|
|
111
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_api(cls, data: Mapping[str, Any]) -> "Relationship":
|
|
115
|
+
foreign_key: Mapping[str, Any] = data.get("foreignKeyField") or {}
|
|
116
|
+
return cls(
|
|
117
|
+
id=data["id"],
|
|
118
|
+
parent_table_id=data["parentTableId"],
|
|
119
|
+
child_table_id=data["childTableId"],
|
|
120
|
+
foreign_key_field_id=foreign_key.get("id"),
|
|
121
|
+
lookup_field_ids=tuple(item["id"] for item in data.get("lookupFields") or ()),
|
|
122
|
+
is_cross_app=bool(data.get("isCrossApp")),
|
|
123
|
+
raw=data,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FieldIndex:
|
|
128
|
+
"""Find a table's fields by label or ID."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, fields: Iterable[Field]) -> None:
|
|
131
|
+
self._by_id = {item.id: item for item in fields}
|
|
132
|
+
self._by_label: dict[str, list[Field]] = {}
|
|
133
|
+
for item in self._by_id.values():
|
|
134
|
+
self._by_label.setdefault(item.label, []).append(item)
|
|
135
|
+
|
|
136
|
+
def has_label(self, label: str) -> bool:
|
|
137
|
+
return label in self._by_label
|
|
138
|
+
|
|
139
|
+
def resolve(self, ref: str | int) -> Field:
|
|
140
|
+
"""Return the field for a label (`str`) or ID (`int`)."""
|
|
141
|
+
if isinstance(ref, str):
|
|
142
|
+
return self._resolve_label(ref)
|
|
143
|
+
found = None if isinstance(ref, bool) else self._by_id.get(ref)
|
|
144
|
+
if found is None:
|
|
145
|
+
raise UnknownFieldError(f"This table has no field with ID {ref!r}.")
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
def _resolve_label(self, label: str) -> Field:
|
|
149
|
+
matches = self._by_label.get(label, [])
|
|
150
|
+
if len(matches) == 1:
|
|
151
|
+
return matches[0]
|
|
152
|
+
if matches:
|
|
153
|
+
ids = sorted(item.id for item in matches)
|
|
154
|
+
raise UnknownFieldError(f"Several fields are labeled {label!r}. Use an ID: {ids}.")
|
|
155
|
+
close = difflib.get_close_matches(label, self._by_label, n=3)
|
|
156
|
+
hint = f" Did you mean {' or '.join(repr(name) for name in close)}?" if close else ""
|
|
157
|
+
raise UnknownFieldError(f"This table has no field labeled {label!r}.{hint}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _convert(value: Any, field_type: str) -> Any:
|
|
161
|
+
"""Turn dates and timestamps into Python objects, leaving anything unexpected unchanged."""
|
|
162
|
+
if value == "" and field_type in ("date", "timestamp"):
|
|
163
|
+
return None # how Quickbase returns an empty date
|
|
164
|
+
if isinstance(value, str):
|
|
165
|
+
try:
|
|
166
|
+
if field_type == "date":
|
|
167
|
+
return date.fromisoformat(value)
|
|
168
|
+
if field_type == "timestamp":
|
|
169
|
+
return datetime.fromisoformat(value)
|
|
170
|
+
except ValueError:
|
|
171
|
+
return value
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Record(Mapping[str | int, Any]):
|
|
176
|
+
"""One record. Read values by field label or ID: `record["Status"]` or `record[7]`.
|
|
177
|
+
|
|
178
|
+
Iterating yields labels, so `dict(record)` gives `{label: value}`. Date fields are `date`
|
|
179
|
+
objects and timestamp fields are timezone-aware `datetime` objects, or None when empty.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
__slots__ = ("_labels", "_values")
|
|
183
|
+
|
|
184
|
+
def __init__(self, values: Mapping[int, Any], labels: Mapping[str, int]) -> None:
|
|
185
|
+
self._values = values
|
|
186
|
+
self._labels = labels
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def id(self) -> int:
|
|
190
|
+
"""The Record ID#, which queries always select."""
|
|
191
|
+
return self._values[RECORD_ID_FIELD]
|
|
192
|
+
|
|
193
|
+
def __getitem__(self, key: str | int) -> Any:
|
|
194
|
+
field_id = self._labels[key] if isinstance(key, str) else key
|
|
195
|
+
return self._values[field_id]
|
|
196
|
+
|
|
197
|
+
def __iter__(self) -> Iterator[str | int]:
|
|
198
|
+
return iter(label for label, field_id in self._labels.items() if field_id in self._values)
|
|
199
|
+
|
|
200
|
+
def __len__(self) -> int:
|
|
201
|
+
return sum(1 for _ in self)
|
|
202
|
+
|
|
203
|
+
def __repr__(self) -> str:
|
|
204
|
+
# Values may be sensitive and reprs end up in logs, so show only the shape.
|
|
205
|
+
return f"Record(id={self._values.get(RECORD_ID_FIELD)!r}, fields={len(self)})"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
209
|
+
class Page:
|
|
210
|
+
"""One page of query results. `total` counts every match, not just this page."""
|
|
211
|
+
|
|
212
|
+
records: tuple[Record, ...]
|
|
213
|
+
total: int
|
|
214
|
+
skip: int
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def has_more(self) -> bool:
|
|
218
|
+
return self.next_skip < self.total
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def next_skip(self) -> int:
|
|
222
|
+
"""The `skip` that fetches the following page."""
|
|
223
|
+
return self.skip + len(self.records)
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_api(cls, data: Mapping[str, Any]) -> "Page":
|
|
227
|
+
fields = data["fields"]
|
|
228
|
+
labels: dict[str, int] = {item["label"]: item["id"] for item in fields}
|
|
229
|
+
types: dict[int, str] = {item["id"]: item["type"] for item in fields}
|
|
230
|
+
records = tuple(
|
|
231
|
+
Record(
|
|
232
|
+
{
|
|
233
|
+
int(field_id): _convert(cell["value"], types.get(int(field_id), ""))
|
|
234
|
+
for field_id, cell in row.items()
|
|
235
|
+
},
|
|
236
|
+
labels,
|
|
237
|
+
)
|
|
238
|
+
for row in data["data"]
|
|
239
|
+
)
|
|
240
|
+
metadata = data["metadata"]
|
|
241
|
+
return cls(records=records, total=metadata["totalRecords"], skip=metadata["skip"])
|
pyquickbase/_query.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Build and check `where` clauses and query bodies. No network access.
|
|
2
|
+
|
|
3
|
+
Quickbase accepts many broken queries and answers with plausible results: a lowercase or unknown
|
|
4
|
+
operator acts like "contains", and an unknown field matches nothing. Everything here exists so
|
|
5
|
+
that those mistakes raise an error instead. See docs/api-notes.md for the tested behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any, ClassVar
|
|
14
|
+
|
|
15
|
+
from ._models import RECORD_ID_FIELD, Field, FieldIndex
|
|
16
|
+
from .errors import QueryError, UnknownFieldError
|
|
17
|
+
|
|
18
|
+
FieldRef = str | int
|
|
19
|
+
OPERATORS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"EX", "XEX", "CT", "XCT", "SW", "XSW", "LT", "LTE", "GT", "GTE",
|
|
22
|
+
"BF", "OBF", "AF", "OAF", "IR", "XIR", "HAS", "XHAS", "TV", "XTV",
|
|
23
|
+
}
|
|
24
|
+
) # fmt: skip
|
|
25
|
+
_CONDITION = re.compile(
|
|
26
|
+
r"\{\s*(\d+)\s*\.\s*([A-Za-z]+)\s*\.\s*('(?:[^'{}\\]|\\[^{}])*'|[^'{}\\]*?)\s*\}"
|
|
27
|
+
)
|
|
28
|
+
_JOIN = re.compile(r"(?:AND|OR)\b")
|
|
29
|
+
_ESCAPE = re.compile(r"\\(.)")
|
|
30
|
+
_WHERE_HELP = (
|
|
31
|
+
"Each condition must be {fieldId.OPERATOR.'value'} in its own braces, joined outside the "
|
|
32
|
+
"braces with AND or OR in uppercase. Write an apostrophe in a value as \\'."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_value(value: Any) -> str:
|
|
37
|
+
"""Render a Python value for use between the quotes of a condition.
|
|
38
|
+
|
|
39
|
+
Backslashes and quotes are escaped. Braces are rejected because Quickbase can't search for
|
|
40
|
+
them, and because every way we've found to break out of a value relies on one.
|
|
41
|
+
"""
|
|
42
|
+
if value is None:
|
|
43
|
+
return ""
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
return "true" if value else "false"
|
|
46
|
+
if isinstance(value, int | float | Decimal):
|
|
47
|
+
return str(value)
|
|
48
|
+
if isinstance(value, datetime):
|
|
49
|
+
if value.tzinfo is None:
|
|
50
|
+
raise QueryError("A datetime in a query needs a timezone, so its meaning is clear.")
|
|
51
|
+
return str(int(value.timestamp() * 1000))
|
|
52
|
+
if isinstance(value, date):
|
|
53
|
+
return value.isoformat()
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
if "{" in value or "}" in value:
|
|
56
|
+
raise QueryError("Quickbase can't search for values containing { or }.")
|
|
57
|
+
return value.replace("\\", "\\\\").replace("'", "\\'")
|
|
58
|
+
raise QueryError(f"Can't use a value of type {type(value).__name__} in a query.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Expression:
|
|
62
|
+
"""A query condition. Combine with `&` and `|`, not `and` and `or`."""
|
|
63
|
+
|
|
64
|
+
__slots__ = ()
|
|
65
|
+
|
|
66
|
+
def __and__(self, other: "Expression") -> "Group":
|
|
67
|
+
return Group.of("AND", self, other)
|
|
68
|
+
|
|
69
|
+
def __or__(self, other: "Expression") -> "Group":
|
|
70
|
+
return Group.of("OR", self, other)
|
|
71
|
+
|
|
72
|
+
def __bool__(self) -> bool:
|
|
73
|
+
# `a and b` or `1 < F("x") < 5` would otherwise silently drop a condition.
|
|
74
|
+
raise QueryError(
|
|
75
|
+
"Combine conditions with & and |, not `and`, `or`, or chained comparisons."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def compile(self, fields: FieldIndex) -> str:
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Condition(Expression):
|
|
84
|
+
field: FieldRef
|
|
85
|
+
operator: str
|
|
86
|
+
value: str
|
|
87
|
+
|
|
88
|
+
def compile(self, fields: FieldIndex) -> str:
|
|
89
|
+
return f"{{{fields.resolve(self.field).id}.{self.operator}.'{self.value}'}}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Group(Expression):
|
|
94
|
+
joiner: str
|
|
95
|
+
parts: tuple[Expression, ...]
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def of(cls, joiner: str, *parts: Expression) -> "Group":
|
|
99
|
+
# Callers without a type checker can pass anything here.
|
|
100
|
+
if not all(isinstance(part, Expression) for part in parts): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
101
|
+
raise QueryError("Conditions can only be combined with other conditions.")
|
|
102
|
+
flat: list[Expression] = []
|
|
103
|
+
for part in parts:
|
|
104
|
+
if isinstance(part, Group) and part.joiner == joiner:
|
|
105
|
+
flat.extend(part.parts)
|
|
106
|
+
else:
|
|
107
|
+
flat.append(part)
|
|
108
|
+
return cls(joiner, tuple(flat))
|
|
109
|
+
|
|
110
|
+
def compile(self, fields: FieldIndex) -> str:
|
|
111
|
+
return self.joiner.join(
|
|
112
|
+
f"({part.compile(fields)})" if isinstance(part, Group) else part.compile(fields)
|
|
113
|
+
for part in self.parts
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class F:
|
|
118
|
+
"""A field in a query, named by label (`str`) or ID (`int`).
|
|
119
|
+
|
|
120
|
+
`(F("Status") == "Done") & (F("Due Date") < date.today())`. Values are escaped for you, so
|
|
121
|
+
this is the safe way to use text you don't control. `== None` matches empty fields.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
__slots__ = ("_field",)
|
|
125
|
+
__hash__: ClassVar[None] = None # pyright: ignore[reportIncompatibleMethodOverride]
|
|
126
|
+
|
|
127
|
+
def __init__(self, field: FieldRef) -> None:
|
|
128
|
+
if isinstance(field, bool) or not isinstance(field, str | int): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
129
|
+
raise QueryError("F() takes a field label (str) or ID (int).")
|
|
130
|
+
self._field = field
|
|
131
|
+
|
|
132
|
+
def _condition(self, operator: str, value: Any) -> Condition:
|
|
133
|
+
return Condition(self._field, operator, format_value(value))
|
|
134
|
+
|
|
135
|
+
def _compare(self, operator: str, date_operator: str, value: Any) -> Condition:
|
|
136
|
+
return self._condition(date_operator if isinstance(value, date) else operator, value)
|
|
137
|
+
|
|
138
|
+
def __eq__(self, value: object) -> Condition: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
139
|
+
return self._condition("EX", value)
|
|
140
|
+
|
|
141
|
+
def __ne__(self, value: object) -> Condition: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
142
|
+
return self._condition("XEX", value)
|
|
143
|
+
|
|
144
|
+
def __lt__(self, value: Any) -> Condition:
|
|
145
|
+
return self._compare("LT", "BF", value)
|
|
146
|
+
|
|
147
|
+
def __le__(self, value: Any) -> Condition:
|
|
148
|
+
return self._compare("LTE", "OBF", value)
|
|
149
|
+
|
|
150
|
+
def __gt__(self, value: Any) -> Condition:
|
|
151
|
+
return self._compare("GT", "AF", value)
|
|
152
|
+
|
|
153
|
+
def __ge__(self, value: Any) -> Condition:
|
|
154
|
+
return self._compare("GTE", "OAF", value)
|
|
155
|
+
|
|
156
|
+
def contains(self, value: Any) -> Condition:
|
|
157
|
+
return self._condition("CT", value)
|
|
158
|
+
|
|
159
|
+
def not_contains(self, value: Any) -> Condition:
|
|
160
|
+
return self._condition("XCT", value)
|
|
161
|
+
|
|
162
|
+
def starts_with(self, value: Any) -> Condition:
|
|
163
|
+
return self._condition("SW", value)
|
|
164
|
+
|
|
165
|
+
def not_starts_with(self, value: Any) -> Condition:
|
|
166
|
+
return self._condition("XSW", value)
|
|
167
|
+
|
|
168
|
+
def is_in(self, values: Iterable[Any]) -> Expression:
|
|
169
|
+
"""Match any of the values, as a series of `EX` conditions joined with `OR`."""
|
|
170
|
+
conditions = [self._condition("EX", value) for value in values]
|
|
171
|
+
if not conditions:
|
|
172
|
+
raise QueryError("is_in() needs at least one value.")
|
|
173
|
+
return conditions[0] if len(conditions) == 1 else Group.of("OR", *conditions)
|
|
174
|
+
|
|
175
|
+
def op(self, operator: str, value: Any) -> Condition:
|
|
176
|
+
"""Use any Quickbase operator directly, such as `HAS` or `IR`."""
|
|
177
|
+
if operator not in OPERATORS:
|
|
178
|
+
raise QueryError(f"Unknown operator {operator!r}. Operators must be uppercase.")
|
|
179
|
+
return self._condition(operator, value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _where_error(where: str, position: int, problem: str) -> QueryError:
|
|
183
|
+
return QueryError(
|
|
184
|
+
f"Invalid where clause: {problem} near `{where[position : position + 40]}`. {_WHERE_HELP}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True, slots=True)
|
|
189
|
+
class WhereCondition:
|
|
190
|
+
"""One condition from a `where` string, with its value unquoted and unescaped."""
|
|
191
|
+
|
|
192
|
+
field_id: int
|
|
193
|
+
operator: str
|
|
194
|
+
value: str
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _unquote(raw: str) -> str:
|
|
198
|
+
return _ESCAPE.sub(r"\1", raw[1:-1]) if raw.startswith("'") else raw
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _scan_where(text: str, fields: FieldIndex) -> list[WhereCondition]:
|
|
202
|
+
found: list[WhereCondition] = []
|
|
203
|
+
position, depth, expect_condition = 0, 0, True
|
|
204
|
+
while position < len(text):
|
|
205
|
+
if text[position].isspace():
|
|
206
|
+
position += 1
|
|
207
|
+
elif expect_condition and text[position] == "(":
|
|
208
|
+
position, depth = position + 1, depth + 1
|
|
209
|
+
elif expect_condition:
|
|
210
|
+
match = _CONDITION.match(text, position)
|
|
211
|
+
if match is None:
|
|
212
|
+
raise _where_error(text, position, "expected a condition")
|
|
213
|
+
try:
|
|
214
|
+
fields.resolve(int(match[1]))
|
|
215
|
+
except UnknownFieldError:
|
|
216
|
+
raise _where_error(text, position, f"field {match[1]} doesn't exist") from None
|
|
217
|
+
if match[2] not in OPERATORS:
|
|
218
|
+
raise _where_error(
|
|
219
|
+
text, position, f"unknown operator '{match[2]}', operators must be uppercase"
|
|
220
|
+
)
|
|
221
|
+
found.append(WhereCondition(int(match[1]), match[2], _unquote(match[3])))
|
|
222
|
+
position, expect_condition = match.end(), False
|
|
223
|
+
elif text[position] == ")":
|
|
224
|
+
if depth == 0:
|
|
225
|
+
raise _where_error(text, position, "unbalanced ')'")
|
|
226
|
+
position, depth = position + 1, depth - 1
|
|
227
|
+
else:
|
|
228
|
+
match = _JOIN.match(text, position)
|
|
229
|
+
if match is None:
|
|
230
|
+
raise _where_error(text, position, "expected AND or OR between conditions")
|
|
231
|
+
position, expect_condition = match.end(), True
|
|
232
|
+
if expect_condition:
|
|
233
|
+
raise _where_error(text, max(0, len(text) - 20), "clause is empty or ends with AND/OR")
|
|
234
|
+
if depth:
|
|
235
|
+
raise _where_error(text, 0, "unbalanced '('")
|
|
236
|
+
return found
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def validate_where(where: str, fields: FieldIndex) -> str:
|
|
240
|
+
"""Check a hand-written `where` string and return it with outer whitespace removed.
|
|
241
|
+
|
|
242
|
+
This catches mistakes, not attacks: a string can legitimately contain several conditions, so
|
|
243
|
+
build queries with `F` when any of the text comes from someone else.
|
|
244
|
+
"""
|
|
245
|
+
text = where.strip()
|
|
246
|
+
_scan_where(text, fields)
|
|
247
|
+
return text
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_where(where: str, fields: Iterable[Field]) -> list[WhereCondition]:
|
|
251
|
+
"""Check a hand-written `where` string and return its conditions.
|
|
252
|
+
|
|
253
|
+
Raises `QueryError` for the same mistakes that `table.query()` would. Useful for extra checks
|
|
254
|
+
of your own, or for finding out which fields a string refers to.
|
|
255
|
+
"""
|
|
256
|
+
return _scan_where(where.strip(), FieldIndex(fields))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def compile_where(where: Expression | str, fields: FieldIndex) -> str:
|
|
260
|
+
if isinstance(where, str):
|
|
261
|
+
return validate_where(where, fields)
|
|
262
|
+
if isinstance(where, Expression): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
263
|
+
return where.compile(fields)
|
|
264
|
+
raise QueryError("`where` must be a condition built with F, or a string.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _as_list(refs: FieldRef | Sequence[FieldRef]) -> list[FieldRef]:
|
|
268
|
+
return [refs] if isinstance(refs, str | int) else list(refs)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _sort_field(ref: FieldRef, fields: FieldIndex) -> dict[str, Any]:
|
|
272
|
+
descending = False
|
|
273
|
+
if isinstance(ref, int) and not isinstance(ref, bool) and ref < 0:
|
|
274
|
+
ref, descending = -ref, True
|
|
275
|
+
elif isinstance(ref, str) and ref.startswith("-") and not fields.has_label(ref):
|
|
276
|
+
ref, descending = ref[1:], True
|
|
277
|
+
return {"fieldId": fields.resolve(ref).id, "order": "DESC" if descending else "ASC"}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _select_ids(
|
|
281
|
+
select: FieldRef | Sequence[FieldRef] | None, all_fields: Sequence[Field], fields: FieldIndex
|
|
282
|
+
) -> list[int]:
|
|
283
|
+
if select is None:
|
|
284
|
+
ids = [item.id for item in all_fields if item.appears_by_default]
|
|
285
|
+
else:
|
|
286
|
+
ids = [fields.resolve(ref).id for ref in _as_list(select)]
|
|
287
|
+
return list(dict.fromkeys([RECORD_ID_FIELD, *ids]))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_query_body(
|
|
291
|
+
table_id: str,
|
|
292
|
+
all_fields: Sequence[Field],
|
|
293
|
+
*,
|
|
294
|
+
where: Expression | str | None,
|
|
295
|
+
select: FieldRef | Sequence[FieldRef] | None,
|
|
296
|
+
order_by: FieldRef | Sequence[FieldRef] | None,
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
"""Build the body of a records query, without its paging options.
|
|
299
|
+
|
|
300
|
+
Record ID# is always selected, and is always the last sort key so that pages stay stable
|
|
301
|
+
when other sort values tie.
|
|
302
|
+
"""
|
|
303
|
+
fields = FieldIndex(all_fields)
|
|
304
|
+
refs = [] if order_by is None else _as_list(order_by)
|
|
305
|
+
sort = [_sort_field(ref, fields) for ref in refs]
|
|
306
|
+
if all(item["fieldId"] != RECORD_ID_FIELD for item in sort):
|
|
307
|
+
sort.append({"fieldId": RECORD_ID_FIELD, "order": "ASC"})
|
|
308
|
+
body: dict[str, Any] = {
|
|
309
|
+
"from": table_id,
|
|
310
|
+
"select": _select_ids(select, all_fields, fields),
|
|
311
|
+
"sortBy": sort,
|
|
312
|
+
}
|
|
313
|
+
if where is not None:
|
|
314
|
+
body["where"] = compile_where(where, fields)
|
|
315
|
+
return body
|