plain.postgres 0.97.0__tar.gz → 0.98.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.97.0/plain/postgres/README.md → plain_postgres-0.98.0/PKG-INFO +62 -25
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/CHANGELOG.md +48 -0
- plain_postgres-0.97.0/PKG-INFO → plain_postgres-0.98.0/plain/postgres/README.md +48 -37
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/__init__.py +3 -0
- plain_postgres-0.98.0/plain/postgres/adapters.py +41 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/core.py +16 -7
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/config.py +4 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/connection.py +39 -303
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/database_url.py +36 -9
- plain_postgres-0.98.0/plain/postgres/db.py +158 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/default_settings.py +7 -3
- plain_postgres-0.98.0/plain/postgres/middleware.py +37 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/otel.py +192 -11
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/preflight.py +31 -1
- plain_postgres-0.98.0/plain/postgres/sources.py +221 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/test/database.py +47 -24
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/test/pytest.py +26 -19
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/transaction.py +8 -12
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/utils.py +22 -8
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/pyproject.toml +3 -3
- plain_postgres-0.98.0/tests/conftest.py +11 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_connection_isolation.py +16 -16
- plain_postgres-0.98.0/tests/test_connection_lifecycle.py +383 -0
- plain_postgres-0.98.0/tests/test_connection_pool.py +162 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_timeouts.py +2 -1
- plain_postgres-0.98.0/tests/test_executor_connection_hook.py +103 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_management_connection.py +3 -3
- plain_postgres-0.98.0/tests/test_otel_metrics.py +243 -0
- plain_postgres-0.98.0/tests/test_read_only_transactions.py +87 -0
- plain_postgres-0.97.0/plain/postgres/connections.py +0 -126
- plain_postgres-0.97.0/plain/postgres/db.py +0 -38
- plain_postgres-0.97.0/tests/test_connection_lifecycle.py +0 -354
- plain_postgres-0.97.0/tests/test_read_only_transactions.py +0 -116
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/.gitignore +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/CLAUDE.md +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/LICENSE +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/README.md +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/introspection/health.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_constraints.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_defaults.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_fk.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_field_defaults.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_functions_uuid.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_m2m.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_mixins.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_random_string_field.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_raw_query.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_related.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_schema_timeouts.py +0 -0
|
@@ -1,9 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.98.0
|
|
4
|
+
Summary: Model your data and store it in a database.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.134.0
|
|
10
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
+
Requires-Dist: psycopg>=3.2
|
|
12
|
+
Requires-Dist: sqlparse>=0.3.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# plain.postgres
|
|
2
16
|
|
|
3
17
|
**Model your data and store it in a database.**
|
|
4
18
|
|
|
5
19
|
- [Overview](#overview)
|
|
6
20
|
- [Database connection](#database-connection)
|
|
21
|
+
- [Middleware](#middleware)
|
|
22
|
+
- [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
|
|
7
23
|
- [Querying](#querying)
|
|
8
24
|
- [Schema management](#schema-management)
|
|
9
25
|
- [Syncing](#syncing)
|
|
@@ -99,6 +115,24 @@ To explicitly disable the database (e.g. during Docker builds where no database
|
|
|
99
115
|
PLAIN_POSTGRES_URL=none
|
|
100
116
|
```
|
|
101
117
|
|
|
118
|
+
### Middleware
|
|
119
|
+
|
|
120
|
+
Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# app/settings.py
|
|
124
|
+
MIDDLEWARE = [
|
|
125
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
126
|
+
# ...other middleware
|
|
127
|
+
]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
|
|
131
|
+
|
|
132
|
+
For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database — for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
|
|
133
|
+
|
|
134
|
+
Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
|
|
135
|
+
|
|
102
136
|
### Bypassing a connection pooler for management operations
|
|
103
137
|
|
|
104
138
|
Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
|
|
@@ -128,14 +162,6 @@ with use_management_connection():
|
|
|
128
162
|
|
|
129
163
|
You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
|
|
130
164
|
|
|
131
|
-
**PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
uv add psycopg[binary] # Pre-built wheels, easiest for local development
|
|
135
|
-
# or
|
|
136
|
-
uv add psycopg[c] # Compiled against your system's libpq, recommended for production
|
|
137
|
-
```
|
|
138
|
-
|
|
139
165
|
## Querying
|
|
140
166
|
|
|
141
167
|
Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
|
|
@@ -443,32 +469,34 @@ with transaction.atomic():
|
|
|
443
469
|
safe_operation() # This still runs in the outer transaction
|
|
444
470
|
```
|
|
445
471
|
|
|
446
|
-
### Read-only
|
|
472
|
+
### Read-only transactions
|
|
447
473
|
|
|
448
|
-
|
|
474
|
+
Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
|
|
449
475
|
|
|
450
476
|
```python
|
|
451
|
-
from plain.postgres.
|
|
477
|
+
from plain.postgres.db import read_only
|
|
452
478
|
|
|
453
479
|
with read_only():
|
|
454
480
|
users = User.query.all() # reads work
|
|
455
481
|
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
456
482
|
```
|
|
457
483
|
|
|
458
|
-
|
|
484
|
+
`read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
|
|
459
485
|
|
|
460
|
-
|
|
486
|
+
Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block — doing so raises `TransactionManagementError`.
|
|
461
487
|
|
|
462
|
-
|
|
463
|
-
from plain.postgres.db import get_connection
|
|
488
|
+
Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
|
|
464
489
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
490
|
+
```python
|
|
491
|
+
with read_only():
|
|
492
|
+
try:
|
|
493
|
+
with atomic():
|
|
494
|
+
User.query.create(name="x") # raises, savepoint rolls back
|
|
495
|
+
except psycopg.errors.ReadOnlySqlTransaction:
|
|
496
|
+
pass
|
|
497
|
+
User.query.count() # still works — outer txn is healthy
|
|
468
498
|
```
|
|
469
499
|
|
|
470
|
-
Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
|
|
471
|
-
|
|
472
500
|
## Schema management
|
|
473
501
|
|
|
474
502
|
Schema changes fall into three categories, each with a different author and apply model:
|
|
@@ -1237,8 +1265,10 @@ The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL`
|
|
|
1237
1265
|
| ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
|
|
1238
1266
|
| `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
|
|
1239
1267
|
| `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
|
|
1240
|
-
| `
|
|
1241
|
-
| `
|
|
1268
|
+
| `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
|
|
1269
|
+
| `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
|
|
1270
|
+
| `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
|
|
1271
|
+
| `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
|
|
1242
1272
|
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1243
1273
|
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1244
1274
|
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
@@ -1282,13 +1312,15 @@ Currently, Plain supports a single database connection per application. For appl
|
|
|
1282
1312
|
|
|
1283
1313
|
## Installation
|
|
1284
1314
|
|
|
1285
|
-
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/)
|
|
1315
|
+
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
|
|
1286
1316
|
|
|
1287
1317
|
```bash
|
|
1288
|
-
uv add plain.postgres psycopg[binary]
|
|
1318
|
+
uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
|
|
1319
|
+
# or
|
|
1320
|
+
uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
|
|
1289
1321
|
```
|
|
1290
1322
|
|
|
1291
|
-
Then add to your `INSTALLED_PACKAGES
|
|
1323
|
+
Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
|
|
1292
1324
|
|
|
1293
1325
|
```python
|
|
1294
1326
|
# app/settings.py
|
|
@@ -1296,4 +1328,9 @@ INSTALLED_PACKAGES = [
|
|
|
1296
1328
|
...
|
|
1297
1329
|
"plain.postgres",
|
|
1298
1330
|
]
|
|
1331
|
+
|
|
1332
|
+
MIDDLEWARE = [
|
|
1333
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
1334
|
+
...
|
|
1335
|
+
]
|
|
1299
1336
|
```
|
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.98.0](https://github.com/dropseed/plain/releases/plain-postgres@0.98.0) (2026-04-22)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Pool-backed connections via `psycopg_pool.ConnectionPool`.** A new `sources` abstraction routes `DatabaseConnection` through either a long-lived `PoolSource` (runtime) or a `DirectSource` (management / one-shot). Each request checks a connection out of the pool on first use and returns it when the HTTP request finishes. `psycopg>=3.2` and `psycopg-pool>=3.2` are now declared as hard dependencies. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
8
|
+
- **New `DatabaseConnectionMiddleware` (required).** Add `"plain.postgres.DatabaseConnectionMiddleware"` to `MIDDLEWARE` — it's what returns the pooled connection at the end of each request. For `StreamingResponse` / `AsyncStreamingResponse` the connection is returned after the body fully drains, so generators that lazily query the database (e.g. `Model.query.iterator()`) keep their cursor alive until the last chunk is sent. A new `postgres.middleware_installed` preflight check errors if the middleware is missing. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
9
|
+
- **Connection settings replaced with pool settings.** `POSTGRES_CONN_MAX_AGE` and `POSTGRES_CONN_HEALTH_CHECKS` are gone. Tune the pool with `POSTGRES_POOL_MIN_SIZE` (default `4`), `POSTGRES_POOL_MAX_SIZE` (default `20`), `POSTGRES_POOL_MAX_LIFETIME` seconds (default `3600.0`), and `POSTGRES_POOL_TIMEOUT` seconds (default `30.0`). Each is also available as a `PLAIN_POSTGRES_POOL_*` environment variable. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
10
|
+
- **`plain.postgres.connections` module removed.** `get_connection`, `has_connection`, `use_management_connection`, and `read_only` now live in `plain.postgres.db` (the underscore-less counterpart). ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
11
|
+
- **`read_only()` is now pgbouncer-safe.** It opens a single `BEGIN READ ONLY` transaction for the block (previously a session-level `SET default_transaction_read_only = on`). Nested `atomic()` blocks become savepoints of the outer read-only transaction. Entering `read_only()` inside an existing `atomic()` block now raises `TransactionManagementError`. The old `DatabaseConnection.set_read_only()` method is removed. ([ebdec30](https://github.com/dropseed/plain/commit/ebdec30))
|
|
12
|
+
- **Added OTel pool + rowcount metrics and semconv polish.** Wires the `db.client.connection.*` metric family (count, max, idle.min/max, pending_requests, wait_time, use_time, timeouts) from the pool's stats and the acquire/release path, plus `db.client.response.returned_rows` for SELECT queries including streamed iterators. Query spans now carry `server.address` / `server.port` alongside `network.peer.*`, and the tracer/meter are tagged with the `plain.postgres` package version for `InstrumentationScope`. ([61278d5](https://github.com/dropseed/plain/commit/61278d5))
|
|
13
|
+
- **Moved `psql` CLI orchestration off `DatabaseConnection`.** New `postgres_cli_args` / `postgres_cli_env` helpers in `plain.postgres.database_url` build the arguments and environment for `psql`, `pg_dump`, etc.; `plain postgres shell` and the `plain-dev` backup client both use them. `DatabaseConnection.runshell()` and `executable_name` are gone. ([5b4a488](https://github.com/dropseed/plain/commit/5b4a488))
|
|
14
|
+
- **Removed dead connection-lifecycle plumbing.** `close_if_unusable_or_obsolete`, `close_if_health_check_failed`, `closed_in_transaction`, `is_usable`, `health_check_enabled`, `health_check_done`, `close_at`, `_maintenance_cursor`, and `DatabaseConnection.from_url` are gone — the pool handles recycling, health checks, and URL parsing. `close()` now validates there's no open atomic block instead of silently deferring. ([044e942](https://github.com/dropseed/plain/commit/044e942), [2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
15
|
+
- **Inlined `pg_version` and removed `temporary_connection()`.** The single caller now reads `connection.info.server_version` directly; `temporary_connection()` has no remaining users. ([319f6ac](https://github.com/dropseed/plain/commit/319f6ac))
|
|
16
|
+
- **`APIResult` shorthand returns moved out of `View`.** Any internal views that relied on dict/int shorthand now wrap their returns in `JsonResponse` / `Response(status_code=...)` to match plain 0.134.0's narrower `View` handler return type. ([1935f3f](https://github.com/dropseed/plain/commit/1935f3f))
|
|
17
|
+
- **Adapter registration extracted to `plain.postgres.adapters`.** `PlainRangeDumper` and `get_adapters_template()` moved out of `connection.py` into their own module.
|
|
18
|
+
|
|
19
|
+
### Upgrade instructions
|
|
20
|
+
|
|
21
|
+
- Requires `plain>=0.134.0`.
|
|
22
|
+
- **Add the middleware** to `app/settings.py`:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
MIDDLEWARE = [
|
|
26
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
27
|
+
# ...the rest of your middleware
|
|
28
|
+
]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly. Preflight will error if it's missing.
|
|
32
|
+
|
|
33
|
+
- **Replace `POSTGRES_CONN_MAX_AGE` / `POSTGRES_CONN_HEALTH_CHECKS`** with the pool settings (`POSTGRES_POOL_MIN_SIZE`, `POSTGRES_POOL_MAX_SIZE`, `POSTGRES_POOL_MAX_LIFETIME`, `POSTGRES_POOL_TIMEOUT`) or remove them to take the defaults.
|
|
34
|
+
|
|
35
|
+
- **Update imports from `plain.postgres.connections`** to `plain.postgres.db`:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# Before
|
|
39
|
+
from plain.postgres.connections import get_connection, read_only, use_management_connection
|
|
40
|
+
|
|
41
|
+
# After
|
|
42
|
+
from plain.postgres.db import get_connection, read_only, use_management_connection
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **If you called `DatabaseConnection.set_read_only(True)`** for a sticky read-only session, switch to the `read_only()` context manager around the block you want read-only. If you need session-level enforcement outside a transaction, open a `DirectSource` connection yourself and issue `SET default_transaction_read_only = on` on it.
|
|
46
|
+
|
|
47
|
+
- **If you entered `read_only()` inside an `atomic()` block**, move `read_only()` to the outer position — it now owns the transaction. Nested `atomic()` blocks inside `read_only()` are fine (they become savepoints).
|
|
48
|
+
|
|
49
|
+
- **If you pinned `psycopg` via your own dependency**, make sure it's `>=3.2`, and add `psycopg-pool>=3.2` if you were installing psycopg without extras.
|
|
50
|
+
|
|
3
51
|
## [0.97.0](https://github.com/dropseed/plain/releases/plain-postgres@0.97.0) (2026-04-21)
|
|
4
52
|
|
|
5
53
|
### What's changed
|
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.97.0
|
|
4
|
-
Summary: Model your data and store it in a database.
|
|
5
|
-
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
-
License-Expression: BSD-3-Clause
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.13
|
|
9
|
-
Requires-Dist: plain<1.0.0,>=0.132.0
|
|
10
|
-
Requires-Dist: sqlparse>=0.3.1
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
1
|
# plain.postgres
|
|
14
2
|
|
|
15
3
|
**Model your data and store it in a database.**
|
|
16
4
|
|
|
17
5
|
- [Overview](#overview)
|
|
18
6
|
- [Database connection](#database-connection)
|
|
7
|
+
- [Middleware](#middleware)
|
|
8
|
+
- [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
|
|
19
9
|
- [Querying](#querying)
|
|
20
10
|
- [Schema management](#schema-management)
|
|
21
11
|
- [Syncing](#syncing)
|
|
@@ -111,6 +101,24 @@ To explicitly disable the database (e.g. during Docker builds where no database
|
|
|
111
101
|
PLAIN_POSTGRES_URL=none
|
|
112
102
|
```
|
|
113
103
|
|
|
104
|
+
### Middleware
|
|
105
|
+
|
|
106
|
+
Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# app/settings.py
|
|
110
|
+
MIDDLEWARE = [
|
|
111
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
112
|
+
# ...other middleware
|
|
113
|
+
]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
|
|
117
|
+
|
|
118
|
+
For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database — for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
|
|
119
|
+
|
|
120
|
+
Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
|
|
121
|
+
|
|
114
122
|
### Bypassing a connection pooler for management operations
|
|
115
123
|
|
|
116
124
|
Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
|
|
@@ -140,14 +148,6 @@ with use_management_connection():
|
|
|
140
148
|
|
|
141
149
|
You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
|
|
142
150
|
|
|
143
|
-
**PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
uv add psycopg[binary] # Pre-built wheels, easiest for local development
|
|
147
|
-
# or
|
|
148
|
-
uv add psycopg[c] # Compiled against your system's libpq, recommended for production
|
|
149
|
-
```
|
|
150
|
-
|
|
151
151
|
## Querying
|
|
152
152
|
|
|
153
153
|
Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
|
|
@@ -455,32 +455,34 @@ with transaction.atomic():
|
|
|
455
455
|
safe_operation() # This still runs in the outer transaction
|
|
456
456
|
```
|
|
457
457
|
|
|
458
|
-
### Read-only
|
|
458
|
+
### Read-only transactions
|
|
459
459
|
|
|
460
|
-
|
|
460
|
+
Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
|
|
461
461
|
|
|
462
462
|
```python
|
|
463
|
-
from plain.postgres.
|
|
463
|
+
from plain.postgres.db import read_only
|
|
464
464
|
|
|
465
465
|
with read_only():
|
|
466
466
|
users = User.query.all() # reads work
|
|
467
467
|
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
468
468
|
```
|
|
469
469
|
|
|
470
|
-
|
|
470
|
+
`read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
|
|
471
471
|
|
|
472
|
-
|
|
472
|
+
Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block — doing so raises `TransactionManagementError`.
|
|
473
473
|
|
|
474
|
-
|
|
475
|
-
from plain.postgres.db import get_connection
|
|
474
|
+
Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
|
|
476
475
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
476
|
+
```python
|
|
477
|
+
with read_only():
|
|
478
|
+
try:
|
|
479
|
+
with atomic():
|
|
480
|
+
User.query.create(name="x") # raises, savepoint rolls back
|
|
481
|
+
except psycopg.errors.ReadOnlySqlTransaction:
|
|
482
|
+
pass
|
|
483
|
+
User.query.count() # still works — outer txn is healthy
|
|
480
484
|
```
|
|
481
485
|
|
|
482
|
-
Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
|
|
483
|
-
|
|
484
486
|
## Schema management
|
|
485
487
|
|
|
486
488
|
Schema changes fall into three categories, each with a different author and apply model:
|
|
@@ -1249,8 +1251,10 @@ The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL`
|
|
|
1249
1251
|
| ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
|
|
1250
1252
|
| `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
|
|
1251
1253
|
| `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
|
|
1252
|
-
| `
|
|
1253
|
-
| `
|
|
1254
|
+
| `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
|
|
1255
|
+
| `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
|
|
1256
|
+
| `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
|
|
1257
|
+
| `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
|
|
1254
1258
|
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1255
1259
|
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1256
1260
|
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
@@ -1294,13 +1298,15 @@ Currently, Plain supports a single database connection per application. For appl
|
|
|
1294
1298
|
|
|
1295
1299
|
## Installation
|
|
1296
1300
|
|
|
1297
|
-
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/)
|
|
1301
|
+
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
|
|
1298
1302
|
|
|
1299
1303
|
```bash
|
|
1300
|
-
uv add plain.postgres psycopg[binary]
|
|
1304
|
+
uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
|
|
1305
|
+
# or
|
|
1306
|
+
uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
|
|
1301
1307
|
```
|
|
1302
1308
|
|
|
1303
|
-
Then add to your `INSTALLED_PACKAGES
|
|
1309
|
+
Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
|
|
1304
1310
|
|
|
1305
1311
|
```python
|
|
1306
1312
|
# app/settings.py
|
|
@@ -1308,4 +1314,9 @@ INSTALLED_PACKAGES = [
|
|
|
1308
1314
|
...
|
|
1309
1315
|
"plain.postgres",
|
|
1310
1316
|
]
|
|
1317
|
+
|
|
1318
|
+
MIDDLEWARE = [
|
|
1319
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
1320
|
+
...
|
|
1321
|
+
]
|
|
1311
1322
|
```
|
|
@@ -7,6 +7,7 @@ from . import (
|
|
|
7
7
|
from .base import Model
|
|
8
8
|
from .constraints import CheckConstraint, UniqueConstraint
|
|
9
9
|
from .db import get_connection, use_management_connection
|
|
10
|
+
from .middleware import DatabaseConnectionMiddleware
|
|
10
11
|
from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
|
|
11
12
|
from .expressions import F
|
|
12
13
|
from .enums import TextChoices
|
|
@@ -105,6 +106,8 @@ __all__ = [
|
|
|
105
106
|
# From db
|
|
106
107
|
"get_connection",
|
|
107
108
|
"use_management_connection",
|
|
109
|
+
# From middleware
|
|
110
|
+
"DatabaseConnectionMiddleware",
|
|
108
111
|
# From registry
|
|
109
112
|
"register_model",
|
|
110
113
|
"models_registry",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Psycopg adapter registration for Plain.
|
|
2
|
+
|
|
3
|
+
The `AdaptersMap` returned by `get_adapters_template()` is attached to every
|
|
4
|
+
psycopg connection we open (via `build_connection_params` in `sources.py`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from psycopg import adapt, adapters
|
|
13
|
+
from psycopg.abc import PyFormat
|
|
14
|
+
from psycopg.postgres import types as pg_types
|
|
15
|
+
from psycopg.types.range import BaseRangeDumper, Range, RangeDumper
|
|
16
|
+
from psycopg.types.string import TextLoader
|
|
17
|
+
|
|
18
|
+
TSRANGE_OID = pg_types["tsrange"].oid
|
|
19
|
+
TSTZRANGE_OID = pg_types["tstzrange"].oid
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlainRangeDumper(RangeDumper):
|
|
23
|
+
"""A Range dumper customized for Plain."""
|
|
24
|
+
|
|
25
|
+
def upgrade(self, obj: Range[Any], format: PyFormat) -> BaseRangeDumper:
|
|
26
|
+
dumper = super().upgrade(obj, format)
|
|
27
|
+
if dumper is not self and dumper.oid == TSRANGE_OID:
|
|
28
|
+
dumper.oid = TSTZRANGE_OID
|
|
29
|
+
return dumper
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache
|
|
33
|
+
def get_adapters_template() -> adapt.AdaptersMap:
|
|
34
|
+
ctx = adapt.AdaptersMap(adapters)
|
|
35
|
+
# No-op JSON loader to avoid psycopg3 round trips
|
|
36
|
+
ctx.register_loader("jsonb", TextLoader)
|
|
37
|
+
# Treat inet/cidr as text
|
|
38
|
+
ctx.register_loader("inet", TextLoader)
|
|
39
|
+
ctx.register_loader("cidr", TextLoader)
|
|
40
|
+
ctx.register_dumper(Range, PlainRangeDumper)
|
|
41
|
+
return ctx
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
3
5
|
import subprocess
|
|
4
6
|
import sys
|
|
5
7
|
import time
|
|
@@ -10,6 +12,7 @@ import psycopg
|
|
|
10
12
|
|
|
11
13
|
from plain.cli import register_cli
|
|
12
14
|
|
|
15
|
+
from ..database_url import postgres_cli_args, postgres_cli_env
|
|
13
16
|
from ..db import get_connection
|
|
14
17
|
from ..dialect import quote_name
|
|
15
18
|
from .converge import converge
|
|
@@ -36,16 +39,20 @@ cli.add_command(sync)
|
|
|
36
39
|
@database_management_command
|
|
37
40
|
def shell(parameters: tuple[str, ...]) -> None:
|
|
38
41
|
"""Open an interactive database shell"""
|
|
39
|
-
|
|
42
|
+
config = get_connection().settings_dict
|
|
43
|
+
args = ["psql", *postgres_cli_args(config), *parameters, config["DATABASE"]]
|
|
44
|
+
env = {**os.environ, **postgres_cli_env(config)}
|
|
45
|
+
sigint_handler = signal.getsignal(signal.SIGINT)
|
|
40
46
|
try:
|
|
41
|
-
|
|
47
|
+
# Allow SIGINT to pass to psql to abort queries.
|
|
48
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
49
|
+
subprocess.run(args, env=env, check=True)
|
|
42
50
|
except FileNotFoundError:
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# message catches the common case.
|
|
51
|
+
# FileNotFoundError almost always means psql isn't installed or on
|
|
52
|
+
# PATH, but could be raised for other reasons — the message covers
|
|
53
|
+
# the common case.
|
|
47
54
|
click.secho(
|
|
48
|
-
|
|
55
|
+
"You appear not to have the 'psql' program installed or on your path.",
|
|
49
56
|
fg="red",
|
|
50
57
|
err=True,
|
|
51
58
|
)
|
|
@@ -60,6 +67,8 @@ def shell(parameters: tuple[str, ...]) -> None:
|
|
|
60
67
|
err=True,
|
|
61
68
|
)
|
|
62
69
|
sys.exit(e.returncode)
|
|
70
|
+
finally:
|
|
71
|
+
signal.signal(signal.SIGINT, sigint_handler)
|
|
63
72
|
|
|
64
73
|
|
|
65
74
|
@cli.command("drop-unknown-tables")
|
|
@@ -4,7 +4,9 @@ from plain.packages import (
|
|
|
4
4
|
register_config,
|
|
5
5
|
)
|
|
6
6
|
|
|
7
|
+
from .otel import register_pool_observables
|
|
7
8
|
from .registry import models_registry
|
|
9
|
+
from .sources import runtime_pool_source
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
@register_config
|
|
@@ -16,3 +18,5 @@ class Config(PackageConfig):
|
|
|
16
18
|
packages_registry.autodiscover_modules("models", include_app=False)
|
|
17
19
|
|
|
18
20
|
models_registry.ready = True
|
|
21
|
+
|
|
22
|
+
register_pool_observables(runtime_pool_source)
|