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.
Files changed (135) hide show
  1. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/.gitignore +1 -0
  2. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/PKG-INFO +1 -1
  3. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/CHANGELOG.md +22 -0
  4. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +7 -14
  5. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/analysis.py +88 -68
  6. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/introspection/__init__.py +2 -0
  7. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/introspection/schema.py +25 -3
  8. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/otel.py +20 -1
  9. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/pyproject.toml +1 -1
  10. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_constraints.py +36 -1
  11. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_introspection.py +16 -0
  12. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/CLAUDE.md +0 -0
  13. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/LICENSE +0 -0
  14. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/README.md +0 -0
  15. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/README.md +0 -0
  16. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/__init__.py +0 -0
  17. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  18. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/aggregates.py +0 -0
  19. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/base.py +0 -0
  20. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/__init__.py +0 -0
  21. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/converge.py +0 -0
  22. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/core.py +0 -0
  23. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/diagnose.py +0 -0
  24. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/migrations.py +0 -0
  25. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/schema.py +0 -0
  26. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/cli/sync.py +0 -0
  27. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/config.py +0 -0
  28. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/connection.py +0 -0
  29. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/connections.py +0 -0
  30. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/constants.py +0 -0
  31. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/constraints.py +0 -0
  32. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/__init__.py +0 -0
  33. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/fixes.py +0 -0
  34. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/convergence/planning.py +0 -0
  35. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/database_url.py +0 -0
  36. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/db.py +0 -0
  37. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/ddl.py +0 -0
  38. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/default_settings.py +0 -0
  39. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/deletion.py +0 -0
  40. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/dialect.py +0 -0
  41. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/entrypoints.py +0 -0
  42. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/enums.py +0 -0
  43. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/exceptions.py +0 -0
  44. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/expressions.py +0 -0
  45. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/__init__.py +0 -0
  46. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/encrypted.py +0 -0
  47. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/json.py +0 -0
  48. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/mixins.py +0 -0
  49. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related.py +0 -0
  50. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_descriptors.py +0 -0
  51. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_lookups.py +0 -0
  52. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/related_managers.py +0 -0
  53. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  54. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/reverse_related.py +0 -0
  55. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/fields/timezones.py +0 -0
  56. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/forms.py +0 -0
  57. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/__init__.py +0 -0
  58. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/comparison.py +0 -0
  59. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/datetime.py +0 -0
  60. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/math.py +0 -0
  61. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/mixins.py +0 -0
  62. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/text.py +0 -0
  63. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/functions/window.py +0 -0
  64. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/indexes.py +0 -0
  65. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/introspection/health.py +0 -0
  66. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/lookups.py +0 -0
  67. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/meta.py +0 -0
  68. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/__init__.py +0 -0
  69. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/autodetector.py +0 -0
  70. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/exceptions.py +0 -0
  71. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/executor.py +0 -0
  72. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/graph.py +0 -0
  73. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/loader.py +0 -0
  74. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/migration.py +0 -0
  75. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  76. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/base.py +0 -0
  77. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/fields.py +0 -0
  78. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/models.py +0 -0
  79. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/operations/special.py +0 -0
  80. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/optimizer.py +0 -0
  81. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/questioner.py +0 -0
  82. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/recorder.py +0 -0
  83. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/serializer.py +0 -0
  84. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/state.py +0 -0
  85. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/utils.py +0 -0
  86. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/migrations/writer.py +0 -0
  87. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/options.py +0 -0
  88. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/preflight.py +0 -0
  89. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/query.py +0 -0
  90. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/query_utils.py +0 -0
  91. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/registry.py +0 -0
  92. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/schema.py +0 -0
  93. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/__init__.py +0 -0
  94. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/compiler.py +0 -0
  95. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/constants.py +0 -0
  96. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/datastructures.py +0 -0
  97. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/query.py +0 -0
  98. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/sql/where.py +0 -0
  99. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/test/__init__.py +0 -0
  100. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/test/pytest.py +0 -0
  101. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/test/utils.py +0 -0
  102. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/transaction.py +0 -0
  103. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/types.py +0 -0
  104. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/types.pyi +0 -0
  105. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/plain/postgres/utils.py +0 -0
  106. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  107. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  108. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  109. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  110. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  111. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  112. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  113. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/migrations/__init__.py +0 -0
  114. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/examples/models.py +0 -0
  115. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/settings.py +0 -0
  116. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/app/urls.py +0 -0
  117. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/conftest_convergence.py +0 -0
  118. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_connection_isolation.py +0 -0
  119. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_connection_lifecycle.py +0 -0
  120. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence.py +0 -0
  121. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_fk.py +0 -0
  122. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_indexes.py +0 -0
  123. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_convergence_nullability.py +0 -0
  124. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_database_url.py +0 -0
  125. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_delete_behaviors.py +0 -0
  126. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_encrypted_fields.py +0 -0
  127. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_exceptions.py +0 -0
  128. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_iterator.py +0 -0
  129. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_manager_assignment.py +0 -0
  130. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_migration_executor.py +0 -0
  131. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_models.py +0 -0
  132. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_read_only_transactions.py +0 -0
  133. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_related_descriptors.py +0 -0
  134. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_related_manager_api.py +0 -0
  135. {plain_postgres-0.92.0 → plain_postgres-0.93.0}/tests/test_schema_normalize_type.py +0 -0
@@ -20,3 +20,4 @@ plain*/tests/.plain
20
20
  /.claude/settings.local.json
21
21
  /CLAUDE.local.md
22
22
  /.benchmarks
23
+ .claude/worktrees
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.92.0
3
+ Version: 0.93.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -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` — the primary command: makes migrations (in DEBUG), applies them, and converges
37
- - `uv run plain migrations create` create migrations (`--dry-run` to preview, `--check` for CI)
38
- - `uv run plain migrations apply` — apply migrations
39
- - `uv run plain migrations list` view status
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
- - `Index` requires a `name` argument — use `{table}_{column(s)}_idx` (e.g., `plainjobs_jobrequest_priority_idx`, `plainobserver_log_trace_id_timestamp_idx`)
67
- - Use `UniqueConstraint` in constraints, not `unique=True` on fields
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
- expected_def = normalize_index_definition(index.to_sql(model))
425
- actual_def = normalize_index_definition(db_idx.definition)
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
- changed = ConstraintDrift(
1113
- kind=DriftKind.CHANGED, table=table, constraint=constraint, model=model
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
- if constraint.expressions:
1117
- # Expression-based: the expression IS the core structure, so
1118
- # normalized-definition comparison is the right tool.
1119
- expected_def = constraint.to_sql(model)
1120
- if normalize_index_definition(actual_def) != normalize_index_definition(
1121
- expected_def
1122
- ):
1123
- return f"definition differs: DB has {actual_def!r}", changed
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 constraint.fields
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
- return None, None
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
- col_str = s[start:i].strip()
1216
- for part in col_str.split(","):
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 constraint definition for comparison.
228
+ """Normalize a CHECK/condition definition for comparison.
219
229
 
220
- Strips the CHECK(...) wrapper and redundant parentheses so that
221
- pg_get_constraintdef output and model-generated SQL can be compared.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.92.0"
3
+ version = "0.93.0"
4
4
  description = "Model your data and store it in a database."
5
5
  authors = [{ name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev" }]
6
6
  readme = "README.md"
@@ -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