plain.postgres 0.84.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.
- plain/postgres/CHANGELOG.md +1028 -0
- plain/postgres/README.md +925 -0
- plain/postgres/__init__.py +120 -0
- plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
- plain/postgres/aggregates.py +236 -0
- plain/postgres/backups/__init__.py +0 -0
- plain/postgres/backups/cli.py +148 -0
- plain/postgres/backups/clients.py +94 -0
- plain/postgres/backups/core.py +172 -0
- plain/postgres/base.py +1415 -0
- plain/postgres/cli/__init__.py +3 -0
- plain/postgres/cli/db.py +142 -0
- plain/postgres/cli/migrations.py +1085 -0
- plain/postgres/config.py +18 -0
- plain/postgres/connection.py +1331 -0
- plain/postgres/connections.py +77 -0
- plain/postgres/constants.py +13 -0
- plain/postgres/constraints.py +495 -0
- plain/postgres/database_url.py +94 -0
- plain/postgres/db.py +59 -0
- plain/postgres/default_settings.py +38 -0
- plain/postgres/deletion.py +475 -0
- plain/postgres/dialect.py +640 -0
- plain/postgres/entrypoints.py +4 -0
- plain/postgres/enums.py +103 -0
- plain/postgres/exceptions.py +217 -0
- plain/postgres/expressions.py +1912 -0
- plain/postgres/fields/__init__.py +2118 -0
- plain/postgres/fields/encrypted.py +354 -0
- plain/postgres/fields/json.py +413 -0
- plain/postgres/fields/mixins.py +30 -0
- plain/postgres/fields/related.py +1192 -0
- plain/postgres/fields/related_descriptors.py +290 -0
- plain/postgres/fields/related_lookups.py +223 -0
- plain/postgres/fields/related_managers.py +661 -0
- plain/postgres/fields/reverse_descriptors.py +229 -0
- plain/postgres/fields/reverse_related.py +328 -0
- plain/postgres/fields/timezones.py +143 -0
- plain/postgres/forms.py +773 -0
- plain/postgres/functions/__init__.py +189 -0
- plain/postgres/functions/comparison.py +127 -0
- plain/postgres/functions/datetime.py +454 -0
- plain/postgres/functions/math.py +140 -0
- plain/postgres/functions/mixins.py +59 -0
- plain/postgres/functions/text.py +282 -0
- plain/postgres/functions/window.py +125 -0
- plain/postgres/indexes.py +286 -0
- plain/postgres/lookups.py +758 -0
- plain/postgres/meta.py +584 -0
- plain/postgres/migrations/__init__.py +53 -0
- plain/postgres/migrations/autodetector.py +1379 -0
- plain/postgres/migrations/exceptions.py +54 -0
- plain/postgres/migrations/executor.py +188 -0
- plain/postgres/migrations/graph.py +364 -0
- plain/postgres/migrations/loader.py +377 -0
- plain/postgres/migrations/migration.py +180 -0
- plain/postgres/migrations/operations/__init__.py +34 -0
- plain/postgres/migrations/operations/base.py +139 -0
- plain/postgres/migrations/operations/fields.py +373 -0
- plain/postgres/migrations/operations/models.py +798 -0
- plain/postgres/migrations/operations/special.py +184 -0
- plain/postgres/migrations/optimizer.py +74 -0
- plain/postgres/migrations/questioner.py +340 -0
- plain/postgres/migrations/recorder.py +119 -0
- plain/postgres/migrations/serializer.py +378 -0
- plain/postgres/migrations/state.py +882 -0
- plain/postgres/migrations/utils.py +147 -0
- plain/postgres/migrations/writer.py +302 -0
- plain/postgres/options.py +207 -0
- plain/postgres/otel.py +231 -0
- plain/postgres/preflight.py +336 -0
- plain/postgres/query.py +2242 -0
- plain/postgres/query_utils.py +456 -0
- plain/postgres/registry.py +217 -0
- plain/postgres/schema.py +1885 -0
- plain/postgres/sql/__init__.py +40 -0
- plain/postgres/sql/compiler.py +1869 -0
- plain/postgres/sql/constants.py +22 -0
- plain/postgres/sql/datastructures.py +222 -0
- plain/postgres/sql/query.py +2947 -0
- plain/postgres/sql/where.py +374 -0
- plain/postgres/test/__init__.py +0 -0
- plain/postgres/test/pytest.py +117 -0
- plain/postgres/test/utils.py +18 -0
- plain/postgres/transaction.py +222 -0
- plain/postgres/types.py +92 -0
- plain/postgres/types.pyi +751 -0
- plain/postgres/utils.py +345 -0
- plain_postgres-0.84.0.dist-info/METADATA +937 -0
- plain_postgres-0.84.0.dist-info/RECORD +93 -0
- plain_postgres-0.84.0.dist-info/WHEEL +4 -0
- plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
- plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
plain/postgres/otel.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import traceback
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry import context as otel_context
|
|
10
|
+
from opentelemetry import trace
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from opentelemetry.trace import Span
|
|
14
|
+
|
|
15
|
+
from plain.postgres.connection import DatabaseConnection
|
|
16
|
+
|
|
17
|
+
from opentelemetry.semconv._incubating.attributes.db_attributes import (
|
|
18
|
+
DB_QUERY_PARAMETER_TEMPLATE,
|
|
19
|
+
DB_USER,
|
|
20
|
+
)
|
|
21
|
+
from opentelemetry.semconv.attributes.code_attributes import (
|
|
22
|
+
CODE_COLUMN_NUMBER,
|
|
23
|
+
CODE_FILE_PATH,
|
|
24
|
+
CODE_FUNCTION_NAME,
|
|
25
|
+
CODE_LINE_NUMBER,
|
|
26
|
+
CODE_STACKTRACE,
|
|
27
|
+
)
|
|
28
|
+
from opentelemetry.semconv.attributes.db_attributes import (
|
|
29
|
+
DB_COLLECTION_NAME,
|
|
30
|
+
DB_NAMESPACE,
|
|
31
|
+
DB_OPERATION_NAME,
|
|
32
|
+
DB_QUERY_SUMMARY,
|
|
33
|
+
DB_QUERY_TEXT,
|
|
34
|
+
DB_SYSTEM_NAME,
|
|
35
|
+
)
|
|
36
|
+
from opentelemetry.semconv.attributes.network_attributes import (
|
|
37
|
+
NETWORK_PEER_ADDRESS,
|
|
38
|
+
NETWORK_PEER_PORT,
|
|
39
|
+
)
|
|
40
|
+
from opentelemetry.semconv.trace import DbSystemValues
|
|
41
|
+
from opentelemetry.trace import SpanKind
|
|
42
|
+
|
|
43
|
+
from plain.runtime import settings
|
|
44
|
+
|
|
45
|
+
# Use a stable string key so OpenTelemetry context APIs receive the expected type.
|
|
46
|
+
_SUPPRESS_KEY = "plain.postgres.suppress_db_tracing"
|
|
47
|
+
|
|
48
|
+
tracer = trace.get_tracer("plain.postgres")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DB_SYSTEM = DbSystemValues.POSTGRESQL.value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_operation_and_target(sql: str) -> tuple[str, str | None, str | None]:
|
|
55
|
+
"""Extract operation, table name, and collection from SQL.
|
|
56
|
+
|
|
57
|
+
Returns: (operation, summary, collection_name)
|
|
58
|
+
"""
|
|
59
|
+
sql_upper = sql.upper().strip()
|
|
60
|
+
|
|
61
|
+
# Strip leading parentheses (e.g. UNION queries: "(SELECT ... UNION ...)")
|
|
62
|
+
operation = sql_upper.lstrip("(").split()[0] if sql_upper else "UNKNOWN"
|
|
63
|
+
|
|
64
|
+
# Pattern to match quoted and unquoted identifiers
|
|
65
|
+
# Matches: "quoted" (PostgreSQL), unquoted.name
|
|
66
|
+
identifier_pattern = r'("([^"]+)"|([\w.]+))'
|
|
67
|
+
|
|
68
|
+
# Map operations to the SQL keyword that precedes the table name.
|
|
69
|
+
keyword_by_operation = {
|
|
70
|
+
"SELECT": "FROM",
|
|
71
|
+
"DELETE": "FROM",
|
|
72
|
+
"INSERT": "INTO",
|
|
73
|
+
"UPDATE": "UPDATE",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Extract table/collection name based on operation
|
|
77
|
+
collection_name = None
|
|
78
|
+
summary = operation
|
|
79
|
+
|
|
80
|
+
keyword = keyword_by_operation.get(operation)
|
|
81
|
+
if keyword:
|
|
82
|
+
match = re.search(rf"{keyword}\s+{identifier_pattern}", sql, re.IGNORECASE)
|
|
83
|
+
if match:
|
|
84
|
+
collection_name = _clean_identifier(match.group(1))
|
|
85
|
+
summary = f"{operation} {collection_name}"
|
|
86
|
+
|
|
87
|
+
# Detect UNION queries
|
|
88
|
+
if " UNION " in sql_upper and summary:
|
|
89
|
+
summary = f"{summary} UNION"
|
|
90
|
+
|
|
91
|
+
return operation, summary, collection_name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _clean_identifier(identifier: str) -> str:
|
|
95
|
+
"""Remove quotes from SQL identifiers."""
|
|
96
|
+
if identifier.startswith('"') and identifier.endswith('"'):
|
|
97
|
+
return identifier[1:-1]
|
|
98
|
+
return identifier
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def db_span(
|
|
103
|
+
db: DatabaseConnection, sql: Any, *, many: bool = False, params: Any = None
|
|
104
|
+
) -> Generator[Span | None]:
|
|
105
|
+
"""Open an OpenTelemetry CLIENT span for a database query.
|
|
106
|
+
|
|
107
|
+
All common attributes (`db.*`, `network.*`, etc.) are set automatically.
|
|
108
|
+
Follows OpenTelemetry semantic conventions for database instrumentation.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# Fast-exit if instrumentation suppression flag set in context.
|
|
112
|
+
if otel_context.get_value(_SUPPRESS_KEY):
|
|
113
|
+
yield None
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
sql = str(sql) # Ensure SQL is a string for span attributes.
|
|
117
|
+
|
|
118
|
+
# Extract operation and target information
|
|
119
|
+
operation, summary, collection_name = extract_operation_and_target(sql)
|
|
120
|
+
|
|
121
|
+
if many:
|
|
122
|
+
summary = f"{summary} many"
|
|
123
|
+
|
|
124
|
+
# Span name follows semantic conventions: {target} or {db.operation.name} {target}
|
|
125
|
+
if summary:
|
|
126
|
+
span_name = summary[:255]
|
|
127
|
+
else:
|
|
128
|
+
span_name = operation
|
|
129
|
+
|
|
130
|
+
# Build attribute set following semantic conventions
|
|
131
|
+
attrs: dict[str, Any] = {
|
|
132
|
+
DB_SYSTEM_NAME: DB_SYSTEM,
|
|
133
|
+
DB_NAMESPACE: db.settings_dict.get("DATABASE"),
|
|
134
|
+
DB_QUERY_TEXT: sql, # Already parameterized from Django/Plain
|
|
135
|
+
DB_QUERY_SUMMARY: summary,
|
|
136
|
+
DB_OPERATION_NAME: operation,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
attrs.update(_get_code_attributes())
|
|
140
|
+
|
|
141
|
+
# Add collection name if detected
|
|
142
|
+
if collection_name:
|
|
143
|
+
attrs[DB_COLLECTION_NAME] = collection_name
|
|
144
|
+
|
|
145
|
+
# Add user attribute
|
|
146
|
+
if user := db.settings_dict.get("USER"):
|
|
147
|
+
attrs[DB_USER] = user
|
|
148
|
+
|
|
149
|
+
# Network attributes
|
|
150
|
+
if host := db.settings_dict.get("HOST"):
|
|
151
|
+
attrs[NETWORK_PEER_ADDRESS] = host
|
|
152
|
+
|
|
153
|
+
if port := db.settings_dict.get("PORT"):
|
|
154
|
+
try:
|
|
155
|
+
attrs[NETWORK_PEER_PORT] = int(port)
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# Add query parameters as attributes when DEBUG is True
|
|
160
|
+
if settings.DEBUG and params is not None:
|
|
161
|
+
# Convert params to appropriate format based on type
|
|
162
|
+
if isinstance(params, dict):
|
|
163
|
+
# Dictionary params (e.g., for named placeholders)
|
|
164
|
+
for key, value in params.items():
|
|
165
|
+
attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{key}"] = str(value)
|
|
166
|
+
elif isinstance(params, list | tuple):
|
|
167
|
+
# Sequential params (e.g., for %s or ? placeholders)
|
|
168
|
+
for i, value in enumerate(params):
|
|
169
|
+
attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{i + 1}"] = str(value)
|
|
170
|
+
else:
|
|
171
|
+
# Single param (rare but possible)
|
|
172
|
+
attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.1"] = str(params)
|
|
173
|
+
|
|
174
|
+
with tracer.start_as_current_span(
|
|
175
|
+
span_name, kind=SpanKind.CLIENT, attributes=attrs
|
|
176
|
+
) as span:
|
|
177
|
+
yield span
|
|
178
|
+
span.set_status(trace.StatusCode.OK)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@contextmanager
|
|
182
|
+
def suppress_db_tracing() -> Generator[None]:
|
|
183
|
+
token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True))
|
|
184
|
+
try:
|
|
185
|
+
yield
|
|
186
|
+
finally:
|
|
187
|
+
otel_context.detach(token)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_internal_frame(frame: traceback.FrameSummary) -> bool:
|
|
191
|
+
"""Return True if the frame is internal to plain.postgres or contextlib."""
|
|
192
|
+
filepath = frame.filename
|
|
193
|
+
if not filepath:
|
|
194
|
+
return True
|
|
195
|
+
if "/plain/postgres/" in filepath:
|
|
196
|
+
return True
|
|
197
|
+
if filepath.endswith("contextlib.py"):
|
|
198
|
+
return True
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _get_code_attributes() -> dict[str, Any]:
|
|
203
|
+
"""Extract code context attributes for the current database query.
|
|
204
|
+
|
|
205
|
+
Returns a dict of OpenTelemetry code attributes.
|
|
206
|
+
"""
|
|
207
|
+
stack = traceback.extract_stack()
|
|
208
|
+
|
|
209
|
+
# Find the first user code frame (outermost non-internal frame from the top of the call stack)
|
|
210
|
+
for frame in reversed(stack):
|
|
211
|
+
if _is_internal_frame(frame):
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
attrs: dict[str, Any] = {
|
|
215
|
+
CODE_FILE_PATH: frame.filename,
|
|
216
|
+
}
|
|
217
|
+
if frame.lineno:
|
|
218
|
+
attrs[CODE_LINE_NUMBER] = frame.lineno
|
|
219
|
+
if frame.name:
|
|
220
|
+
attrs[CODE_FUNCTION_NAME] = frame.name
|
|
221
|
+
if frame.colno:
|
|
222
|
+
attrs[CODE_COLUMN_NUMBER] = frame.colno
|
|
223
|
+
|
|
224
|
+
# Add full stack trace only in DEBUG mode (expensive)
|
|
225
|
+
if settings.DEBUG:
|
|
226
|
+
filtered_stack = [f for f in stack if not _is_internal_frame(f)]
|
|
227
|
+
attrs[CODE_STACKTRACE] = "".join(traceback.format_list(filtered_stack))
|
|
228
|
+
|
|
229
|
+
return attrs
|
|
230
|
+
|
|
231
|
+
return {}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from plain.packages import packages_registry
|
|
9
|
+
from plain.postgres.db import get_connection
|
|
10
|
+
from plain.postgres.migrations.recorder import MIGRATION_TABLE_NAME
|
|
11
|
+
from plain.postgres.registry import ModelsRegistry, models_registry
|
|
12
|
+
from plain.preflight import PreflightCheck, PreflightResult, register_check
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_check("postgres.all_models")
|
|
16
|
+
class CheckAllModels(PreflightCheck):
|
|
17
|
+
"""Validates all model definitions for common issues."""
|
|
18
|
+
|
|
19
|
+
def run(self) -> list[PreflightResult]:
|
|
20
|
+
db_table_models = defaultdict(list)
|
|
21
|
+
indexes = defaultdict(list)
|
|
22
|
+
constraints = defaultdict(list)
|
|
23
|
+
errors = []
|
|
24
|
+
models = models_registry.get_models()
|
|
25
|
+
for model in models:
|
|
26
|
+
db_table_models[model.model_options.db_table].append(
|
|
27
|
+
model.model_options.label
|
|
28
|
+
)
|
|
29
|
+
if not inspect.ismethod(model.preflight):
|
|
30
|
+
errors.append(
|
|
31
|
+
PreflightResult(
|
|
32
|
+
fix=f"The '{model.__name__}.preflight()' class method is currently overridden by {model.preflight!r}.",
|
|
33
|
+
obj=model,
|
|
34
|
+
id="postgres.preflight_method_overridden",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
errors.extend(model.preflight())
|
|
39
|
+
for model_index in model.model_options.indexes:
|
|
40
|
+
indexes[model_index.name].append(model.model_options.label)
|
|
41
|
+
for model_constraint in model.model_options.constraints:
|
|
42
|
+
constraints[model_constraint.name].append(model.model_options.label)
|
|
43
|
+
for db_table, model_labels in db_table_models.items():
|
|
44
|
+
if len(model_labels) != 1:
|
|
45
|
+
model_labels_str = ", ".join(model_labels)
|
|
46
|
+
errors.append(
|
|
47
|
+
PreflightResult(
|
|
48
|
+
fix=f"db_table '{db_table}' is used by multiple models: {model_labels_str}.",
|
|
49
|
+
obj=db_table,
|
|
50
|
+
id="postgres.duplicate_db_table",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
for index_name, model_labels in indexes.items():
|
|
54
|
+
if len(model_labels) > 1:
|
|
55
|
+
model_labels = set(model_labels)
|
|
56
|
+
errors.append(
|
|
57
|
+
PreflightResult(
|
|
58
|
+
fix="index name '{}' is not unique {} {}.".format(
|
|
59
|
+
index_name,
|
|
60
|
+
"for model" if len(model_labels) == 1 else "among models:",
|
|
61
|
+
", ".join(sorted(model_labels)),
|
|
62
|
+
),
|
|
63
|
+
id="postgres.index_name_not_unique_single"
|
|
64
|
+
if len(model_labels) == 1
|
|
65
|
+
else "postgres.index_name_not_unique_multiple",
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
for constraint_name, model_labels in constraints.items():
|
|
69
|
+
if len(model_labels) > 1:
|
|
70
|
+
model_labels = set(model_labels)
|
|
71
|
+
errors.append(
|
|
72
|
+
PreflightResult(
|
|
73
|
+
fix="constraint name '{}' is not unique {} {}.".format(
|
|
74
|
+
constraint_name,
|
|
75
|
+
"for model" if len(model_labels) == 1 else "among models:",
|
|
76
|
+
", ".join(sorted(model_labels)),
|
|
77
|
+
),
|
|
78
|
+
id="postgres.constraint_name_not_unique_single"
|
|
79
|
+
if len(model_labels) == 1
|
|
80
|
+
else "postgres.constraint_name_not_unique_multiple",
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
return errors
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_lazy_references(
|
|
87
|
+
models_registry: ModelsRegistry, packages_registry: Any
|
|
88
|
+
) -> list[PreflightResult]:
|
|
89
|
+
"""
|
|
90
|
+
Ensure all lazy (i.e. string) model references have been resolved.
|
|
91
|
+
|
|
92
|
+
Lazy references are used in various places throughout Plain, primarily in
|
|
93
|
+
related fields and model signals. Identify those common cases and provide
|
|
94
|
+
more helpful error messages for them.
|
|
95
|
+
"""
|
|
96
|
+
pending_models = set(models_registry._pending_operations)
|
|
97
|
+
|
|
98
|
+
# Short circuit if there aren't any errors.
|
|
99
|
+
if not pending_models:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
def extract_operation(
|
|
103
|
+
obj: Any,
|
|
104
|
+
) -> tuple[Callable[..., Any], list[Any], dict[str, Any]]:
|
|
105
|
+
"""
|
|
106
|
+
Take a callable found in Packages._pending_operations and identify the
|
|
107
|
+
original callable passed to Packages.lazy_model_operation(). If that
|
|
108
|
+
callable was a partial, return the inner, non-partial function and
|
|
109
|
+
any arguments and keyword arguments that were supplied with it.
|
|
110
|
+
|
|
111
|
+
obj is a callback defined locally in Packages.lazy_model_operation() and
|
|
112
|
+
annotated there with a `func` attribute so as to imitate a partial.
|
|
113
|
+
"""
|
|
114
|
+
operation, args, keywords = obj, [], {}
|
|
115
|
+
while hasattr(operation, "func"):
|
|
116
|
+
args.extend(getattr(operation, "args", []))
|
|
117
|
+
keywords.update(getattr(operation, "keywords", {}))
|
|
118
|
+
operation = operation.func
|
|
119
|
+
return operation, args, keywords
|
|
120
|
+
|
|
121
|
+
def app_model_error(model_key: tuple[str, str]) -> str:
|
|
122
|
+
try:
|
|
123
|
+
packages_registry.get_package_config(model_key[0])
|
|
124
|
+
model_error = "app '{}' doesn't provide model '{}'".format(*model_key)
|
|
125
|
+
except LookupError:
|
|
126
|
+
model_error = f"app '{model_key[0]}' isn't installed"
|
|
127
|
+
return model_error
|
|
128
|
+
|
|
129
|
+
# Here are several functions which return CheckMessage instances for the
|
|
130
|
+
# most common usages of lazy operations throughout Plain. These functions
|
|
131
|
+
# take the model that was being waited on as an (package_label, modelname)
|
|
132
|
+
# pair, the original lazy function, and its positional and keyword args as
|
|
133
|
+
# determined by extract_operation().
|
|
134
|
+
|
|
135
|
+
def field_error(
|
|
136
|
+
model_key: tuple[str, str],
|
|
137
|
+
func: Callable[..., Any],
|
|
138
|
+
args: list[Any],
|
|
139
|
+
keywords: dict[str, Any],
|
|
140
|
+
) -> PreflightResult:
|
|
141
|
+
error_msg = (
|
|
142
|
+
"The field %(field)s was declared with a lazy reference "
|
|
143
|
+
"to '%(model)s', but %(model_error)s."
|
|
144
|
+
)
|
|
145
|
+
params = {
|
|
146
|
+
"model": ".".join(model_key),
|
|
147
|
+
"field": keywords["field"],
|
|
148
|
+
"model_error": app_model_error(model_key),
|
|
149
|
+
}
|
|
150
|
+
return PreflightResult(
|
|
151
|
+
fix=error_msg % params,
|
|
152
|
+
obj=keywords["field"],
|
|
153
|
+
id="fields.lazy_reference_not_resolvable",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def default_error(
|
|
157
|
+
model_key: tuple[str, str],
|
|
158
|
+
func: Callable[..., Any],
|
|
159
|
+
args: list[Any],
|
|
160
|
+
keywords: dict[str, Any],
|
|
161
|
+
) -> PreflightResult:
|
|
162
|
+
error_msg = (
|
|
163
|
+
"%(op)s contains a lazy reference to %(model)s, but %(model_error)s."
|
|
164
|
+
)
|
|
165
|
+
params = {
|
|
166
|
+
"op": func,
|
|
167
|
+
"model": ".".join(model_key),
|
|
168
|
+
"model_error": app_model_error(model_key),
|
|
169
|
+
}
|
|
170
|
+
return PreflightResult(
|
|
171
|
+
fix=error_msg % params,
|
|
172
|
+
obj=func,
|
|
173
|
+
id="postgres.lazy_reference_resolution_failed",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Maps common uses of lazy operations to corresponding error functions
|
|
177
|
+
# defined above. If a key maps to None, no error will be produced.
|
|
178
|
+
# default_error() will be used for usages that don't appear in this dict.
|
|
179
|
+
known_lazy = {
|
|
180
|
+
("plain.postgres.fields.related", "resolve_related_class"): field_error,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def build_error(
|
|
184
|
+
model_key: tuple[str, str],
|
|
185
|
+
func: Callable[..., Any],
|
|
186
|
+
args: list[Any],
|
|
187
|
+
keywords: dict[str, Any],
|
|
188
|
+
) -> PreflightResult | None:
|
|
189
|
+
key = (func.__module__, func.__name__) # type: ignore[attr-defined]
|
|
190
|
+
error_fn = known_lazy.get(key, default_error)
|
|
191
|
+
return error_fn(model_key, func, args, keywords) if error_fn else None
|
|
192
|
+
|
|
193
|
+
return sorted(
|
|
194
|
+
filter(
|
|
195
|
+
None,
|
|
196
|
+
(
|
|
197
|
+
build_error(model_key, *extract_operation(func))
|
|
198
|
+
for model_key in pending_models
|
|
199
|
+
for func in models_registry._pending_operations[model_key]
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
key=lambda error: error.fix,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@register_check("postgres.lazy_references")
|
|
207
|
+
class CheckLazyReferences(PreflightCheck):
|
|
208
|
+
"""Ensures all lazy (string) model references have been resolved."""
|
|
209
|
+
|
|
210
|
+
def run(self) -> list[PreflightResult]:
|
|
211
|
+
return _check_lazy_references(models_registry, packages_registry)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@register_check("postgres.postgres_version")
|
|
215
|
+
class CheckPostgresVersion(PreflightCheck):
|
|
216
|
+
"""Checks that the PostgreSQL server meets the minimum version requirement."""
|
|
217
|
+
|
|
218
|
+
MINIMUM_VERSION = 16
|
|
219
|
+
|
|
220
|
+
def run(self) -> list[PreflightResult]:
|
|
221
|
+
conn = get_connection()
|
|
222
|
+
major, minor = divmod(conn.pg_version, 10000)
|
|
223
|
+
if major < self.MINIMUM_VERSION:
|
|
224
|
+
return [
|
|
225
|
+
PreflightResult(
|
|
226
|
+
fix=f"PostgreSQL {self.MINIMUM_VERSION} or later is required (found {major}.{minor}).",
|
|
227
|
+
id="postgres.postgres_version_too_old",
|
|
228
|
+
)
|
|
229
|
+
]
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@register_check("postgres.database_tables")
|
|
234
|
+
class CheckDatabaseTables(PreflightCheck):
|
|
235
|
+
"""Checks for unknown tables in the database when plain.postgres is available."""
|
|
236
|
+
|
|
237
|
+
def run(self) -> list[PreflightResult]:
|
|
238
|
+
conn = get_connection()
|
|
239
|
+
unknown_tables = (
|
|
240
|
+
set(conn.table_names())
|
|
241
|
+
- set(conn.plain_table_names())
|
|
242
|
+
- {MIGRATION_TABLE_NAME}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if not unknown_tables:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
table_names = ", ".join(sorted(unknown_tables))
|
|
249
|
+
return [
|
|
250
|
+
PreflightResult(
|
|
251
|
+
fix=f"Unknown tables in default database: {table_names}. "
|
|
252
|
+
"Tables may be from packages/models that have been uninstalled. "
|
|
253
|
+
"Make sure you have a backup, then run `plain db drop-unknown-tables` to remove them.",
|
|
254
|
+
id="postgres.unknown_database_tables",
|
|
255
|
+
warning=True,
|
|
256
|
+
)
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@register_check("postgres.prunable_migrations")
|
|
261
|
+
class CheckPrunableMigrations(PreflightCheck):
|
|
262
|
+
"""Warns about stale migration records in the database."""
|
|
263
|
+
|
|
264
|
+
def run(self) -> list[PreflightResult]:
|
|
265
|
+
# Import here to avoid circular import issues
|
|
266
|
+
from plain.postgres.migrations.loader import MigrationLoader
|
|
267
|
+
from plain.postgres.migrations.recorder import MigrationRecorder
|
|
268
|
+
|
|
269
|
+
errors = []
|
|
270
|
+
|
|
271
|
+
# Load migrations from disk and database
|
|
272
|
+
conn = get_connection()
|
|
273
|
+
loader = MigrationLoader(conn, ignore_no_migrations=True)
|
|
274
|
+
recorder = MigrationRecorder(conn)
|
|
275
|
+
recorded_migrations = recorder.applied_migrations()
|
|
276
|
+
|
|
277
|
+
# disk_migrations should not be None after MigrationLoader initialization,
|
|
278
|
+
# but check to satisfy type checker
|
|
279
|
+
if loader.disk_migrations is None:
|
|
280
|
+
return errors
|
|
281
|
+
|
|
282
|
+
# Find all prunable migrations (recorded but not on disk)
|
|
283
|
+
all_prunable = [
|
|
284
|
+
migration
|
|
285
|
+
for migration in recorded_migrations
|
|
286
|
+
if migration not in loader.disk_migrations
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
if not all_prunable:
|
|
290
|
+
return errors
|
|
291
|
+
|
|
292
|
+
# Separate into existing packages vs orphaned packages
|
|
293
|
+
existing_packages = set(loader.migrated_packages)
|
|
294
|
+
prunable_existing: list[tuple[str, str]] = []
|
|
295
|
+
prunable_orphaned: list[tuple[str, str]] = []
|
|
296
|
+
|
|
297
|
+
for migration in all_prunable:
|
|
298
|
+
package, name = migration
|
|
299
|
+
if package in existing_packages:
|
|
300
|
+
prunable_existing.append(migration)
|
|
301
|
+
else:
|
|
302
|
+
prunable_orphaned.append(migration)
|
|
303
|
+
|
|
304
|
+
# Build the warning message
|
|
305
|
+
total_count = len(all_prunable)
|
|
306
|
+
message_parts = [
|
|
307
|
+
f"Found {total_count} stale migration record{'s' if total_count != 1 else ''} in the database."
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
if prunable_existing:
|
|
311
|
+
existing_list = ", ".join(
|
|
312
|
+
f"{pkg}.{name}" for pkg, name in prunable_existing[:3]
|
|
313
|
+
)
|
|
314
|
+
if len(prunable_existing) > 3:
|
|
315
|
+
existing_list += f" (and {len(prunable_existing) - 3} more)"
|
|
316
|
+
message_parts.append(f"From existing packages: {existing_list}.")
|
|
317
|
+
|
|
318
|
+
if prunable_orphaned:
|
|
319
|
+
orphaned_list = ", ".join(
|
|
320
|
+
f"{pkg}.{name}" for pkg, name in prunable_orphaned[:3]
|
|
321
|
+
)
|
|
322
|
+
if len(prunable_orphaned) > 3:
|
|
323
|
+
orphaned_list += f" (and {len(prunable_orphaned) - 3} more)"
|
|
324
|
+
message_parts.append(f"From removed packages: {orphaned_list}.")
|
|
325
|
+
|
|
326
|
+
message_parts.append("Run 'plain migrations prune' to review and remove them.")
|
|
327
|
+
|
|
328
|
+
errors.append(
|
|
329
|
+
PreflightResult(
|
|
330
|
+
fix=" ".join(message_parts),
|
|
331
|
+
id="postgres.prunable_migrations",
|
|
332
|
+
warning=True,
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return errors
|