iris-pgwire 1.7.2__tar.gz → 2.0.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.
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/PKG-INFO +38 -7
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/README.md +36 -6
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/pyproject.toml +11 -1
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/__init__.py +1 -1
- iris_pgwire-2.0.0/src/iris_pgwire/_isolation.py +413 -0
- iris_pgwire-2.0.0/src/iris_pgwire/auth/__init__.py +29 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/ddl_idempotency.py +4 -1
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/vector_syntax.py +4 -2
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/dbapi_executor.py +15 -1
- iris_pgwire-2.0.0/src/iris_pgwire/embedded_vector_fetch.py +108 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/iris_executor.py +126 -29
- iris_pgwire-2.0.0/src/iris_pgwire/password_auth.py +237 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/protocol.py +169 -399
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/server.py +173 -7
- iris_pgwire-2.0.0/src/iris_pgwire/sql_translator/isolation.py +342 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/mappings/constructs.py +3 -2
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/sqlstate.py +86 -10
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/vector_optimizer.py +4 -1
- iris_pgwire-1.7.2/src/iris_pgwire/auth/__init__.py +0 -120
- iris_pgwire-1.7.2/src/iris_pgwire/auth/auth_selector.py +0 -112
- iris_pgwire-1.7.2/src/iris_pgwire/auth/oauth_bridge.py +0 -383
- iris_pgwire-1.7.2/src/iris_pgwire/auth/scram.py +0 -782
- iris_pgwire-1.7.2/src/iris_pgwire/auth/wallet_credentials.py +0 -313
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/.gitignore +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/LICENSE +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/_column_naming.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/_noop_cursor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/_result_processing.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/_type_mapping.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/auth/gssapi_auth.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/backend_selector.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/bulk_executor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/_reentrancy.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/catalog_functions.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/catalog_router.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/function_installer.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/functions.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/oid_generator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_attrdef.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_attribute.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_class.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_constraint.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_index.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_namespace.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/pg_type.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/views/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/views/definitions.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/catalog/views/installer.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/column_validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/config.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/config_schema.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/constitutional.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/bulk_insert.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/date_horolog.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/ddl_splitter.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/conversions/json_path.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/copy_handler.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/csv_processor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/dbapi_connection_pool.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/debug_tracer.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/health_checker.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/integratedml.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/iris_constructs.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/iris_log_handler.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/iris_user_management.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/migrations/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/migrations/__main__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/migrations/executor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/models/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/models/backend_config.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/models/connection_pool_state.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/models/dbapi_connection.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/models/ipm_metadata.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/models/vector_query_request.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/observability.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/performance_monitor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/__main__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/code_quality_validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/documentation_validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/package_metadata_validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/security_validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/quality/validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/schema_mapper.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/alias_extractor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/api.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/array_literal.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/array_params.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/boolean_expr.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/boolean_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/cache.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/column_types.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/confidence_analyzer.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/config.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/constraint_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/copy_parser.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/date_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/ddl_parser.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/ddl_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/debug.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/default_values.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/enum_registry.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/enum_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/error_handler.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/identifier_normalizer.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/index_validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/interceptor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/logging_config.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/mappings/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/mappings/datatypes.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/mappings/document_filters.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/mappings/functions.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/metadata_cache.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/metrics.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/models.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/normalizer.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/parser.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/performance_monitor.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/pg_array.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/pg_functions.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/pipeline.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/refiner.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/reserved_words.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/returning_plan.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/skipped_table_set.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/statement_filter.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/transaction_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/type_translator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/validator.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/sql_translator/verbatim.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/testing/__init__.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/testing/base_fixture_builder.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/type_mapping.py +0 -0
- {iris_pgwire-1.7.2 → iris_pgwire-2.0.0}/src/iris_pgwire/vector_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: iris-pgwire
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: PostgreSQL Wire Protocol Server for InterSystems IRIS - Connect BI tools, Python frameworks, and PostgreSQL clients to IRIS databases
|
|
5
5
|
Project-URL: Homepage, https://github.com/intersystems-community/iris-pgwire
|
|
6
6
|
Project-URL: Documentation, https://github.com/intersystems-community/iris-pgwire#readme
|
|
@@ -71,6 +71,7 @@ Provides-Extra: iris
|
|
|
71
71
|
Provides-Extra: kerberos
|
|
72
72
|
Requires-Dist: gssapi>=1.8.0; extra == 'kerberos'
|
|
73
73
|
Provides-Extra: test
|
|
74
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'test'
|
|
74
75
|
Requires-Dist: docker>=6.0.0; extra == 'test'
|
|
75
76
|
Requires-Dist: iris-devtester>=0.1.0; extra == 'test'
|
|
76
77
|
Requires-Dist: k5test>=0.10.3; extra == 'test'
|
|
@@ -105,8 +106,10 @@ git clone https://github.com/intersystems-community/iris-pgwire.git
|
|
|
105
106
|
cd iris-pgwire
|
|
106
107
|
docker compose up -d
|
|
107
108
|
|
|
108
|
-
# Test it (PGWire runs on port 5432 inside the IRIS container)
|
|
109
|
-
|
|
109
|
+
# Test it (PGWire runs on port 5432 inside the IRIS container).
|
|
110
|
+
# Clients log in with an IRIS username and password; the development container accepts the
|
|
111
|
+
# password over a plain connection because it is published on localhost only.
|
|
112
|
+
PGPASSWORD=SYS psql -h localhost -p 5432 -U _SYSTEM -d USER -c "SELECT 'Hello from IRIS!'"
|
|
110
113
|
```
|
|
111
114
|
|
|
112
115
|
### Python package
|
|
@@ -117,9 +120,13 @@ pip install iris-pgwire psycopg[binary]
|
|
|
117
120
|
export IRIS_HOST=localhost IRIS_PORT=1972 \
|
|
118
121
|
IRIS_USERNAME=_SYSTEM IRIS_PASSWORD=SYS IRIS_NAMESPACE=USER
|
|
119
122
|
|
|
120
|
-
python -m iris_pgwire.server
|
|
123
|
+
python -m iris_pgwire.server --allow-plaintext-password
|
|
121
124
|
```
|
|
122
125
|
|
|
126
|
+
Clients must log in with an IRIS username and password, and the password is sent in the clear, so
|
|
127
|
+
the server refuses to start unless you choose how it is protected — see
|
|
128
|
+
[Authentication](#authentication). `--allow-plaintext-password` is for a network you trust.
|
|
129
|
+
|
|
123
130
|
### First query
|
|
124
131
|
|
|
125
132
|
```python
|
|
@@ -163,7 +170,30 @@ See [Client Compatibility Guide](docs/CLIENT_RECOMMENDATIONS.md) for setup examp
|
|
|
163
170
|
|
|
164
171
|
**SQL translation** — `RETURNING` emulation, `ON CONFLICT`, boolean literals, `pg_catalog` → `INFORMATION_SCHEMA` rewrites, JSON operators (`->` / `->>` → `JSON_EXTRACT`), parameterized queries.
|
|
165
172
|
|
|
166
|
-
|
|
173
|
+
<a id="authentication"></a>
|
|
174
|
+
|
|
175
|
+
**Authentication** — Required by default. The client sends its IRIS username and password and the
|
|
176
|
+
server checks them by logging in to IRIS with them, so IRIS remains the only store of users and
|
|
177
|
+
passwords. Because the password crosses the wire, TLS is required unless you say otherwise:
|
|
178
|
+
|
|
179
|
+
| You want | Run with |
|
|
180
|
+
| ------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
|
181
|
+
| TLS (production) | `PGWIRE_SSL_ENABLED=true PGWIRE_SSL_CERT=… PGWIRE_SSL_KEY=…` |
|
|
182
|
+
| Plain connection on a trusted network, or behind a TLS-terminating proxy | `--allow-plaintext-password` (`PGWIRE_ALLOW_PLAINTEXT_PASSWORD=true`) |
|
|
183
|
+
| No authentication, local development only | `--trust` (`PGWIRE_AUTH_MODE=trust`) |
|
|
184
|
+
| Let users other than the service account log in | `--allowed-users alice,bob` (`PGWIRE_ALLOWED_USERS`) |
|
|
185
|
+
|
|
186
|
+
A server started with none of these refuses to start and says so.
|
|
187
|
+
|
|
188
|
+
What authentication does and does not do: it proves the client knows the password of an IRIS user.
|
|
189
|
+
Statements still run as the service account configured with `IRIS_USERNAME`, so every user who can
|
|
190
|
+
log in has that account's privileges. That is why **only the service account may log in unless you
|
|
191
|
+
list others**; a list replaces the default, so list only people you would trust with that account.
|
|
192
|
+
SCRAM-SHA-256, OAuth 2.0 and IRIS Wallet credentials are
|
|
193
|
+
**not available**: earlier versions advertised them, but the SCRAM exchange never checked the client's
|
|
194
|
+
proof, and `PGWIRE_ENABLE_SCRAM` was never read. Asking for SCRAM now fails at startup. Login
|
|
195
|
+
attempts are checked by IRIS itself, so its lockout and password policies apply — repeated failures
|
|
196
|
+
can lock an account.
|
|
167
197
|
|
|
168
198
|
**Dual backend** — Embedded Python (irispython, lowest latency) or external DBAPI (standard TCP connection). Selectable via `IRIS_BACKEND` env var.
|
|
169
199
|
|
|
@@ -265,9 +295,10 @@ python -m iris_pgwire.quality
|
|
|
265
295
|
|
|
266
296
|
See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for the full list. Key items:
|
|
267
297
|
|
|
298
|
+
- **No MVCC or snapshot isolation.** `REPEATABLE READ` and `SERIALIZABLE` are rejected rather than silently downgraded; IRIS `READ COMMITTED` is narrower than PostgreSQL's, and dirty reads are reachable through aggregates, `GROUP BY` and `DISTINCT`. Lock conflicts arrive as retryable `40001`. Version-guarded (optimistic-locking) updates can report false conflicts — read the isolation section before porting an ORM that uses them.
|
|
268
299
|
- IRIS Community Edition: 5-user connection limit — use `PGWIRE_POOL_SIZE=1` for dev
|
|
269
|
-
-
|
|
270
|
-
- Kerberos/GSSAPI auth wiring deferred
|
|
300
|
+
- TLS is native (`PGWIRE_SSL_ENABLED`); behind a TLS-terminating proxy, use `--allow-plaintext-password`
|
|
301
|
+
- Kerberos/GSSAPI auth wiring deferred; SCRAM-SHA-256, OAuth 2.0 and Wallet credentials are not available (see [Authentication](#authentication))
|
|
271
302
|
- `INFORMATION_SCHEMA` only, no `pg_catalog` tables (translated automatically)
|
|
272
303
|
|
|
273
304
|
---
|
|
@@ -19,8 +19,10 @@ git clone https://github.com/intersystems-community/iris-pgwire.git
|
|
|
19
19
|
cd iris-pgwire
|
|
20
20
|
docker compose up -d
|
|
21
21
|
|
|
22
|
-
# Test it (PGWire runs on port 5432 inside the IRIS container)
|
|
23
|
-
|
|
22
|
+
# Test it (PGWire runs on port 5432 inside the IRIS container).
|
|
23
|
+
# Clients log in with an IRIS username and password; the development container accepts the
|
|
24
|
+
# password over a plain connection because it is published on localhost only.
|
|
25
|
+
PGPASSWORD=SYS psql -h localhost -p 5432 -U _SYSTEM -d USER -c "SELECT 'Hello from IRIS!'"
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
### Python package
|
|
@@ -31,9 +33,13 @@ pip install iris-pgwire psycopg[binary]
|
|
|
31
33
|
export IRIS_HOST=localhost IRIS_PORT=1972 \
|
|
32
34
|
IRIS_USERNAME=_SYSTEM IRIS_PASSWORD=SYS IRIS_NAMESPACE=USER
|
|
33
35
|
|
|
34
|
-
python -m iris_pgwire.server
|
|
36
|
+
python -m iris_pgwire.server --allow-plaintext-password
|
|
35
37
|
```
|
|
36
38
|
|
|
39
|
+
Clients must log in with an IRIS username and password, and the password is sent in the clear, so
|
|
40
|
+
the server refuses to start unless you choose how it is protected — see
|
|
41
|
+
[Authentication](#authentication). `--allow-plaintext-password` is for a network you trust.
|
|
42
|
+
|
|
37
43
|
### First query
|
|
38
44
|
|
|
39
45
|
```python
|
|
@@ -77,7 +83,30 @@ See [Client Compatibility Guide](docs/CLIENT_RECOMMENDATIONS.md) for setup examp
|
|
|
77
83
|
|
|
78
84
|
**SQL translation** — `RETURNING` emulation, `ON CONFLICT`, boolean literals, `pg_catalog` → `INFORMATION_SCHEMA` rewrites, JSON operators (`->` / `->>` → `JSON_EXTRACT`), parameterized queries.
|
|
79
85
|
|
|
80
|
-
|
|
86
|
+
<a id="authentication"></a>
|
|
87
|
+
|
|
88
|
+
**Authentication** — Required by default. The client sends its IRIS username and password and the
|
|
89
|
+
server checks them by logging in to IRIS with them, so IRIS remains the only store of users and
|
|
90
|
+
passwords. Because the password crosses the wire, TLS is required unless you say otherwise:
|
|
91
|
+
|
|
92
|
+
| You want | Run with |
|
|
93
|
+
| ------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
|
94
|
+
| TLS (production) | `PGWIRE_SSL_ENABLED=true PGWIRE_SSL_CERT=… PGWIRE_SSL_KEY=…` |
|
|
95
|
+
| Plain connection on a trusted network, or behind a TLS-terminating proxy | `--allow-plaintext-password` (`PGWIRE_ALLOW_PLAINTEXT_PASSWORD=true`) |
|
|
96
|
+
| No authentication, local development only | `--trust` (`PGWIRE_AUTH_MODE=trust`) |
|
|
97
|
+
| Let users other than the service account log in | `--allowed-users alice,bob` (`PGWIRE_ALLOWED_USERS`) |
|
|
98
|
+
|
|
99
|
+
A server started with none of these refuses to start and says so.
|
|
100
|
+
|
|
101
|
+
What authentication does and does not do: it proves the client knows the password of an IRIS user.
|
|
102
|
+
Statements still run as the service account configured with `IRIS_USERNAME`, so every user who can
|
|
103
|
+
log in has that account's privileges. That is why **only the service account may log in unless you
|
|
104
|
+
list others**; a list replaces the default, so list only people you would trust with that account.
|
|
105
|
+
SCRAM-SHA-256, OAuth 2.0 and IRIS Wallet credentials are
|
|
106
|
+
**not available**: earlier versions advertised them, but the SCRAM exchange never checked the client's
|
|
107
|
+
proof, and `PGWIRE_ENABLE_SCRAM` was never read. Asking for SCRAM now fails at startup. Login
|
|
108
|
+
attempts are checked by IRIS itself, so its lockout and password policies apply — repeated failures
|
|
109
|
+
can lock an account.
|
|
81
110
|
|
|
82
111
|
**Dual backend** — Embedded Python (irispython, lowest latency) or external DBAPI (standard TCP connection). Selectable via `IRIS_BACKEND` env var.
|
|
83
112
|
|
|
@@ -179,9 +208,10 @@ python -m iris_pgwire.quality
|
|
|
179
208
|
|
|
180
209
|
See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for the full list. Key items:
|
|
181
210
|
|
|
211
|
+
- **No MVCC or snapshot isolation.** `REPEATABLE READ` and `SERIALIZABLE` are rejected rather than silently downgraded; IRIS `READ COMMITTED` is narrower than PostgreSQL's, and dirty reads are reachable through aggregates, `GROUP BY` and `DISTINCT`. Lock conflicts arrive as retryable `40001`. Version-guarded (optimistic-locking) updates can report false conflicts — read the isolation section before porting an ORM that uses them.
|
|
182
212
|
- IRIS Community Edition: 5-user connection limit — use `PGWIRE_POOL_SIZE=1` for dev
|
|
183
|
-
-
|
|
184
|
-
- Kerberos/GSSAPI auth wiring deferred
|
|
213
|
+
- TLS is native (`PGWIRE_SSL_ENABLED`); behind a TLS-terminating proxy, use `--allow-plaintext-password`
|
|
214
|
+
- Kerberos/GSSAPI auth wiring deferred; SCRAM-SHA-256, OAuth 2.0 and Wallet credentials are not available (see [Authentication](#authentication))
|
|
185
215
|
- `INFORMATION_SCHEMA` only, no `pg_catalog` tables (translated automatically)
|
|
186
216
|
|
|
187
217
|
---
|
|
@@ -81,6 +81,11 @@ test = [
|
|
|
81
81
|
# [binary] includes C extension + libpq, no system dependencies needed
|
|
82
82
|
"psycopg[binary]>=3.1.0",
|
|
83
83
|
"docker>=6.0.0",
|
|
84
|
+
# asyncpg is a client we claim compatibility with, and
|
|
85
|
+
# tests/client_compatibility/python imports it at module scope. Undeclared,
|
|
86
|
+
# those modules fail to *collect*, which aborts `pytest tests/` before it
|
|
87
|
+
# runs anything rather than skipping two files.
|
|
88
|
+
"asyncpg>=0.29.0",
|
|
84
89
|
"iris-devtester>=0.1.0",
|
|
85
90
|
"pytest-benchmark>=4.0.0",
|
|
86
91
|
# Runtime dependencies needed when importing iris_pgwire modules in tests
|
|
@@ -117,7 +122,7 @@ kerberos = [
|
|
|
117
122
|
]
|
|
118
123
|
|
|
119
124
|
[project.scripts]
|
|
120
|
-
iris-pgwire = "iris_pgwire.server:
|
|
125
|
+
iris-pgwire = "iris_pgwire.server:cli"
|
|
121
126
|
|
|
122
127
|
[project.urls]
|
|
123
128
|
Homepage = "https://github.com/intersystems-community/iris-pgwire"
|
|
@@ -284,6 +289,11 @@ markers = [
|
|
|
284
289
|
"document_db: Tests for document database features",
|
|
285
290
|
"error_handling: Tests for error handling scenarios",
|
|
286
291
|
"mixed_sql: Tests for mixed SQL query processing",
|
|
292
|
+
# A pinned limitation asserts behaviour that is *wrong* by PostgreSQL's
|
|
293
|
+
# standards and cannot be fixed at the wire layer. It is pinned, not endorsed:
|
|
294
|
+
# a failure means the limitation changed and KNOWN_LIMITATIONS.md is now
|
|
295
|
+
# lying. Run them together with `pytest -m pinned_limitation`.
|
|
296
|
+
"pinned_limitation: Pins a documented divergence from PostgreSQL; a failure means the docs are stale",
|
|
287
297
|
]
|
|
288
298
|
|
|
289
299
|
# Flaky test detection configuration
|
|
@@ -6,7 +6,7 @@ Based on the specification from docs/iris_pgwire_plan.md and proven patterns fro
|
|
|
6
6
|
caretdev/sqlalchemy-iris.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
__version__ = "
|
|
9
|
+
__version__ = "2.0.0"
|
|
10
10
|
__author__ = "Thomas Dyar <thomas.dyar@intersystems.com>"
|
|
11
11
|
|
|
12
12
|
# Don't import server/protocol in __init__ to avoid sys.modules conflicts
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Transaction isolation level handling, shared by both backends.
|
|
2
|
+
|
|
3
|
+
This lives in a mixin rather than in either executor because the two backends
|
|
4
|
+
have different code paths to IRIS and a client cannot tell which one it is
|
|
5
|
+
talking to. Isolation handled in only one of them is isolation handled in none:
|
|
6
|
+
the embedded backend serves `iris session`-style execution while DBAPI serves
|
|
7
|
+
`intersystems-irispython`, and the pgwire server picks between them by config.
|
|
8
|
+
|
|
9
|
+
The parsing and the measured IRIS support table are in
|
|
10
|
+
sql_translator/isolation.py; this module is only the wiring and the per-session
|
|
11
|
+
bookkeeping.
|
|
12
|
+
|
|
13
|
+
Two settings, not one. `transaction_isolation` is the level the current
|
|
14
|
+
transaction runs at and `default_transaction_isolation` is what the next one
|
|
15
|
+
starts at, and PostgreSQL's spellings write one or the other — see the measured
|
|
16
|
+
table in tests/unit/test_isolation_scopes.py. IRIS has a single level per
|
|
17
|
+
session, so the two slots are kept here and IRIS is told whichever one is in
|
|
18
|
+
force; `_resync_iris` is what keeps that promise when the level changes because
|
|
19
|
+
a transaction ended rather than because a client asked.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import structlog
|
|
28
|
+
|
|
29
|
+
from .sql_translator.isolation import (
|
|
30
|
+
DEFAULT_ISOLATION_LEVEL,
|
|
31
|
+
MID_TRANSACTION_AFTER_QUERY,
|
|
32
|
+
MID_TRANSACTION_CONDITION,
|
|
33
|
+
MID_TRANSACTION_IN_SUBTRANSACTION,
|
|
34
|
+
MID_TRANSACTION_SQLSTATE,
|
|
35
|
+
SESSION_SCOPE,
|
|
36
|
+
counts_as_query,
|
|
37
|
+
parse_begin_isolation,
|
|
38
|
+
parse_isolation_show,
|
|
39
|
+
parse_savepoint_change,
|
|
40
|
+
parse_set_isolation,
|
|
41
|
+
parse_transaction_boundary,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = structlog.get_logger("iris_pgwire.isolation")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _SessionIsolation:
|
|
49
|
+
"""The per-session state that is not the session default.
|
|
50
|
+
|
|
51
|
+
The session default lives in the host class's ``session_isolation`` dict,
|
|
52
|
+
which predates this and is read by name elsewhere. Everything else is here.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# The transaction-scoped level, in force until the transaction ends.
|
|
56
|
+
override: str | None = None
|
|
57
|
+
# The level IRIS was last told, so a divergence can be noticed and repaired.
|
|
58
|
+
iris_level: str | None = None
|
|
59
|
+
# Whether a transaction is open, which decides whether a session-default
|
|
60
|
+
# change is undoable and whether a resync would land mid-transaction.
|
|
61
|
+
in_transaction: bool = False
|
|
62
|
+
# The session default as it was when this transaction opened, kept only once
|
|
63
|
+
# and only if the transaction changed it — PostgreSQL rolls `SET` back too.
|
|
64
|
+
default_before_transaction: str | None = None
|
|
65
|
+
default_changed_in_transaction: bool = False
|
|
66
|
+
# Whether this transaction has read anything, after which its level can no
|
|
67
|
+
# longer be changed, and how deep it is in savepoints, which is the same rule
|
|
68
|
+
# for a different reason.
|
|
69
|
+
transaction_did_query: bool = False
|
|
70
|
+
savepoint_depth: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IsolationMixin:
|
|
74
|
+
"""`SET`/`SHOW TRANSACTION ISOLATION LEVEL` against real IRIS state.
|
|
75
|
+
|
|
76
|
+
The host class must provide an awaitable
|
|
77
|
+
``execute_query(sql, params=None, session_id=None, **kwargs)`` and must
|
|
78
|
+
create ``self.session_isolation`` in its ``__init__``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
session_isolation: dict[str, str]
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Per-session state
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def _isolation_sessions(self) -> dict[str, _SessionIsolation]:
|
|
89
|
+
"""Created on first use, so a host class only has to know about one dict."""
|
|
90
|
+
store = getattr(self, "_isolation_sessions_store", None)
|
|
91
|
+
if store is None:
|
|
92
|
+
store = {}
|
|
93
|
+
self._isolation_sessions_store: dict[str, _SessionIsolation] = store
|
|
94
|
+
return store
|
|
95
|
+
|
|
96
|
+
def _isolation_state(self, session_id: str) -> _SessionIsolation:
|
|
97
|
+
return self._isolation_sessions.setdefault(session_id, _SessionIsolation())
|
|
98
|
+
|
|
99
|
+
def get_isolation_level(self, session_id: str | None) -> str:
|
|
100
|
+
"""`transaction_isolation`: the level this session is running at now.
|
|
101
|
+
|
|
102
|
+
The transaction's own level if it has one, else the session default, else
|
|
103
|
+
READ UNCOMMITTED — which is what IRIS runs at until told otherwise, not
|
|
104
|
+
PostgreSQL's READ COMMITTED default.
|
|
105
|
+
"""
|
|
106
|
+
if session_id is None:
|
|
107
|
+
return DEFAULT_ISOLATION_LEVEL
|
|
108
|
+
state = self._isolation_sessions.get(session_id)
|
|
109
|
+
if state is not None and state.override is not None:
|
|
110
|
+
return state.override
|
|
111
|
+
return self.get_default_isolation_level(session_id)
|
|
112
|
+
|
|
113
|
+
def get_default_isolation_level(self, session_id: str | None) -> str:
|
|
114
|
+
"""`default_transaction_isolation`: what the next transaction starts at.
|
|
115
|
+
|
|
116
|
+
Deliberately blind to the current transaction's override. A pool asking
|
|
117
|
+
this is asking what it handed out, and answering with a level that ends
|
|
118
|
+
at the next COMMIT would be the wrong answer to that question.
|
|
119
|
+
"""
|
|
120
|
+
if session_id is None:
|
|
121
|
+
return DEFAULT_ISOLATION_LEVEL
|
|
122
|
+
return self.session_isolation.get(session_id, DEFAULT_ISOLATION_LEVEL)
|
|
123
|
+
|
|
124
|
+
def forget_isolation_level(self, session_id: str | None) -> None:
|
|
125
|
+
"""Drop a closed session's state so the dicts cannot grow without bound.
|
|
126
|
+
|
|
127
|
+
Both slots, because session ids are reused: a leftover override would be
|
|
128
|
+
the previous client's, and would be resynced onto IRIS as if asked for.
|
|
129
|
+
"""
|
|
130
|
+
if session_id is not None:
|
|
131
|
+
self.session_isolation.pop(session_id, None)
|
|
132
|
+
self._isolation_sessions.pop(session_id, None)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Statement handling
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _isolation_result(self, guc: str, level: str) -> dict[str, Any]:
|
|
139
|
+
"""A one-row, one-column SHOW reply carrying `level`."""
|
|
140
|
+
return {
|
|
141
|
+
"success": True,
|
|
142
|
+
"rows": [[level]],
|
|
143
|
+
"columns": [
|
|
144
|
+
{
|
|
145
|
+
"name": guc,
|
|
146
|
+
"type_oid": 25, # TEXT
|
|
147
|
+
"type_size": -1,
|
|
148
|
+
"type_modifier": -1,
|
|
149
|
+
"format_code": 0,
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
"row_count": 1,
|
|
153
|
+
"command_tag": "SHOW",
|
|
154
|
+
"execution_time_ms": 0.0,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async def _handle_isolation_statement(
|
|
158
|
+
self, sql: str, session_id: str | None = None, **kwargs
|
|
159
|
+
) -> dict[str, Any] | None:
|
|
160
|
+
"""Handle isolation-level SET and SHOW, or return None to pass `sql` on.
|
|
161
|
+
|
|
162
|
+
Called before the SQL interceptor in both executors, because the
|
|
163
|
+
interceptor's generic SHOW handler answers anything it does not
|
|
164
|
+
recognise with the literal string "unknown". Being on the path of every
|
|
165
|
+
statement is also what lets the transaction boundary be seen, which is
|
|
166
|
+
where a transaction-scoped level ends.
|
|
167
|
+
|
|
168
|
+
A supported SET is executed against IRIS for real and the session's level
|
|
169
|
+
is recorded only once IRIS has accepted it, so `SHOW` can never report a
|
|
170
|
+
level that was not applied. An unsupported one is refused — see
|
|
171
|
+
sql_translator/isolation.py for why it is not downgraded instead.
|
|
172
|
+
"""
|
|
173
|
+
guc = parse_isolation_show(sql)
|
|
174
|
+
if guc is not None:
|
|
175
|
+
level = (
|
|
176
|
+
self.get_default_isolation_level(session_id)
|
|
177
|
+
if guc == "default_transaction_isolation"
|
|
178
|
+
else self.get_isolation_level(session_id)
|
|
179
|
+
)
|
|
180
|
+
return self._isolation_result(guc, level)
|
|
181
|
+
|
|
182
|
+
request = parse_set_isolation(sql)
|
|
183
|
+
if request is not None:
|
|
184
|
+
if not request.supported:
|
|
185
|
+
# The level is checked before the timing, which is the order
|
|
186
|
+
# PostgreSQL measurably uses: an unusable level is refused on its
|
|
187
|
+
# own terms whether or not it arrived too late.
|
|
188
|
+
return self._refuse(request, session_id)
|
|
189
|
+
too_late = self._too_late_to_change(request, session_id)
|
|
190
|
+
if too_late is not None:
|
|
191
|
+
return too_late
|
|
192
|
+
return await self._apply(request, session_id, **kwargs)
|
|
193
|
+
|
|
194
|
+
# `BEGIN ISOLATION LEVEL x` is the same request in the spelling psycopg3
|
|
195
|
+
# sends, and it must be refused and recorded the same way — otherwise a
|
|
196
|
+
# client routes around the refusal by naming the level on BEGIN instead.
|
|
197
|
+
request = parse_begin_isolation(sql)
|
|
198
|
+
if request is not None:
|
|
199
|
+
if not request.supported:
|
|
200
|
+
# Refused before the transaction starts, so the session is left
|
|
201
|
+
# where it was rather than inside a transaction it did not ask for.
|
|
202
|
+
return self._refuse(request, session_id)
|
|
203
|
+
|
|
204
|
+
self._note_transaction_boundary("start", session_id)
|
|
205
|
+
# Still one statement, in IRIS's spelling of it — `BEGIN` is not IRIS
|
|
206
|
+
# SQL and this interception happens ahead of the translator that would
|
|
207
|
+
# have rewritten it. The request carries the tag for the verb the
|
|
208
|
+
# client actually used, so the rewrite stays invisible in the reply.
|
|
209
|
+
return await self._apply(request, session_id, **kwargs)
|
|
210
|
+
|
|
211
|
+
boundary = parse_transaction_boundary(sql)
|
|
212
|
+
if boundary == "start":
|
|
213
|
+
# Before the transaction opens is the last moment IRIS's level can be
|
|
214
|
+
# corrected, since changing it inside a transaction is what PostgreSQL
|
|
215
|
+
# refuses. A transaction that inherited the wrong level would run at it
|
|
216
|
+
# from its first statement to its last.
|
|
217
|
+
failed = await self._resync_iris(session_id, **kwargs)
|
|
218
|
+
if failed is not None:
|
|
219
|
+
return failed
|
|
220
|
+
self._note_transaction_boundary(boundary, session_id)
|
|
221
|
+
# Not our statement: the BEGIN itself still has to reach IRIS.
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
if boundary is not None:
|
|
225
|
+
self._note_transaction_boundary(boundary, session_id)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
self._note_ordinary_statement(sql, session_id)
|
|
229
|
+
return await self._resync_iris(session_id, **kwargs)
|
|
230
|
+
|
|
231
|
+
def _refuse(self, request, session_id: str | None) -> dict[str, Any]:
|
|
232
|
+
"""The error reply for a level IRIS cannot honour."""
|
|
233
|
+
logger.info(
|
|
234
|
+
"Refusing unsupported transaction isolation level",
|
|
235
|
+
requested_level=request.level,
|
|
236
|
+
session_id=session_id,
|
|
237
|
+
)
|
|
238
|
+
return {
|
|
239
|
+
"success": False,
|
|
240
|
+
"error": request.message,
|
|
241
|
+
"sqlstate": request.sqlstate,
|
|
242
|
+
"rows": [],
|
|
243
|
+
"columns": [],
|
|
244
|
+
"row_count": 0,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async def _apply(self, request, session_id: str | None, **kwargs) -> dict[str, Any]:
|
|
248
|
+
"""Send the request's IRIS statement and record the level only if IRIS took it."""
|
|
249
|
+
# `_skip_isolation` stops this from recursing on the statement we send.
|
|
250
|
+
result = await self.execute_query( # type: ignore[attr-defined]
|
|
251
|
+
request.iris_sql, session_id=session_id, _skip_isolation=True, **kwargs
|
|
252
|
+
)
|
|
253
|
+
if result.get("success") and session_id is not None:
|
|
254
|
+
self._record_applied(request, session_id)
|
|
255
|
+
if request.command_tag is not None:
|
|
256
|
+
result["command_tag"] = request.command_tag
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
def _record_applied(self, request, session_id: str) -> None:
|
|
260
|
+
"""Write the accepted level into the slot its spelling names."""
|
|
261
|
+
state = self._isolation_state(session_id)
|
|
262
|
+
state.iris_level = request.level
|
|
263
|
+
|
|
264
|
+
if request.scope == SESSION_SCOPE:
|
|
265
|
+
if state.in_transaction and not state.default_changed_in_transaction:
|
|
266
|
+
# Snapshot once: a ROLLBACK restores the value from before the
|
|
267
|
+
# transaction, not from before the most recent SET inside it.
|
|
268
|
+
state.default_before_transaction = self.session_isolation.get(session_id)
|
|
269
|
+
state.default_changed_in_transaction = True
|
|
270
|
+
self.session_isolation[session_id] = request.level
|
|
271
|
+
# The default is what the current transaction runs at too, unless the
|
|
272
|
+
# transaction named its own level — so an override still wins.
|
|
273
|
+
else:
|
|
274
|
+
state.override = request.level
|
|
275
|
+
|
|
276
|
+
logger.info(
|
|
277
|
+
"Transaction isolation level applied",
|
|
278
|
+
level=request.level,
|
|
279
|
+
scope=request.scope,
|
|
280
|
+
session_id=session_id,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _too_late_to_change(self, request, session_id: str | None) -> dict[str, Any] | None:
|
|
284
|
+
"""The 25001 reply if this transaction is past changing its level, else None.
|
|
285
|
+
|
|
286
|
+
Only the transaction-scoped spellings can be too late; the session-scoped
|
|
287
|
+
ones describe the next transaction. Re-asserting the level already in
|
|
288
|
+
force is always allowed, measured on PostgreSQL 16 and relied on by every
|
|
289
|
+
client that restates its isolation level defensively.
|
|
290
|
+
"""
|
|
291
|
+
if session_id is None or request.scope == SESSION_SCOPE:
|
|
292
|
+
return None
|
|
293
|
+
state = self._isolation_sessions.get(session_id)
|
|
294
|
+
if state is None or not state.in_transaction:
|
|
295
|
+
return None
|
|
296
|
+
if request.level == self.get_isolation_level(session_id):
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
if state.savepoint_depth > 0:
|
|
300
|
+
message = MID_TRANSACTION_IN_SUBTRANSACTION
|
|
301
|
+
elif state.transaction_did_query:
|
|
302
|
+
message = MID_TRANSACTION_AFTER_QUERY
|
|
303
|
+
else:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
logger.info(
|
|
307
|
+
"Refusing a mid-transaction isolation level change",
|
|
308
|
+
requested_level=request.level,
|
|
309
|
+
current_level=self.get_isolation_level(session_id),
|
|
310
|
+
session_id=session_id,
|
|
311
|
+
)
|
|
312
|
+
# Not recorded and not sent: the transaction keeps the level it has, and
|
|
313
|
+
# `SHOW` keeps reporting it.
|
|
314
|
+
return {
|
|
315
|
+
"success": False,
|
|
316
|
+
"error": message,
|
|
317
|
+
"sqlstate": MID_TRANSACTION_SQLSTATE,
|
|
318
|
+
"condition": MID_TRANSACTION_CONDITION,
|
|
319
|
+
"rows": [],
|
|
320
|
+
"columns": [],
|
|
321
|
+
"row_count": 0,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
def _note_ordinary_statement(self, sql: str, session_id: str | None) -> None:
|
|
325
|
+
"""Track what an open transaction has done, which decides the 25001 above."""
|
|
326
|
+
if session_id is None:
|
|
327
|
+
return
|
|
328
|
+
savepoint = parse_savepoint_change(sql)
|
|
329
|
+
if savepoint is not None:
|
|
330
|
+
state = self._isolation_state(session_id)
|
|
331
|
+
if not state.in_transaction:
|
|
332
|
+
# A stray savepoint statement outside a transaction; PostgreSQL
|
|
333
|
+
# has nothing to nest and neither have we.
|
|
334
|
+
return
|
|
335
|
+
if savepoint == "enter":
|
|
336
|
+
state.savepoint_depth += 1
|
|
337
|
+
else:
|
|
338
|
+
state.savepoint_depth = max(0, state.savepoint_depth - 1)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
if not counts_as_query(sql):
|
|
342
|
+
return
|
|
343
|
+
state = self._isolation_sessions.get(session_id)
|
|
344
|
+
if state is not None and state.in_transaction:
|
|
345
|
+
state.transaction_did_query = True
|
|
346
|
+
|
|
347
|
+
def _note_transaction_boundary(self, boundary: str, session_id: str | None) -> None:
|
|
348
|
+
"""Open or close the window a transaction-scoped level lives in."""
|
|
349
|
+
if session_id is None:
|
|
350
|
+
return
|
|
351
|
+
state = self._isolation_state(session_id)
|
|
352
|
+
|
|
353
|
+
if boundary == "start":
|
|
354
|
+
state.in_transaction = True
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
if boundary == "rollback" and state.default_changed_in_transaction:
|
|
358
|
+
# Measured on PostgreSQL 16: a session default set inside a
|
|
359
|
+
# transaction that rolls back never happened. Savepoints are not
|
|
360
|
+
# modelled — a partial rollback keeps the whole change.
|
|
361
|
+
previous = state.default_before_transaction
|
|
362
|
+
if previous is None:
|
|
363
|
+
self.session_isolation.pop(session_id, None)
|
|
364
|
+
else:
|
|
365
|
+
self.session_isolation[session_id] = previous
|
|
366
|
+
|
|
367
|
+
state.override = None
|
|
368
|
+
state.in_transaction = False
|
|
369
|
+
state.default_before_transaction = None
|
|
370
|
+
state.default_changed_in_transaction = False
|
|
371
|
+
state.transaction_did_query = False
|
|
372
|
+
state.savepoint_depth = 0
|
|
373
|
+
|
|
374
|
+
async def _resync_iris(self, session_id: str | None, **kwargs) -> dict[str, Any] | None:
|
|
375
|
+
"""Put IRIS back on the level we report, or return None to carry on.
|
|
376
|
+
|
|
377
|
+
The slots are bookkeeping and IRIS has one level per session, so ending a
|
|
378
|
+
transaction that had its own level changes what we report without any
|
|
379
|
+
statement having said so. Left alone, `SHOW` would go back to naming a
|
|
380
|
+
level the session is not running at, which is the whole defect this
|
|
381
|
+
module exists to fix — arriving by a different route.
|
|
382
|
+
|
|
383
|
+
An error here is returned to the client rather than swallowed: carrying on
|
|
384
|
+
would mean running the statement at a level we are about to misreport.
|
|
385
|
+
"""
|
|
386
|
+
if session_id is None:
|
|
387
|
+
return None
|
|
388
|
+
state = self._isolation_sessions.get(session_id)
|
|
389
|
+
if state is None or state.iris_level is None or state.in_transaction:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
wanted = self.get_isolation_level(session_id)
|
|
393
|
+
if wanted == state.iris_level:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
request = parse_set_isolation(f"SET TRANSACTION ISOLATION LEVEL {wanted}")
|
|
397
|
+
if request is None or not request.supported: # pragma: no cover - defensive
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
logger.info(
|
|
401
|
+
"Resyncing IRIS to the level now in force",
|
|
402
|
+
was=state.iris_level,
|
|
403
|
+
now=wanted,
|
|
404
|
+
session_id=session_id,
|
|
405
|
+
)
|
|
406
|
+
result = await self.execute_query( # type: ignore[attr-defined]
|
|
407
|
+
request.iris_sql, session_id=session_id, _skip_isolation=True, **kwargs
|
|
408
|
+
)
|
|
409
|
+
if not result.get("success"):
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
state.iris_level = wanted
|
|
413
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Authentication components that are not wired into the server.
|
|
2
|
+
|
|
3
|
+
What the server actually does is in ``iris_pgwire.password_auth``: the client sends its IRIS
|
|
4
|
+
password and the server checks it by logging in to IRIS.
|
|
5
|
+
|
|
6
|
+
This package once also held SCRAM-SHA-256, OAuth 2.0 and IRIS Wallet code (feature 024). It was
|
|
7
|
+
removed on 2026-10-24. None of it was ever reachable in a way that verified anything: the SCRAM
|
|
8
|
+
exchange accepted any client proof, handed the next stage the literal password
|
|
9
|
+
``"placeholder_password"`` and answered with a constant server signature. It is in git history,
|
|
10
|
+
and ``docs/adr/0003-password-auth-verified-by-iris-login.md`` records why it was not kept.
|
|
11
|
+
|
|
12
|
+
What remains is the Kerberos/GSSAPI authenticator, which is also not wired in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .gssapi_auth import (
|
|
16
|
+
GSSAPIAuthenticator,
|
|
17
|
+
KerberosAuthenticationError,
|
|
18
|
+
KerberosConfig,
|
|
19
|
+
KerberosPrincipal,
|
|
20
|
+
KerberosTimeoutError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"GSSAPIAuthenticator",
|
|
25
|
+
"KerberosAuthenticationError",
|
|
26
|
+
"KerberosConfig",
|
|
27
|
+
"KerberosPrincipal",
|
|
28
|
+
"KerberosTimeoutError",
|
|
29
|
+
]
|
|
@@ -44,8 +44,11 @@ class DdlErrorHandler:
|
|
|
44
44
|
DdlResult indicating if the operation succeeded, was skipped, or failed
|
|
45
45
|
"""
|
|
46
46
|
error_msg = str(error)
|
|
47
|
+
# iris.sql.exec raises SQLError with a .sqlcode attribute; -324 is the
|
|
48
|
+
# IRIS SQLCODE for "already has index named" (not always present in str(e)).
|
|
49
|
+
sqlcode = getattr(error, "sqlcode", None)
|
|
47
50
|
|
|
48
|
-
if self._is_duplicate_error(error_msg):
|
|
51
|
+
if sqlcode == -324 or self._is_duplicate_error(error_msg):
|
|
49
52
|
if self.has_if_not_exists(sql):
|
|
50
53
|
obj_name = self.extract_object_name(sql)
|
|
51
54
|
obj_type = self._extract_object_type(sql)
|