plain.postgres 0.92.0__tar.gz → 0.93.0__tar.gz
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-0.92.0 → plain_postgres-0.93.0}/.gitignore +1 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/PKG-INFO +1 -1
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/CHANGELOG.md +22 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +7 -14
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/analysis.py +88 -68
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/introspection/__init__.py +2 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/introspection/schema.py +25 -3
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/otel.py +20 -1
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/pyproject.toml +1 -1
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_constraints.py +36 -1
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_introspection.py +16 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/CLAUDE.md +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/LICENSE +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/README.md +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/README.md +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/connections.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/introspection/health.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/models.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_fk.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_models.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_related_descriptors.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_related_manager_api.py +0 -0
- {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_schema_normalize_type.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.93.0](https://github.com/dropseed/plain/releases/plain-postgres@0.93.0) (2026-04-01)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Added `db.client.operation.duration` OTel histogram for database query timing.** Every query executed through `db_span()` now records its duration as an OpenTelemetry histogram metric, following the [semantic conventions](https://opentelemetry.io/docs/specs/semconv/db/database-metrics/) for database client metrics. Attributes include `db.system.name`, `db.operation.name`, and `db.collection.name`. Without a configured `MeterProvider`, this is a no-op with zero overhead. ([56c2f993b88c](https://github.com/dropseed/plain/commit/56c2f993b88c))
|
|
8
|
+
|
|
9
|
+
### Upgrade instructions
|
|
10
|
+
|
|
11
|
+
- No changes required.
|
|
12
|
+
|
|
13
|
+
## [0.92.1](https://github.com/dropseed/plain/releases/plain-postgres@0.92.1) (2026-03-30)
|
|
14
|
+
|
|
15
|
+
### What's changed
|
|
16
|
+
|
|
17
|
+
- **Fixed false-positive "definition differs" for UniqueConstraint with expressions and conditions.** A `UniqueConstraint` using both expressions (e.g. `Lower("username")`) and a `condition` (e.g. `~Q(username="")`) was incorrectly flagged as drifted. PostgreSQL adds type casts (`''::text`) and the ORM adds extra parentheses around expressions — the old full-SQL-string comparison couldn't reconcile these differences. ([e03f3496a49a](https://github.com/dropseed/plain/commit/e03f3496a49a))
|
|
18
|
+
|
|
19
|
+
- **Replaced fragile full-SQL comparison with structured comparison for all index and constraint definitions.** Instead of normalizing entire `CREATE INDEX` statements, convergence now parses `pg_get_indexdef` output into components (expression text, columns, opclasses, WHERE clause) and compares each independently. Both regular indexes and unique constraints share a single comparison core. ([e03f3496a49a](https://github.com/dropseed/plain/commit/e03f3496a49a))
|
|
20
|
+
|
|
21
|
+
### Upgrade instructions
|
|
22
|
+
|
|
23
|
+
- No changes required.
|
|
24
|
+
|
|
3
25
|
## [0.92.0](https://github.com/dropseed/plain/releases/plain-postgres@0.92.0) (2026-03-30)
|
|
4
26
|
|
|
5
27
|
### What's changed
|
|
@@ -33,17 +33,10 @@ Get approval before writing any model code or generating migrations.
|
|
|
33
33
|
|
|
34
34
|
## Migrations
|
|
35
35
|
|
|
36
|
-
- `uv run plain postgres sync` —
|
|
37
|
-
- `uv run plain migrations create
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
- Before committing, consolidate multiple uncommitted migrations into one:
|
|
41
|
-
delete the intermediate files, run `migrations prune --yes` to clean stale DB records,
|
|
42
|
-
run `migrations create` fresh, then `migrations apply --fake` to mark it applied
|
|
43
|
-
- Use `migrations squash` only for already-committed/deployed migrations — never for dev cleanup
|
|
44
|
-
- Only write migrations by hand for custom data migrations
|
|
45
|
-
|
|
46
|
-
Run `uv run plain docs postgres --section migrations` for full workflow details.
|
|
36
|
+
- `uv run plain postgres sync` — creates migrations (in DEBUG), applies them, and converges
|
|
37
|
+
- For custom data migrations, use `uv run plain migrations create --empty --name <name>` to scaffold the file
|
|
38
|
+
|
|
39
|
+
Run `uv run plain docs postgres --section migrations` for individual migration commands and full workflow details.
|
|
47
40
|
|
|
48
41
|
## Querying
|
|
49
42
|
|
|
@@ -62,9 +55,10 @@ Run `uv run plain docs postgres --section querying` for full patterns with code
|
|
|
62
55
|
|
|
63
56
|
## Schema Design
|
|
64
57
|
|
|
58
|
+
- Always index FK columns — Postgres doesn't auto-create these. Use an `Index`, or a constraint with the FK as the first field.
|
|
65
59
|
- Index fields used in `.filter()` and `.order_by()`
|
|
66
|
-
-
|
|
67
|
-
-
|
|
60
|
+
- Indexes: `{table}_{column(s)}_idx`
|
|
61
|
+
- Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
|
|
68
62
|
- Choose `on_delete` deliberately: CASCADE for children, PROTECT for referenced data
|
|
69
63
|
- No `allow_null` on string fields — use `default=""`
|
|
70
64
|
|
|
@@ -81,6 +75,5 @@ Run `uv run plain docs postgres --section diagnostics` for check details, thresh
|
|
|
81
75
|
- Use `Model.query` not `Model.objects`
|
|
82
76
|
- Import fields from `plain.postgres.types` not `plain.postgres.fields` — and don't import field classes directly from `plain.postgres`
|
|
83
77
|
- Use `model_options = postgres.Options(...)` not `class Meta`
|
|
84
|
-
- Fields don't accept `unique=True` — use `UniqueConstraint` in constraints
|
|
85
78
|
- Never format raw SQL strings — always use parameterized queries
|
|
86
79
|
- Migrations are forward-only — no reverse migrations. `RunPython` takes a single callable (no `reverse_code` or `noop`). The callable signature is `fn(models, schema_editor)`, not `fn(apps, schema_editor)`
|
|
@@ -7,6 +7,7 @@ from functools import cached_property
|
|
|
7
7
|
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
9
|
from ..constraints import CheckConstraint, UniqueConstraint
|
|
10
|
+
from ..ddl import compile_expression_sql, compile_index_expressions_sql
|
|
10
11
|
from ..dialect import quote_name
|
|
11
12
|
from ..fields.related import ForeignKeyField
|
|
12
13
|
from ..indexes import Index
|
|
@@ -18,6 +19,7 @@ from ..introspection import (
|
|
|
18
19
|
TableState,
|
|
19
20
|
introspect_table,
|
|
20
21
|
normalize_check_definition,
|
|
22
|
+
normalize_expression,
|
|
21
23
|
normalize_index_definition,
|
|
22
24
|
normalize_unique_definition,
|
|
23
25
|
)
|
|
@@ -25,6 +27,8 @@ from ..introspection import (
|
|
|
25
27
|
if TYPE_CHECKING:
|
|
26
28
|
from ..base import Model
|
|
27
29
|
from ..connection import DatabaseConnection
|
|
30
|
+
from ..expressions import Expression, ReplaceableExpression
|
|
31
|
+
from ..query_utils import Q
|
|
28
32
|
from ..utils import CursorWrapper
|
|
29
33
|
|
|
30
34
|
|
|
@@ -421,21 +425,8 @@ def _compare_indexes(
|
|
|
421
425
|
|
|
422
426
|
# Check if definition matches
|
|
423
427
|
if db_idx.definition:
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if expected_def != actual_def:
|
|
427
|
-
if index.fields:
|
|
428
|
-
expected_columns = [
|
|
429
|
-
model._model_meta.get_forward_field(field_name).column
|
|
430
|
-
for field_name in index.fields
|
|
431
|
-
]
|
|
432
|
-
issue = (
|
|
433
|
-
f"columns differ: DB has {db_idx.columns}, model expects {expected_columns}"
|
|
434
|
-
if expected_columns != db_idx.columns
|
|
435
|
-
else f"definition differs: DB has {db_idx.definition!r}"
|
|
436
|
-
)
|
|
437
|
-
else:
|
|
438
|
-
issue = f"definition differs: DB has {db_idx.definition!r}"
|
|
428
|
+
issue = _compare_index_definition(model, index, db_idx.definition)
|
|
429
|
+
if issue:
|
|
439
430
|
statuses.append(
|
|
440
431
|
IndexStatus(
|
|
441
432
|
name=index.name,
|
|
@@ -554,6 +545,23 @@ def _compare_indexes(
|
|
|
554
545
|
return statuses
|
|
555
546
|
|
|
556
547
|
|
|
548
|
+
def _compare_index_definition(
|
|
549
|
+
model: type[Model], index: Index, actual_def: str
|
|
550
|
+
) -> str | None:
|
|
551
|
+
"""Compare a model index against its pg_get_indexdef output.
|
|
552
|
+
|
|
553
|
+
Returns an issue string if definitions differ, None if they match.
|
|
554
|
+
"""
|
|
555
|
+
return _compare_parsed_index(
|
|
556
|
+
model=model,
|
|
557
|
+
expressions=index.expressions,
|
|
558
|
+
fields=[name for name, _ in index.fields_orders],
|
|
559
|
+
opclasses=list(index.opclasses) if index.opclasses else [],
|
|
560
|
+
condition=index.condition,
|
|
561
|
+
actual_def=actual_def,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
557
565
|
# Constraint comparison
|
|
558
566
|
|
|
559
567
|
|
|
@@ -1099,67 +1107,78 @@ def _compare_index_only_unique(
|
|
|
1099
1107
|
Index-only variants (condition, expressions, opclasses) live as unique
|
|
1100
1108
|
indexes in PostgreSQL, not pg_constraint rows. Their ConstraintState
|
|
1101
1109
|
comes from the pg_index query path with a pg_get_indexdef definition.
|
|
1102
|
-
|
|
1103
|
-
For expression-based constraints we compare normalized index definitions.
|
|
1104
|
-
For field-based (condition/opclass) we parse pg_get_indexdef into
|
|
1105
|
-
structured components and compare each, avoiding fragile cross-format
|
|
1106
|
-
SQL normalization between the ORM and PostgreSQL.
|
|
1107
1110
|
"""
|
|
1108
1111
|
actual_def = actual_state.definition
|
|
1109
1112
|
if not actual_def:
|
|
1110
1113
|
return None, None
|
|
1111
1114
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1115
|
+
issue = _compare_parsed_index(
|
|
1116
|
+
model=model,
|
|
1117
|
+
expressions=constraint.expressions,
|
|
1118
|
+
fields=list(constraint.fields),
|
|
1119
|
+
opclasses=list(constraint.opclasses) if constraint.opclasses else [],
|
|
1120
|
+
condition=constraint.condition,
|
|
1121
|
+
actual_def=actual_def,
|
|
1114
1122
|
)
|
|
1123
|
+
if issue:
|
|
1124
|
+
changed = ConstraintDrift(
|
|
1125
|
+
kind=DriftKind.CHANGED, table=table, constraint=constraint, model=model
|
|
1126
|
+
)
|
|
1127
|
+
return issue, changed
|
|
1115
1128
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1129
|
+
return None, None
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _compare_parsed_index(
|
|
1133
|
+
*,
|
|
1134
|
+
model: type[Model],
|
|
1135
|
+
expressions: tuple[Expression | ReplaceableExpression, ...],
|
|
1136
|
+
fields: list[str],
|
|
1137
|
+
opclasses: list[str],
|
|
1138
|
+
condition: Q | None,
|
|
1139
|
+
actual_def: str,
|
|
1140
|
+
) -> str | None:
|
|
1141
|
+
"""Structured comparison of a model index/constraint against pg_get_indexdef.
|
|
1142
|
+
|
|
1143
|
+
Parses the DB definition into components (expression text, columns,
|
|
1144
|
+
opclasses, WHERE clause) and compares each independently, avoiding
|
|
1145
|
+
fragile full-SQL normalization between the ORM and PostgreSQL.
|
|
1146
|
+
|
|
1147
|
+
Returns an issue string if definitions differ, None if they match.
|
|
1148
|
+
"""
|
|
1149
|
+
db_parts = _parse_index_definition(actual_def)
|
|
1150
|
+
|
|
1151
|
+
if expressions:
|
|
1152
|
+
expected_expr = normalize_expression(
|
|
1153
|
+
compile_index_expressions_sql(model, expressions)
|
|
1154
|
+
)
|
|
1155
|
+
actual_expr = normalize_expression(db_parts.expression_text)
|
|
1156
|
+
if actual_expr != expected_expr:
|
|
1157
|
+
return f"definition differs: DB has {actual_def!r}"
|
|
1124
1158
|
else:
|
|
1125
|
-
# Field-based with condition/opclasses: compare structured components
|
|
1126
|
-
# parsed from pg_get_indexdef rather than normalizing full SQL strings.
|
|
1127
|
-
db_parts = _parse_index_definition(actual_def)
|
|
1128
1159
|
expected_columns = [
|
|
1129
|
-
model._model_meta.get_forward_field(f).column for f in
|
|
1160
|
+
model._model_meta.get_forward_field(f).column for f in fields
|
|
1130
1161
|
]
|
|
1131
1162
|
if db_parts.columns != expected_columns:
|
|
1132
|
-
return
|
|
1133
|
-
f"columns differ: DB has {db_parts.columns}, model expects {expected_columns}",
|
|
1134
|
-
changed,
|
|
1135
|
-
)
|
|
1136
|
-
expected_opclasses = list(constraint.opclasses) if constraint.opclasses else []
|
|
1137
|
-
if db_parts.opclasses != expected_opclasses:
|
|
1138
|
-
return (
|
|
1139
|
-
f"opclasses differ: DB has {db_parts.opclasses}, model expects {expected_opclasses}",
|
|
1140
|
-
changed,
|
|
1141
|
-
)
|
|
1142
|
-
has_condition = constraint.condition is not None
|
|
1143
|
-
if has_condition != db_parts.has_where:
|
|
1144
|
-
where_desc = "has WHERE" if db_parts.has_where else "no WHERE"
|
|
1145
|
-
return (
|
|
1146
|
-
f"condition differs: DB {where_desc}, model {'has' if has_condition else 'no'} condition",
|
|
1147
|
-
changed,
|
|
1148
|
-
)
|
|
1149
|
-
if has_condition and db_parts.where_clause:
|
|
1150
|
-
from ..ddl import compile_expression_sql
|
|
1151
|
-
|
|
1152
|
-
assert constraint.condition is not None
|
|
1153
|
-
expected_where = compile_expression_sql(model, constraint.condition)
|
|
1154
|
-
if normalize_check_definition(
|
|
1155
|
-
db_parts.where_clause
|
|
1156
|
-
) != normalize_check_definition(expected_where):
|
|
1157
|
-
return (
|
|
1158
|
-
f"condition differs: DB has WHERE ({db_parts.where_clause})",
|
|
1159
|
-
changed,
|
|
1160
|
-
)
|
|
1163
|
+
return f"columns differ: DB has {db_parts.columns}, model expects {expected_columns}"
|
|
1161
1164
|
|
|
1162
|
-
|
|
1165
|
+
if db_parts.opclasses != opclasses:
|
|
1166
|
+
return f"opclasses differ: DB has {db_parts.opclasses}, model expects {opclasses}"
|
|
1167
|
+
|
|
1168
|
+
# Compare WHERE clause
|
|
1169
|
+
has_condition = condition is not None
|
|
1170
|
+
if has_condition != db_parts.has_where:
|
|
1171
|
+
where_desc = "has WHERE" if db_parts.has_where else "no WHERE"
|
|
1172
|
+
return f"condition differs: DB {where_desc}, model {'has' if has_condition else 'no'} condition"
|
|
1173
|
+
if has_condition and db_parts.where_clause:
|
|
1174
|
+
assert condition is not None
|
|
1175
|
+
expected_where = compile_expression_sql(model, condition)
|
|
1176
|
+
if normalize_check_definition(
|
|
1177
|
+
db_parts.where_clause
|
|
1178
|
+
) != normalize_check_definition(expected_where):
|
|
1179
|
+
return f"condition differs: DB has WHERE ({db_parts.where_clause})"
|
|
1180
|
+
|
|
1181
|
+
return None
|
|
1163
1182
|
|
|
1164
1183
|
|
|
1165
1184
|
@dataclass
|
|
@@ -1170,6 +1189,7 @@ class _IndexParts:
|
|
|
1170
1189
|
opclasses: list[str]
|
|
1171
1190
|
has_where: bool
|
|
1172
1191
|
where_clause: str | None
|
|
1192
|
+
expression_text: str # raw text between the column-list parens
|
|
1173
1193
|
|
|
1174
1194
|
|
|
1175
1195
|
def _parse_index_definition(definition: str) -> _IndexParts:
|
|
@@ -1202,6 +1222,7 @@ def _parse_index_definition(definition: str) -> _IndexParts:
|
|
|
1202
1222
|
# Find the column list: content between parens after USING method
|
|
1203
1223
|
columns: list[str] = []
|
|
1204
1224
|
opclasses: list[str] = []
|
|
1225
|
+
expression_text = ""
|
|
1205
1226
|
using_match = re.search(r"\busing\s+\w+\s*\(", s)
|
|
1206
1227
|
if using_match:
|
|
1207
1228
|
start = using_match.end()
|
|
@@ -1212,8 +1233,8 @@ def _parse_index_definition(definition: str) -> _IndexParts:
|
|
|
1212
1233
|
elif s[i] == ")":
|
|
1213
1234
|
depth -= 1
|
|
1214
1235
|
if depth == 0:
|
|
1215
|
-
|
|
1216
|
-
for part in
|
|
1236
|
+
expression_text = s[start:i].strip()
|
|
1237
|
+
for part in expression_text.split(","):
|
|
1217
1238
|
part = part.strip()
|
|
1218
1239
|
# "col opclass" or just "col"
|
|
1219
1240
|
tokens = part.split()
|
|
@@ -1231,6 +1252,7 @@ def _parse_index_definition(definition: str) -> _IndexParts:
|
|
|
1231
1252
|
opclasses=opclasses,
|
|
1232
1253
|
has_where=has_where,
|
|
1233
1254
|
where_clause=where_clause,
|
|
1255
|
+
expression_text=expression_text,
|
|
1234
1256
|
)
|
|
1235
1257
|
|
|
1236
1258
|
|
|
@@ -1238,8 +1260,6 @@ def _get_expected_check_definition(
|
|
|
1238
1260
|
model: type[Model], constraint: CheckConstraint
|
|
1239
1261
|
) -> str:
|
|
1240
1262
|
"""Generate the CHECK expression that the model would produce."""
|
|
1241
|
-
from ..ddl import compile_expression_sql
|
|
1242
|
-
|
|
1243
1263
|
check_sql = compile_expression_sql(model, constraint.check)
|
|
1244
1264
|
return f"CHECK ({check_sql})"
|
|
1245
1265
|
|
|
@@ -17,6 +17,7 @@ from .schema import (
|
|
|
17
17
|
get_unknown_tables,
|
|
18
18
|
introspect_table,
|
|
19
19
|
normalize_check_definition,
|
|
20
|
+
normalize_expression,
|
|
20
21
|
normalize_index_definition,
|
|
21
22
|
normalize_unique_definition,
|
|
22
23
|
)
|
|
@@ -37,6 +38,7 @@ __all__ = [
|
|
|
37
38
|
"get_unknown_tables",
|
|
38
39
|
"introspect_table",
|
|
39
40
|
"normalize_check_definition",
|
|
41
|
+
"normalize_expression",
|
|
40
42
|
"normalize_index_definition",
|
|
41
43
|
"normalize_unique_definition",
|
|
42
44
|
"run_all_checks",
|
|
@@ -214,13 +214,25 @@ def _normalize_sql(s: str) -> str:
|
|
|
214
214
|
return re.sub(r"\s+", " ", s).strip()
|
|
215
215
|
|
|
216
216
|
|
|
217
|
+
def _strip_type_casts(s: str) -> str:
|
|
218
|
+
"""Strip PostgreSQL type casts (e.g. ''::text, 0::integer).
|
|
219
|
+
|
|
220
|
+
PostgreSQL adds explicit casts to stored definitions (pg_get_indexdef,
|
|
221
|
+
pg_get_constraintdef) but the ORM compiler omits them. Only used for
|
|
222
|
+
expression/condition comparison where the two generators diverge.
|
|
223
|
+
"""
|
|
224
|
+
return re.sub(r"::\w+", "", s)
|
|
225
|
+
|
|
226
|
+
|
|
217
227
|
def normalize_check_definition(s: str) -> str:
|
|
218
|
-
"""Normalize a CHECK
|
|
228
|
+
"""Normalize a CHECK/condition definition for comparison.
|
|
219
229
|
|
|
220
|
-
Strips the CHECK(...) wrapper
|
|
221
|
-
pg_get_constraintdef output and model-generated
|
|
230
|
+
Strips the CHECK(...) wrapper, redundant parentheses, and PG type casts
|
|
231
|
+
so that pg_get_constraintdef/pg_get_indexdef output and model-generated
|
|
232
|
+
SQL can be compared.
|
|
222
233
|
"""
|
|
223
234
|
s = _normalize_sql(s)
|
|
235
|
+
s = _strip_type_casts(s)
|
|
224
236
|
# Strip outer check(...)
|
|
225
237
|
if s.startswith("check"):
|
|
226
238
|
s = s[5:].strip()
|
|
@@ -243,6 +255,16 @@ def normalize_unique_definition(s: str) -> str:
|
|
|
243
255
|
return s
|
|
244
256
|
|
|
245
257
|
|
|
258
|
+
def normalize_expression(s: str) -> str:
|
|
259
|
+
"""Normalize an index expression for comparison.
|
|
260
|
+
|
|
261
|
+
Lowercases, strips quotes, collapses whitespace, and strips redundant
|
|
262
|
+
outer parentheses. Used for comparing the expression portion of index
|
|
263
|
+
definitions (e.g. 'LOWER("col")' vs 'lower(col)').
|
|
264
|
+
"""
|
|
265
|
+
return _strip_balanced_parens(_normalize_sql(s))
|
|
266
|
+
|
|
267
|
+
|
|
246
268
|
def normalize_index_definition(s: str) -> str:
|
|
247
269
|
"""Extract and normalize the expression part of a CREATE INDEX definition.
|
|
248
270
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
import time
|
|
4
5
|
import traceback
|
|
5
6
|
from collections.abc import Generator
|
|
6
7
|
from contextlib import contextmanager
|
|
7
8
|
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
9
10
|
from opentelemetry import context as otel_context
|
|
10
|
-
from opentelemetry import trace
|
|
11
|
+
from opentelemetry import metrics, trace
|
|
12
|
+
from opentelemetry.semconv.metrics.db_metrics import DB_CLIENT_OPERATION_DURATION
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
13
15
|
from opentelemetry.trace import Span
|
|
@@ -47,6 +49,12 @@ _SUPPRESS_KEY = "plain.postgres.suppress_db_tracing"
|
|
|
47
49
|
|
|
48
50
|
tracer = trace.get_tracer("plain.postgres")
|
|
49
51
|
|
|
52
|
+
meter = metrics.get_meter("plain.postgres")
|
|
53
|
+
query_duration_histogram = meter.create_histogram(
|
|
54
|
+
name=DB_CLIENT_OPERATION_DURATION,
|
|
55
|
+
unit="s",
|
|
56
|
+
description="Duration of database client operations.",
|
|
57
|
+
)
|
|
50
58
|
|
|
51
59
|
DB_SYSTEM = DbSystemValues.POSTGRESQL.value
|
|
52
60
|
|
|
@@ -174,7 +182,18 @@ def db_span(
|
|
|
174
182
|
with tracer.start_as_current_span(
|
|
175
183
|
span_name, kind=SpanKind.CLIENT, attributes=attrs
|
|
176
184
|
) as span:
|
|
185
|
+
start = time.perf_counter()
|
|
177
186
|
yield span
|
|
187
|
+
duration_s = time.perf_counter() - start
|
|
188
|
+
|
|
189
|
+
metric_attrs: dict[str, str] = {
|
|
190
|
+
DB_SYSTEM_NAME: DB_SYSTEM,
|
|
191
|
+
DB_OPERATION_NAME: operation,
|
|
192
|
+
}
|
|
193
|
+
if collection_name:
|
|
194
|
+
metric_attrs[DB_COLLECTION_NAME] = collection_name
|
|
195
|
+
query_duration_histogram.record(duration_s, metric_attrs)
|
|
196
|
+
|
|
178
197
|
span.set_status(trace.StatusCode.OK)
|
|
179
198
|
|
|
180
199
|
|
|
@@ -25,7 +25,7 @@ from plain.postgres.convergence import (
|
|
|
25
25
|
plan_convergence,
|
|
26
26
|
plan_model_convergence,
|
|
27
27
|
)
|
|
28
|
-
from plain.postgres.functions.text import Upper
|
|
28
|
+
from plain.postgres.functions.text import Lower, Upper
|
|
29
29
|
from plain.postgres.introspection import ConType
|
|
30
30
|
|
|
31
31
|
|
|
@@ -816,6 +816,41 @@ class TestIndexBackedUniqueConstraints:
|
|
|
816
816
|
finally:
|
|
817
817
|
Car.model_options.constraints = original_constraints
|
|
818
818
|
|
|
819
|
+
def test_matching_expression_with_condition_no_drift(self, db):
|
|
820
|
+
"""Expression unique with a WHERE clause should not report false-positive drift.
|
|
821
|
+
|
|
822
|
+
PG adds type casts (e.g. ''::text) and the ORM adds extra parens around
|
|
823
|
+
expressions. Structured comparison handles both.
|
|
824
|
+
"""
|
|
825
|
+
original_constraints = list(Car.model_options.constraints)
|
|
826
|
+
constraint = UniqueConstraint(
|
|
827
|
+
Lower("make"),
|
|
828
|
+
condition=~Q(make=""),
|
|
829
|
+
name="examples_car_make_lower_cond_uq",
|
|
830
|
+
)
|
|
831
|
+
Car.model_options.constraints = [*original_constraints, constraint]
|
|
832
|
+
|
|
833
|
+
execute(constraint.to_sql(Car))
|
|
834
|
+
|
|
835
|
+
try:
|
|
836
|
+
conn = get_connection()
|
|
837
|
+
with conn.cursor() as cursor:
|
|
838
|
+
plan = plan_model_convergence(conn, cursor, Car)
|
|
839
|
+
|
|
840
|
+
constraint_drifts = [
|
|
841
|
+
d
|
|
842
|
+
for d in plan.items
|
|
843
|
+
if isinstance(d.drift, ConstraintDrift)
|
|
844
|
+
and d.drift.constraint is not None
|
|
845
|
+
and d.drift.constraint.name == "examples_car_make_lower_cond_uq"
|
|
846
|
+
]
|
|
847
|
+
assert constraint_drifts == [], (
|
|
848
|
+
f"Expected no drift for matching expression+condition unique, got: "
|
|
849
|
+
f"{[d.describe() for d in constraint_drifts]}"
|
|
850
|
+
)
|
|
851
|
+
finally:
|
|
852
|
+
Car.model_options.constraints = original_constraints
|
|
853
|
+
|
|
819
854
|
# -- Gap 3: full lifecycle converges (create → re-check → no work) --
|
|
820
855
|
|
|
821
856
|
def test_conditional_unique_lifecycle(self, isolated_db):
|
|
@@ -234,6 +234,13 @@ class TestNormalizeCheckDefinition:
|
|
|
234
234
|
def test_handles_bare_expression(self):
|
|
235
235
|
assert normalize_check_definition("id > 0") == "id > 0"
|
|
236
236
|
|
|
237
|
+
def test_strips_type_casts(self):
|
|
238
|
+
"""PG adds explicit type casts to stored definitions."""
|
|
239
|
+
assert (
|
|
240
|
+
normalize_check_definition("CHECK (username <> ''::text)")
|
|
241
|
+
== "username <> ''"
|
|
242
|
+
)
|
|
243
|
+
|
|
237
244
|
|
|
238
245
|
class TestNormalizeIndexDefinition:
|
|
239
246
|
def test_strips_prefix_with_using(self):
|
|
@@ -267,3 +274,12 @@ class TestNormalizeIndexDefinition:
|
|
|
267
274
|
assert normalize_index_definition(db_def) == normalize_index_definition(
|
|
268
275
|
model_def
|
|
269
276
|
)
|
|
277
|
+
|
|
278
|
+
def test_with_where_clause(self):
|
|
279
|
+
"""normalize_index_definition preserves WHERE clause as-is (structured
|
|
280
|
+
comparison handles WHERE separately)."""
|
|
281
|
+
result = normalize_index_definition(
|
|
282
|
+
"CREATE UNIQUE INDEX foo ON public.bar USING btree (lower(username)) WHERE (NOT (username = ''::text))"
|
|
283
|
+
)
|
|
284
|
+
assert "where" in result
|
|
285
|
+
assert "lower(username)" in result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_descriptors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/reverse_descriptors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/__init__.py
RENAMED
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/base.py
RENAMED
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/fields.py
RENAMED
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/models.py
RENAMED
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/special.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0006_secretstore.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|