plain.postgres 0.88.2__tar.gz → 0.89.1__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 (124) hide show
  1. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/PKG-INFO +1 -1
  2. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/CHANGELOG.md +58 -0
  3. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/cli/core.py +2 -0
  4. plain_postgres-0.89.1/plain/postgres/cli/schema.py +248 -0
  5. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/diagnose/checks.py +2 -30
  6. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/dialect.py +1 -1
  7. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/related.py +0 -5
  8. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/indexes.py +3 -36
  9. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/meta.py +0 -7
  10. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/options.py +1 -6
  11. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/preflight.py +15 -37
  12. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/schema.py +4 -75
  13. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/types.pyi +0 -2
  14. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/pyproject.toml +1 -1
  15. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/.gitignore +0 -0
  16. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/CLAUDE.md +0 -0
  17. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/LICENSE +0 -0
  18. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/README.md +0 -0
  19. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/README.md +0 -0
  20. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/__init__.py +0 -0
  21. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  22. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md +0 -0
  23. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/aggregates.py +0 -0
  24. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/backups/__init__.py +0 -0
  25. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/backups/cli.py +0 -0
  26. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/backups/clients.py +0 -0
  27. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/backups/core.py +0 -0
  28. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/base.py +0 -0
  29. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/cli/__init__.py +0 -0
  30. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/cli/diagnose.py +0 -0
  31. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/cli/migrations.py +0 -0
  32. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/config.py +0 -0
  33. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/connection.py +0 -0
  34. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/connections.py +0 -0
  35. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/constants.py +0 -0
  36. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/constraints.py +0 -0
  37. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/database_url.py +0 -0
  38. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/db.py +0 -0
  39. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/default_settings.py +0 -0
  40. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/deletion.py +0 -0
  41. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/diagnose/__init__.py +0 -0
  42. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/diagnose/context.py +0 -0
  43. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/diagnose/tables.py +0 -0
  44. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/diagnose/types.py +0 -0
  45. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/entrypoints.py +0 -0
  46. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/enums.py +0 -0
  47. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/exceptions.py +0 -0
  48. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/expressions.py +0 -0
  49. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/__init__.py +0 -0
  50. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/encrypted.py +0 -0
  51. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/json.py +0 -0
  52. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/mixins.py +0 -0
  53. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/related_descriptors.py +0 -0
  54. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/related_lookups.py +0 -0
  55. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/related_managers.py +0 -0
  56. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/reverse_descriptors.py +0 -0
  57. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/reverse_related.py +0 -0
  58. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/fields/timezones.py +0 -0
  59. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/forms.py +0 -0
  60. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/__init__.py +0 -0
  61. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/comparison.py +0 -0
  62. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/datetime.py +0 -0
  63. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/math.py +0 -0
  64. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/mixins.py +0 -0
  65. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/text.py +0 -0
  66. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/functions/window.py +0 -0
  67. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/lookups.py +0 -0
  68. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/__init__.py +0 -0
  69. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/autodetector.py +0 -0
  70. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/exceptions.py +0 -0
  71. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/executor.py +0 -0
  72. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/graph.py +0 -0
  73. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/loader.py +0 -0
  74. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/migration.py +0 -0
  75. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/operations/__init__.py +0 -0
  76. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/operations/base.py +0 -0
  77. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/operations/fields.py +0 -0
  78. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/operations/models.py +0 -0
  79. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/operations/special.py +0 -0
  80. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/optimizer.py +0 -0
  81. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/questioner.py +0 -0
  82. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/recorder.py +0 -0
  83. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/serializer.py +0 -0
  84. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/state.py +0 -0
  85. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/utils.py +0 -0
  86. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/migrations/writer.py +0 -0
  87. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/otel.py +0 -0
  88. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/query.py +0 -0
  89. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/query_utils.py +0 -0
  90. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/registry.py +0 -0
  91. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/sql/__init__.py +0 -0
  92. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/sql/compiler.py +0 -0
  93. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/sql/constants.py +0 -0
  94. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/sql/datastructures.py +0 -0
  95. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/sql/query.py +0 -0
  96. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/sql/where.py +0 -0
  97. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/test/__init__.py +0 -0
  98. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/test/pytest.py +0 -0
  99. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/test/utils.py +0 -0
  100. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/transaction.py +0 -0
  101. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/types.py +0 -0
  102. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/plain/postgres/utils.py +0 -0
  103. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/0001_initial.py +0 -0
  104. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  105. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  106. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  107. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  108. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  109. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/migrations/__init__.py +0 -0
  110. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/examples/models.py +0 -0
  111. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/settings.py +0 -0
  112. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/app/urls.py +0 -0
  113. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_connection_isolation.py +0 -0
  114. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_connection_lifecycle.py +0 -0
  115. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_database_url.py +0 -0
  116. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_delete_behaviors.py +0 -0
  117. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_encrypted_fields.py +0 -0
  118. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_exceptions.py +0 -0
  119. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_iterator.py +0 -0
  120. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_manager_assignment.py +0 -0
  121. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_models.py +0 -0
  122. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_read_only_transactions.py +0 -0
  123. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_related_descriptors.py +0 -0
  124. {plain_postgres-0.88.2 → plain_postgres-0.89.1}/tests/test_related_manager_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.88.2
3
+ Version: 0.89.1
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,63 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.89.1](https://github.com/dropseed/plain/releases/plain-postgres@0.89.1) (2026-03-26)
4
+
5
+ ### What's changed
6
+
7
+ - Fixed `schema` command type mismatches for `time`, `timestamp`, and `DecimalField` types that caused false drift reports ([187e39e3faeb](https://github.com/dropseed/plain/commit/187e39e3faeb))
8
+ - Fixed `schema` command crash on expression-based unique constraints (e.g. `UniqueConstraint` with `expressions` instead of `fields`) ([187e39e3faeb](https://github.com/dropseed/plain/commit/187e39e3faeb))
9
+ - Improved 0.89.0 upgrade instructions with clearer ordering and step descriptions ([a59062327ed5](https://github.com/dropseed/plain/commit/a59062327ed5), [c0520bdca709](https://github.com/dropseed/plain/commit/c0520bdca709))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - No changes required.
14
+
15
+ ## [0.89.0](https://github.com/dropseed/plain/releases/plain-postgres@0.89.0) (2026-03-25)
16
+
17
+ ### What's changed
18
+
19
+ - **Removed `db_index` from `ForeignKeyField`** — FK fields no longer create indexes automatically. Declare an explicit `Index(fields=["field"], name="...")` for any FK column that needs one. The `db_index` parameter has been removed entirely. ([061b97f5d538](https://github.com/dropseed/plain/commit/061b97f5d538))
20
+ - **Removed `Index.set_name_with_model()`** — the hash-based auto-naming machinery is gone. `Index.name` is now validated as non-empty at construction time. ([9a4ecf8ac2f0](https://github.com/dropseed/plain/commit/9a4ecf8ac2f0))
21
+ - **Index/constraint name collision detection** — preflight now checks index and constraint names together (they share the same Postgres namespace), catching cross-type collisions that would fail at migrate time. ([292f8d6791d6](https://github.com/dropseed/plain/commit/292f8d6791d6))
22
+ - **New `plain postgres schema` command** — shows expected DB schema from model definitions and compares it against the actual database. Detects column type mismatches, nullability drift, missing/extra columns, and orphan indexes. Use `--check` for CI (exits non-zero on drift). ([ee336078483f](https://github.com/dropseed/plain/commit/ee336078483f))
23
+
24
+ ### Upgrade instructions
25
+
26
+ 1. Remove any `db_index=False` from FK fields in models and migration files — the parameter no longer exists.
27
+
28
+ 2. For each `ForeignKeyField`, check if it's covered by an explicit `Index` or `UniqueConstraint` (with the FK as the leading field). Most FK columns should have an index.
29
+
30
+ 3. **If uncovered**, add an explicit index:
31
+
32
+ ```python
33
+ model_options = postgres.Options(
34
+ indexes=[
35
+ postgres.Index(name="myapp_mymodel_author_id_idx", fields=["author"]),
36
+ ],
37
+ )
38
+ ```
39
+
40
+ 4. Run `makemigrations`. Before the `AddIndex` operation, add a `RunSQL` to drop the orphan auto-index left behind by the old `db_index=True` default:
41
+
42
+ ```python
43
+ operations = [
44
+ migrations.RunSQL('DROP INDEX IF EXISTS "myapp_mymodel_author_id_abc12345"'),
45
+ migrations.AddIndex(...),
46
+ ]
47
+ ```
48
+
49
+ The old auto-index name follows the pattern `{table}_{column}_{hash}`. Find orphan names by running `plain postgres schema`.
50
+
51
+ 5. **If already covered** by a composite index or unique constraint, the orphan auto-index is redundant. Generate a migration to drop it:
52
+
53
+ ```python
54
+ operations = [
55
+ migrations.RunSQL('DROP INDEX IF EXISTS "myapp_mymodel_author_id_abc12345"'),
56
+ ]
57
+ ```
58
+
59
+ 6. Run `migrate`.
60
+
3
61
  ## [0.88.2](https://github.com/dropseed/plain/releases/plain-postgres@0.88.2) (2026-03-25)
4
62
 
5
63
  ### What's changed
@@ -15,6 +15,7 @@ from ..db import get_connection
15
15
  from ..dialect import quote_name
16
16
  from ..migrations.recorder import MIGRATION_TABLE_NAME
17
17
  from .diagnose import diagnose
18
+ from .schema import schema
18
19
 
19
20
 
20
21
  @register_cli("postgres")
@@ -25,6 +26,7 @@ def cli() -> None:
25
26
 
26
27
  cli.add_command(backups_cli)
27
28
  cli.add_command(diagnose)
29
+ cli.add_command(schema)
28
30
 
29
31
 
30
32
  @cli.command()
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from ..db import get_connection
10
+ from ..registry import models_registry
11
+
12
+ # Map from db_type() short names to format_type() canonical names.
13
+ # Postgres stores these as aliases; format_type() returns the long form.
14
+ _TYPE_ALIASES: dict[str, str] = {
15
+ "bool": "boolean",
16
+ "varchar": "character varying",
17
+ "int2": "smallint",
18
+ "int4": "integer",
19
+ "int8": "bigint",
20
+ "float4": "real",
21
+ "float8": "double precision",
22
+ "time": "time without time zone",
23
+ "timestamp": "timestamp without time zone",
24
+ "timestamptz": "timestamp with time zone",
25
+ "timetz": "time with time zone",
26
+ "serial": "integer",
27
+ "bigserial": "bigint",
28
+ "smallserial": "smallint",
29
+ }
30
+
31
+ _TYPE_PARTS = re.compile(r"^(\w+)(.*)")
32
+
33
+
34
+ def _normalize_type(db_type: str) -> str:
35
+ """Normalize a db_type() string to match Postgres format_type() output."""
36
+ m = _TYPE_PARTS.match(db_type)
37
+ if not m:
38
+ return db_type
39
+ base, suffix = m.group(1), m.group(2)
40
+ canonical = _TYPE_ALIASES.get(base, base)
41
+ return canonical + suffix
42
+
43
+
44
+ def _ok() -> None:
45
+ click.secho(" ✓", fg="green", dim=True)
46
+
47
+
48
+ def _err(msg: str) -> None:
49
+ click.secho(f" ✗ {msg}", fg="red")
50
+
51
+
52
+ def _get_actual_columns(cursor: Any, table_name: str) -> dict[str, tuple[str, bool]]:
53
+ """Return {column_name: (type_string, is_not_null)} from the actual DB."""
54
+ cursor.execute(
55
+ """
56
+ SELECT a.attname, format_type(a.atttypid, a.atttypmod), a.attnotnull
57
+ FROM pg_attribute a
58
+ JOIN pg_class c ON a.attrelid = c.oid
59
+ WHERE c.relname = %s AND pg_catalog.pg_table_is_visible(c.oid)
60
+ AND a.attnum > 0 AND NOT a.attisdropped
61
+ ORDER BY a.attnum
62
+ """,
63
+ [table_name],
64
+ )
65
+ return {
66
+ name: (type_str, is_not_null)
67
+ for name, type_str, is_not_null in cursor.fetchall()
68
+ }
69
+
70
+
71
+ def _show_model(conn: Any, cursor: Any, model: Any) -> int:
72
+ """Print schema for a model, annotating any drift. Returns issue count."""
73
+ from ..constraints import UniqueConstraint
74
+
75
+ table_name = model.model_options.db_table
76
+ issues = 0
77
+
78
+ actual_columns = _get_actual_columns(cursor, table_name)
79
+ actual_constraints = conn.get_constraints(cursor, table_name)
80
+
81
+ actual_indexes: dict[str, dict[str, Any]] = {}
82
+ actual_unique: dict[str, dict[str, Any]] = {}
83
+ for name, info in actual_constraints.items():
84
+ if info.get("primary_key"):
85
+ continue
86
+ if info.get("index"):
87
+ actual_indexes[name] = info
88
+ elif info.get("unique"):
89
+ actual_unique[name] = info
90
+
91
+ click.secho(model.model_options.label, bold=True, nl=False)
92
+ click.secho(f" → {table_name}", dim=True)
93
+
94
+ if not actual_columns:
95
+ click.echo(" ", nl=False)
96
+ _err("table missing from database")
97
+ return 1
98
+
99
+ # Columns
100
+ expected_col_names: set[str] = set()
101
+
102
+ for field in model._model_meta.local_fields:
103
+ db_type = field.db_type()
104
+ if db_type is None:
105
+ continue
106
+
107
+ expected_col_names.add(field.column)
108
+ expected_type = _normalize_type(db_type)
109
+
110
+ col_display = field.column
111
+ if field.column != field.name:
112
+ col_display = f"{field.name} → {field.column}"
113
+
114
+ type_parts = [click.style(db_type, fg="cyan")]
115
+ if field.allow_null:
116
+ type_parts.append(click.style("NULL", dim=True))
117
+ if field.primary_key:
118
+ type_parts.append(click.style("PK", fg="yellow"))
119
+ suffix = field.db_type_suffix()
120
+ if suffix:
121
+ type_parts.append(click.style(suffix, dim=True))
122
+
123
+ click.echo(f" {col_display:30s} {' '.join(type_parts)}", nl=False)
124
+
125
+ if field.column not in actual_columns:
126
+ _err("missing from database")
127
+ issues += 1
128
+ else:
129
+ actual_type, actual_not_null = actual_columns[field.column]
130
+ col_issues = []
131
+ if expected_type != actual_type:
132
+ col_issues.append(
133
+ f"type: expected {expected_type}, actual {actual_type}"
134
+ )
135
+ if (not field.allow_null) != actual_not_null:
136
+ exp = "NOT NULL" if not field.allow_null else "NULL"
137
+ act = "NOT NULL" if actual_not_null else "NULL"
138
+ col_issues.append(f"expected {exp}, actual {act}")
139
+ if col_issues:
140
+ _err("; ".join(col_issues))
141
+ issues += len(col_issues)
142
+ else:
143
+ _ok()
144
+
145
+ for col_name in sorted(actual_columns.keys() - expected_col_names):
146
+ click.echo(f" {col_name:30s} ", nl=False)
147
+ _err("extra column, not in model")
148
+ issues += 1
149
+
150
+ # Indexes
151
+ model_indexes = model.model_options.indexes
152
+ extra_indexes = actual_indexes.keys() - {idx.name for idx in model_indexes}
153
+
154
+ if model_indexes or extra_indexes:
155
+ click.echo()
156
+ click.secho(" Indexes:", dim=True)
157
+
158
+ for index in model_indexes:
159
+ fields_str = ", ".join(index.fields) if index.fields else "expressions"
160
+ click.echo(f" {index.name} ({fields_str})", nl=False)
161
+
162
+ if index.name not in actual_indexes:
163
+ _err("missing from database")
164
+ issues += 1
165
+ else:
166
+ _ok()
167
+
168
+ for name in sorted(extra_indexes):
169
+ cols = actual_indexes[name].get("columns", [])
170
+ cols_str = ", ".join(cols) if cols else "expression"
171
+ click.echo(f" {name} ({cols_str})", nl=False)
172
+ _err("not in model")
173
+ issues += 1
174
+
175
+ # Unique constraints
176
+ model_constraints = [
177
+ c for c in model.model_options.constraints if isinstance(c, UniqueConstraint)
178
+ ]
179
+ extra_constraints = actual_unique.keys() - {c.name for c in model_constraints}
180
+
181
+ if model_constraints or extra_constraints:
182
+ click.echo()
183
+ click.secho(" Constraints:", dim=True)
184
+
185
+ for constraint in model_constraints:
186
+ if constraint.fields:
187
+ fields_str = ", ".join(constraint.fields)
188
+ else:
189
+ fields_str = "expressions"
190
+ click.echo(f" {constraint.name} UNIQUE ({fields_str})", nl=False)
191
+
192
+ if constraint.name not in actual_unique:
193
+ _err("missing from database")
194
+ issues += 1
195
+ else:
196
+ _ok()
197
+
198
+ for name in sorted(extra_constraints):
199
+ cols = actual_unique[name].get("columns", [])
200
+ cols_str = ", ".join(cols) if cols else "?"
201
+ click.echo(f" {name} UNIQUE ({cols_str})", nl=False)
202
+ _err("not in model")
203
+ issues += 1
204
+
205
+ return issues
206
+
207
+
208
+ @click.command()
209
+ @click.argument("model_label", required=False)
210
+ def schema(model_label: str | None) -> None:
211
+ """Show database schema from models, compared against the actual database"""
212
+ models = models_registry.get_models()
213
+
214
+ if model_label:
215
+ model_label_lower = model_label.lower()
216
+ models = [
217
+ m
218
+ for m in models
219
+ if m.model_options.label_lower == model_label_lower
220
+ or m.model_options.db_table == model_label
221
+ or m.__name__.lower() == model_label_lower
222
+ ]
223
+ if not models:
224
+ raise click.ClickException(f"No model found matching '{model_label}'")
225
+
226
+ conn = get_connection()
227
+ total_issues = 0
228
+ models_checked = 0
229
+
230
+ with conn.cursor() as cursor:
231
+ for i, model in enumerate(models):
232
+ if i > 0:
233
+ click.echo()
234
+ models_checked += 1
235
+ total_issues += _show_model(conn, cursor, model)
236
+
237
+ click.echo()
238
+ if total_issues == 0:
239
+ click.secho(
240
+ f"{models_checked} models, all match the database.",
241
+ fg="green",
242
+ )
243
+ else:
244
+ click.secho(
245
+ f"{models_checked} models, {total_issues} issue{'s' if total_issues != 1 else ''}.",
246
+ fg="red",
247
+ )
248
+ sys.exit(1)
@@ -101,24 +101,6 @@ def check_duplicate_indexes(
101
101
  """)
102
102
  rows = cursor.fetchall()
103
103
 
104
- # Identify FK columns: (table_name, column_number) -> column_name
105
- cursor.execute("""
106
- SELECT
107
- ct.relname AS table_name,
108
- c.conkey[1] AS col_number,
109
- a.attname AS column_name
110
- FROM pg_catalog.pg_constraint c
111
- JOIN pg_catalog.pg_class ct ON ct.oid = c.conrelid
112
- JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace
113
- JOIN pg_catalog.pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
114
- WHERE c.contype = 'f'
115
- AND array_length(c.conkey, 1) = 1
116
- AND n.nspname = 'public'
117
- """)
118
- fk_columns: dict[tuple[str, int], str] = {
119
- (row[0], row[1]): row[2] for row in cursor.fetchall()
120
- }
121
-
122
104
  # Group by table
123
105
  by_table: dict[str, list[tuple[str, list[int], list[int], bool, str, int]]] = {}
124
106
  for table_name, index_name, cols, opclasses, is_unique, size, size_bytes in rows:
@@ -142,18 +124,8 @@ def check_duplicate_indexes(
142
124
  and ops_l[: len(cols_s)] == ops_s
143
125
  and not unique_s # unique indexes serve a constraint purpose
144
126
  ):
145
- # Single-column index on a FK column = auto-generated FK index
146
- fk_field = (
147
- fk_columns.get((table_name, cols_s[0]))
148
- if len(cols_s) == 1
149
- else None
150
- )
151
-
152
127
  source, package = _table_source(table_name, table_owners)
153
- if fk_field:
154
- app_suggestion = f'Set db_index=False on the "{fk_field}" FK field, then run makemigrations'
155
- else:
156
- app_suggestion = f'Remove "{name_s}" from model constraints, then run makemigrations'
128
+ app_suggestion = f'Remove "{name_s}" from model indexes/constraints, then run makemigrations'
157
129
 
158
130
  items.append(
159
131
  CheckItem(
@@ -245,7 +217,7 @@ def check_unused_indexes(
245
217
  suggestion=_index_suggestion(
246
218
  source=source,
247
219
  package=package,
248
- app_suggestion=f'Remove "{index_name}" from model constraints, then run makemigrations',
220
+ app_suggestion=f'Remove "{index_name}" from model indexes/constraints, then run makemigrations',
249
221
  unmanaged_suggestion=f'DROP INDEX CONCURRENTLY "{index_name}";',
250
222
  ),
251
223
  )
@@ -109,7 +109,7 @@ DATA_TYPES: dict[str, Any] = {
109
109
  "CharField": _get_varchar_column,
110
110
  "DateField": "date",
111
111
  "DateTimeField": "timestamp with time zone",
112
- "DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)",
112
+ "DecimalField": "numeric(%(max_digits)s,%(decimal_places)s)",
113
113
  "DurationField": "interval",
114
114
  "FloatField": "double precision",
115
115
  "IntegerField": "integer",
@@ -339,7 +339,6 @@ class ForeignKeyField(RelatedField):
339
339
  on_delete: Any,
340
340
  related_query_name: str | None = None,
341
341
  limit_choices_to: Any = None,
342
- db_index: bool = True,
343
342
  db_constraint: bool = True,
344
343
  **kwargs: Any,
345
344
  ):
@@ -367,7 +366,6 @@ class ForeignKeyField(RelatedField):
367
366
  limit_choices_to=limit_choices_to,
368
367
  **kwargs,
369
368
  )
370
- self.db_index = db_index
371
369
  self.db_constraint = db_constraint
372
370
 
373
371
  def __copy__(self) -> ForeignKeyField:
@@ -556,9 +554,6 @@ class ForeignKeyField(RelatedField):
556
554
  else:
557
555
  kwargs["to"] = self.remote_field.model.model_options.label_lower
558
556
 
559
- if self.db_index is not True:
560
- kwargs["db_index"] = self.db_index
561
-
562
557
  if self.db_constraint is not True:
563
558
  kwargs["db_constraint"] = self.db_constraint
564
559
 
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, Self
6
6
  from plain.postgres.expressions import Col, ExpressionList, F, Func, OrderBy
7
7
  from plain.postgres.query_utils import Q
8
8
  from plain.postgres.sql.query import Query
9
- from plain.postgres.utils import names_digest, split_identifier
10
9
  from plain.utils.functional import partition
11
10
 
12
11
  if TYPE_CHECKING:
@@ -65,7 +64,9 @@ class Index:
65
64
  (field_name.removeprefix("-"), "DESC" if field_name.startswith("-") else "")
66
65
  for field_name in self.fields
67
66
  ]
68
- self.name = name or ""
67
+ if not name:
68
+ raise ValueError("Index.name is required.")
69
+ self.name = name
69
70
  self.opclasses: tuple[str, ...] = tuple(opclasses)
70
71
  self.condition = condition
71
72
  self.include = tuple(include) if include else ()
@@ -151,40 +152,6 @@ class Index:
151
152
  _, args, kwargs = self.deconstruct()
152
153
  return self.__class__(*args, **kwargs)
153
154
 
154
- def set_name_with_model(self, model: type[Model]) -> None:
155
- """
156
- Generate a unique name for the index.
157
-
158
- The name is divided into 3 parts - table name (12 chars), field name
159
- (8 chars) and unique hash + suffix (10 chars). Each part is made to
160
- fit its size by truncating the excess length.
161
- """
162
- _, table_name = split_identifier(model.model_options.db_table)
163
- column_names = [
164
- model._model_meta.get_forward_field(field_name).column
165
- for field_name, order in self.fields_orders
166
- ]
167
- column_names_with_order = [
168
- (("-%s" if order else "%s") % column_name)
169
- for column_name, (field_name, order) in zip(
170
- column_names, self.fields_orders
171
- )
172
- ]
173
- # The length of the parts of the name is based on the default max
174
- # length of 30 characters.
175
- hash_data = [table_name] + column_names_with_order + [self.suffix]
176
- self.name = "{}_{}_{}".format(
177
- table_name[:11],
178
- column_names[0][:7],
179
- f"{names_digest(*hash_data, length=6)}_{self.suffix}",
180
- )
181
- if len(self.name) > self.max_name_length:
182
- raise ValueError(
183
- "Index name too long. Is self.suffix longer than 3 characters?"
184
- )
185
- if self.name[0] == "_" or self.name[0].isdigit():
186
- self.name = f"D{self.name[1:]}"
187
-
188
155
  def __repr__(self) -> str:
189
156
  return "<{}:{}{}{}{}{}{}>".format(
190
157
  self.__class__.__qualname__,
@@ -132,13 +132,6 @@ class Meta:
132
132
  instance.local_fields.sort(key=lambda f: (not f.primary_key, f.name))
133
133
  instance.local_many_to_many.sort(key=lambda f: f.name)
134
134
 
135
- # Set index names now that fields are contributed
136
- # Trigger model_options descriptor to ensure it's initialized
137
- # (accessing it will cache the instance)
138
- for index in model.model_options.indexes:
139
- if not index.name:
140
- index.set_name_with_model(model)
141
-
142
135
  return instance
143
136
 
144
137
  @property
@@ -168,12 +168,7 @@ class Options:
168
168
  options = {}
169
169
  for name in self._provided_options:
170
170
  if name == "indexes":
171
- # Clone indexes and ensure names are set
172
- indexes = [idx.clone() for idx in self.indexes]
173
- for index in indexes:
174
- if not index.name:
175
- index.set_name_with_model(self.model)
176
- options["indexes"] = indexes
171
+ options["indexes"] = [idx.clone() for idx in self.indexes]
177
172
  elif name == "constraints":
178
173
  # Clone constraints
179
174
  options["constraints"] = [con.clone() for con in self.constraints]
@@ -38,14 +38,6 @@ def _collect_model_indexes(model: Any) -> list[tuple[str, list[str], bool]]:
38
38
  if isinstance(constraint, UniqueConstraint) and constraint.fields:
39
39
  all_indexes.append((constraint.name, list(constraint.fields), True))
40
40
 
41
- for field in model._model_meta.local_fields:
42
- if (
43
- isinstance(field, ForeignKeyField)
44
- and field.db_index
45
- and not field.primary_key
46
- ):
47
- all_indexes.append((f"{field.name} (auto)", [field.name], False))
48
-
49
41
  return all_indexes
50
42
 
51
43
 
@@ -55,8 +47,9 @@ class CheckAllModels(PreflightCheck):
55
47
 
56
48
  def run(self) -> list[PreflightResult]:
57
49
  db_table_models = defaultdict(list)
58
- indexes = defaultdict(list)
59
- constraints = defaultdict(list)
50
+ # Indexes and constraints share the same Postgres namespace,
51
+ # so track them together to catch cross-type collisions.
52
+ relation_names = defaultdict(list)
60
53
  errors = []
61
54
  models = models_registry.get_models()
62
55
  for model in models:
@@ -74,9 +67,9 @@ class CheckAllModels(PreflightCheck):
74
67
  else:
75
68
  errors.extend(model.preflight())
76
69
  for model_index in model.model_options.indexes:
77
- indexes[model_index.name].append(model.model_options.label)
70
+ relation_names[model_index.name].append(model.model_options.label)
78
71
  for model_constraint in model.model_options.constraints:
79
- constraints[model_constraint.name].append(model.model_options.label)
72
+ relation_names[model_constraint.name].append(model.model_options.label)
80
73
  for db_table, model_labels in db_table_models.items():
81
74
  if len(model_labels) != 1:
82
75
  model_labels_str = ", ".join(model_labels)
@@ -87,34 +80,20 @@ class CheckAllModels(PreflightCheck):
87
80
  id="postgres.duplicate_db_table",
88
81
  )
89
82
  )
90
- for index_name, model_labels in indexes.items():
91
- if len(model_labels) > 1:
92
- model_labels = set(model_labels)
93
- errors.append(
94
- PreflightResult(
95
- fix="index name '{}' is not unique {} {}.".format(
96
- index_name,
97
- "for model" if len(model_labels) == 1 else "among models:",
98
- ", ".join(sorted(model_labels)),
99
- ),
100
- id="postgres.index_name_not_unique_single"
101
- if len(model_labels) == 1
102
- else "postgres.index_name_not_unique_multiple",
103
- ),
104
- )
105
- for constraint_name, model_labels in constraints.items():
83
+ for relation_name, model_labels in relation_names.items():
106
84
  if len(model_labels) > 1:
107
- model_labels = set(model_labels)
85
+ unique_models = set(model_labels)
86
+ single_model = len(unique_models) == 1
108
87
  errors.append(
109
88
  PreflightResult(
110
- fix="constraint name '{}' is not unique {} {}.".format(
111
- constraint_name,
112
- "for model" if len(model_labels) == 1 else "among models:",
113
- ", ".join(sorted(model_labels)),
89
+ fix="index/constraint name '{}' is not unique {} {}.".format(
90
+ relation_name,
91
+ "for model" if single_model else "among models:",
92
+ ", ".join(sorted(unique_models)),
114
93
  ),
115
- id="postgres.constraint_name_not_unique_single"
116
- if len(model_labels) == 1
117
- else "postgres.constraint_name_not_unique_multiple",
94
+ id="postgres.relation_name_not_unique_single"
95
+ if single_model
96
+ else "postgres.relation_name_not_unique_multiple",
118
97
  ),
119
98
  )
120
99
  return errors
@@ -391,7 +370,6 @@ class CheckMissingFKIndexes(PreflightCheck):
391
370
  isinstance(field, ForeignKeyField)
392
371
  and not field.primary_key
393
372
  and field.name not in covered_fields
394
- and not field.db_index
395
373
  ):
396
374
  results.append(
397
375
  PreflightResult(
@@ -951,41 +951,6 @@ class DatabaseSchemaEditor:
951
951
  self.sql_delete_fk, new_rel.related_model, fk_name
952
952
  )
953
953
  )
954
- # Removed an index? (no strict check, as multiple indexes are possible)
955
- # Remove indexes if db_index switched to False or a unique constraint
956
- # will now be used in lieu of an index. The following lines from the
957
- # truth table show all True cases; the rest are False:
958
- #
959
- # old_field.db_index | old_field.primary_key | new_field.db_index | new_field.primary_key
960
- # ------------------------------------------------------------------------------
961
- # True | False | False | False
962
- # True | False | False | True
963
- # True | False | True | True
964
- if (
965
- isinstance(old_field, ForeignKeyField)
966
- and old_field.db_index
967
- and not old_field.primary_key
968
- and (
969
- not (isinstance(new_field, ForeignKeyField) and new_field.db_index)
970
- or new_field.primary_key
971
- )
972
- ):
973
- # Find the index for this field
974
- meta_index_names = {index.name for index in model.model_options.indexes}
975
- # Retrieve only BTREE indexes since this is what's created with
976
- # db_index=True.
977
- index_names = self._constraint_names(
978
- model,
979
- [old_field.column],
980
- index=True,
981
- type_=Index.suffix,
982
- exclude=meta_index_names,
983
- )
984
- for index_name in index_names:
985
- # The only way to check if an index was created with
986
- # db_index=True or with Index(['field'], name='foo')
987
- # is to look at its name (refs #28053).
988
- self.execute(self._delete_index_sql(model, index_name))
989
954
  # Change check constraints?
990
955
  old_db_check = self._field_db_check(old_field, old_db_params)
991
956
  new_db_check = self._field_db_check(new_field, new_db_params)
@@ -1109,25 +1074,6 @@ class DatabaseSchemaEditor:
1109
1074
  if old_field.primary_key and not new_field.primary_key:
1110
1075
  self._delete_primary_key(model, strict)
1111
1076
 
1112
- # Added an index? Add an index if db_index switched to True or a unique
1113
- # constraint will no longer be used in lieu of an index. The following
1114
- # lines from the truth table show all True cases; the rest are False:
1115
- #
1116
- # old_field.db_index | old_field.primary_key | new_field.db_index | new_field.primary_key
1117
- # ------------------------------------------------------------------------------
1118
- # False | False | True | False
1119
- # False | True | True | False
1120
- # True | True | True | False
1121
- if (
1122
- (
1123
- not (isinstance(old_field, ForeignKeyField) and old_field.db_index)
1124
- or old_field.primary_key
1125
- )
1126
- and isinstance(new_field, ForeignKeyField)
1127
- and new_field.db_index
1128
- and not new_field.primary_key
1129
- ):
1130
- self.execute(self._create_index_sql(model, fields=[new_field]))
1131
1077
  # Type alteration on primary key? Then we need to alter the column
1132
1078
  # referring to us.
1133
1079
  rels_to_update = []
@@ -1201,24 +1147,14 @@ class DatabaseSchemaEditor:
1201
1147
  }
1202
1148
  self.execute(sql, params)
1203
1149
 
1204
- # Added an index? Create any PostgreSQL-specific indexes.
1205
- if (
1206
- not (
1207
- (isinstance(old_field, ForeignKeyField) and old_field.db_index)
1208
- or old_field.primary_key
1209
- )
1210
- and isinstance(new_field, ForeignKeyField)
1211
- and new_field.db_index
1212
- ) or (not old_field.primary_key and new_field.primary_key):
1150
+ # Added a primary key? Create any PostgreSQL-specific indexes.
1151
+ if not old_field.primary_key and new_field.primary_key:
1213
1152
  like_index_statement = self._create_like_index_sql(model, new_field)
1214
1153
  if like_index_statement is not None:
1215
1154
  self.execute(like_index_statement)
1216
1155
 
1217
- # Removed an index? Drop any PostgreSQL-specific indexes.
1218
- if old_field.primary_key and not (
1219
- (isinstance(new_field, ForeignKeyField) and new_field.db_index)
1220
- or new_field.primary_key
1221
- ):
1156
+ # Removed a primary key? Drop any PostgreSQL-specific indexes.
1157
+ if old_field.primary_key and not new_field.primary_key:
1222
1158
  index_to_remove = self._create_index_name(
1223
1159
  model.model_options.db_table, [old_field.column], suffix="_like"
1224
1160
  )
@@ -1570,8 +1506,6 @@ class DatabaseSchemaEditor:
1570
1506
  Return a list of all index SQL statements for the specified field.
1571
1507
  """
1572
1508
  output: list[Statement] = []
1573
- if self._field_should_be_indexed(model, field):
1574
- output.append(self._create_index_sql(model, fields=[field]))
1575
1509
  # Add LIKE index for varchar/text primary keys
1576
1510
  like_index_statement = self._create_like_index_sql(model, field)
1577
1511
  if like_index_statement is not None:
@@ -1632,11 +1566,6 @@ class DatabaseSchemaEditor:
1632
1566
  old_kwargs,
1633
1567
  ) != (new_path, new_args, new_kwargs)
1634
1568
 
1635
- def _field_should_be_indexed(self, model: type[Model], field: Field) -> bool:
1636
- if isinstance(field, ForeignKeyField):
1637
- return bool(field.remote_field) and field.db_index and not field.primary_key
1638
- return False
1639
-
1640
1569
  def _field_became_primary_key(self, old_field: Field, new_field: Field) -> bool:
1641
1570
  return not old_field.primary_key and new_field.primary_key
1642
1571
 
@@ -628,7 +628,6 @@ def ForeignKeyField[T: Model](
628
628
  *,
629
629
  related_query_name: str | None = None,
630
630
  limit_choices_to: Any = None,
631
- db_index: bool = True,
632
631
  db_constraint: bool = True,
633
632
  max_length: int | None = None,
634
633
  required: bool = True,
@@ -645,7 +644,6 @@ def ForeignKeyField[T: Model](
645
644
  *,
646
645
  related_query_name: str | None = None,
647
646
  limit_choices_to: Any = None,
648
- db_index: bool = True,
649
647
  db_constraint: bool = True,
650
648
  max_length: int | None = None,
651
649
  required: bool = True,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.88.2"
3
+ version = "0.89.1"
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"
File without changes