Skip to content

Latest commit

 

History

History
335 lines (243 loc) · 20.1 KB

File metadata and controls

335 lines (243 loc) · 20.1 KB

Project: ponylang/postgres

Pure Pony PostgreSQL driver. Alpha-level. Version 0.2.2.

Building and Testing

make ssl=3.0.x                        # build and run all tests
make unit-tests ssl=3.0.x             # unit tests only (no postgres needed)
make integration-tests ssl=3.0.x      # integration tests (needs postgres)
make build-examples ssl=3.0.x         # compile examples
make start-pg-containers              # docker postgres:14.5 on ports 5432 (plain) and 5433 (SSL)
make stop-pg-containers               # stop docker containers

SSL version is mandatory. Tests run with --sequential. Integration tests require running PostgreSQL 14.5 containers with MD5 auth (user: postgres, password: postgres, database: postgres) — one plain on port 5432 and one with SSL on port 5433. Environment variables: POSTGRES_HOST, POSTGRES_PORT, POSTGRES_SSL_HOST, POSTGRES_SSL_PORT, POSTGRES_USERNAME, POSTGRES_PASSWORD, POSTGRES_DATABASE.

Dependencies

  • ponylang/ssl 1.0.1 (MD5 password hashing via ssl/crypto, SSL/TLS via ssl/net)
  • ponylang/lori 0.7.2 (TCP networking, STARTTLS support)

Managed via corral.

GitHub Labels

  • changelog - added, changelog - changed, changelog - fixedPR-only labels. CI uses these to auto-generate CHANGELOG entries on merge. Never apply to issues.
  • bug, help wanted, good first issue, documentation, etc. — issue classification labels.

Architecture

Session State Machine

Session actor is the main entry point. Implements lori.TCPConnectionActor and lori.ClientLifecycleEventReceiver. State transitions via _SessionState interface with four concrete states:

_SessionUnopened  --connect (no SSL)-->  _SessionConnected
_SessionUnopened  --connect (SSL)-->     _SessionSSLNegotiating
_SessionUnopened  --fail-->              _SessionClosed
_SessionSSLNegotiating --'S'+TLS ok-->   _SessionConnected
_SessionSSLNegotiating --'N'/fail-->     _SessionClosed
_SessionConnected --auth ok-->           _SessionLoggedIn
_SessionConnected --auth fail-->         _SessionClosed
_SessionLoggedIn  --close-->             _SessionClosed

State behavior is composed via a trait hierarchy that mixes in capabilities and defaults:

  • _ConnectableState / _NotConnectableState — can/can't receive connection events
  • _ConnectedState / _UnconnectedState — has/doesn't have a live connection
  • _AuthenticableState / _NotAuthenticableState — can/can't authenticate
  • _AuthenticatedState / _NotAuthenticated — has/hasn't authenticated

_SessionSSLNegotiating is a standalone class (not using _ConnectedState) because it handles raw bytes — the server's SSL response is not a PostgreSQL protocol message, so _ResponseParser is not used. It mixes in _NotConnectableState, _NotAuthenticableState, and _NotAuthenticated.

This design makes illegal state transitions call _IllegalState() (panic) by default via the trait hierarchy, so only valid transitions need explicit implementation.

Query Execution Flow

  1. Client calls session.execute(query, ResultReceiver) where query is SimpleQuery, PreparedQuery, or NamedPreparedQuery; or session.prepare(name, sql, PrepareReceiver) to create a named statement; or session.close_statement(name) to destroy one
  2. _SessionLoggedIn queues operations as _QueueItem — a union of _QueuedQuery (execute), _QueuedPrepare (prepare), and _QueuedCloseStatement (close_statement)
  3. The _QueryState sub-state machine manages operation lifecycle:
    • _QueryNotReady: initial state after auth, waiting for server readiness
    • _QueryReady: server is idle, try_run_query dispatches based on queue item type — SimpleQuery transitions to _SimpleQueryInFlight, PreparedQuery and NamedPreparedQuery transition to _ExtendedQueryInFlight, _QueuedPrepare transitions to _PrepareInFlight, _QueuedCloseStatement transitions to _CloseStatementInFlight
    • _SimpleQueryInFlight: owns per-query accumulation data (_data_rows, _row_description), delivers results on CommandComplete
    • _ExtendedQueryInFlight: same data accumulation and result delivery as _SimpleQueryInFlight (duplicated because Pony traits can't have iso fields). Entered after sending Parse+Bind+Describe(portal)+Execute+Sync (unnamed) or Bind+Describe(portal)+Execute+Sync (named)
    • _PrepareInFlight: handles Parse+Describe(statement)+Sync cycle. Notifies PrepareReceiver on success/failure via ReadyForQuery
    • _CloseStatementInFlight: handles Close(statement)+Sync cycle. Fire-and-forget (no callback); errors silently absorbed
  4. Response data arrives: _RowDescriptionMessage sets column metadata, _DataRowMessage accumulates rows
  5. _CommandCompleteMessage triggers result delivery to receiver
  6. _ReadyForQueryMessage dequeues completed operation, transitions to _QueryReady (idle) or _QueryNotReady (non-idle)

Only one operation is in-flight at a time. The queue serializes execution. query_queue and query_state are non-underscore-prefixed fields on _SessionLoggedIn because the _QueryState implementations need cross-type access (Pony private fields are type-private).

Protocol Layer

Frontend (client → server):

  • _FrontendMessage primitive: startup(), password(), query(), parse(), bind(), describe_portal(), describe_statement(), execute_msg(), close_statement(), sync(), ssl_request() — builds raw byte arrays with big-endian wire format

Backend (server → client):

  • _ResponseParser primitive: incremental parser consuming from a Reader buffer. Returns one parsed message per call, None if incomplete, errors on junk.
  • _ResponseMessageParser primitive: routes parsed messages to the current session state's callbacks. Processes messages synchronously within a query cycle (looping until ReadyForQuery or buffer exhaustion), then yields via s._process_again() between cycles. This prevents behaviors like close() from interleaving between result delivery and query dequeuing. If a callback triggers shutdown during the loop, on_shutdown clears the read buffer, causing the next parse to return None and exit the loop.

Supported message types: AuthenticationOk, AuthenticationMD5Password, CommandComplete, DataRow, EmptyQueryResponse, ErrorResponse, ReadyForQuery, RowDescription, ParseComplete, BindComplete, NoData, CloseComplete, ParameterDescription, PortalSuspended. Extended query acknowledgment messages (ParseComplete, BindComplete, NoData, etc.) are parsed but silently consumed — they fall through the _ResponseMessageParser match without routing since the state machine tracks query lifecycle through data-carrying messages only.

Public API Types

  • Query — union type (SimpleQuery | PreparedQuery | NamedPreparedQuery)
  • SimpleQuery — val class wrapping a query string (simple query protocol)
  • PreparedQuery — val class wrapping a query string + Array[(String | None)] val params (extended query protocol, single statement only)
  • NamedPreparedQuery — val class wrapping a statement name + Array[(String | None)] val params (executes a previously prepared named statement)
  • Result trait — ResultSet (rows), SimpleResult (no rows), RowModifying (INSERT/UPDATE/DELETE with count)
  • Rows / Row / Field — result data. Field.value is FieldDataTypes union
  • FieldDataTypes = (Bool | F32 | F64 | I16 | I32 | I64 | None | String)
  • SessionStatusNotify interface (tag) — lifecycle callbacks (connected, connection_failed, authenticated, authentication_failed, shutdown)
  • ResultReceiver interface (tag) — pg_query_result(Session, Result), pg_query_failed(Session, Query, (ErrorResponseMessage | ClientQueryError))
  • PrepareReceiver interface (tag) — pg_statement_prepared(Session, name), pg_prepare_failed(Session, name, (ErrorResponseMessage | ClientQueryError))
  • ClientQueryError trait — SessionNeverOpened, SessionClosed, SessionNotAuthenticated, DataError
  • SSLMode — union type (SSLDisabled | SSLRequired). SSLDisabled is the default (plaintext). SSLRequired wraps an SSLContext val for TLS negotiation.
  • ErrorResponseMessage — full PostgreSQL error with all standard fields
  • AuthenticationFailureReason = (InvalidAuthenticationSpecification | InvalidPassword)

Type Conversion (PostgreSQL OID → Pony)

In _RowsBuilder._field_to_type():

  • 16 (bool) → Bool (checks for "t")
  • 20 (int8) → I64
  • 21 (int2) → I16
  • 23 (int4) → I32
  • 700 (float4) → F32
  • 701 (float8) → F64
  • Everything else → String
  • NULL → None

Mort Primitives

_IllegalState and _Unreachable in _mort.pony. Print file/line to stderr via FFI and exit. Issue URL: https://github.com/ponylang/postgres/issues.

Test Organization

Tests live in the main postgres/ package (private test classes).

Unit tests (no external dependencies):

  • _TestFrontendMessage* — verify wire format of outgoing messages
  • _TestResponseParser* — verify parsing of individual and sequential backend messages
  • _TestHandlingJunkMessages — uses a local TCP listener that sends junk; verifies session shuts down
  • _TestUnansweredQueriesFailOnShutdown — uses a local TCP listener that auto-auths but never responds to queries; verifies queued queries get SessionClosed failures
  • _TestPrepareShutdownDrainsPrepareQueue — uses a local TCP listener that auto-auths but never becomes ready; verifies pending prepare operations get SessionClosed failures on shutdown
  • _TestSSLNegotiationRefused — mock server responds 'N' to SSLRequest; verifies pg_session_connection_failed fires
  • _TestSSLNegotiationJunkResponse — mock server responds with junk byte to SSLRequest; verifies session shuts down
  • _TestSSLNegotiationSuccess — mock server responds 'S', both sides upgrade to TLS, sends AuthOk+ReadyForQuery; verifies full SSL→auth flow

Integration tests (require PostgreSQL, names prefixed integration/):

  • Connect, ConnectFailure, Authenticate, AuthenticateFailure
  • Query/Results, Query/AfterAuthenticationFailure, Query/AfterConnectionFailure, Query/AfterSessionHasBeenClosed
  • Query/OfNonExistentTable, Query/CreateAndDropTable, Query/InsertAndDelete, Query/EmptyQuery
  • PreparedQuery/Results, PreparedQuery/NullParam, PreparedQuery/OfNonExistentTable
  • PreparedQuery/InsertAndDelete, PreparedQuery/MixedWithSimple
  • PreparedStatement/Prepare, PreparedStatement/PrepareAndExecute, PreparedStatement/PrepareAndExecuteMultiple
  • PreparedStatement/PrepareAndClose, PreparedStatement/PrepareFails, PreparedStatement/PrepareAfterClose
  • PreparedStatement/CloseNonexistent, PreparedStatement/PrepareDuplicateName
  • PreparedStatement/MixedWithSimpleAndPrepared
  • SSL/Connect, SSL/Authenticate, SSL/Query, SSL/Refused

Test helpers: _ConnectionTestConfiguration reads env vars with defaults. Several test message builder classes (_Incoming*TestMessage) construct raw protocol bytes for unit tests.

Known Issues and TODOs in Code

  • rows.pony:43 — TODO: need tests for Rows/Row/Field (requires implementing eq)
  • _test_response_parser.pony:6 — TODO: chain-of-messages tests to verify correct buffer advancement across message sequences

Roadmap

SSL/TLS negotiation is implemented. Pass SSLRequired(sslctx) to Session.create() to enable. Design: discussion #76. Full feature roadmap: discussion #72. CI uses ghcr.io/ponylang/postgres-ci-pg-ssl:latest as a service container for SSL integration tests; built via build-ci-image.yml workflow dispatch or locally via .ci-dockerfiles/pg-ssl/build-and-push.bash.

Supported PostgreSQL Features

SSL/TLS: Optional SSL negotiation via SSLRequired. The driver sends an SSLRequest before authentication. If the server accepts ('S'), the connection is upgraded to TLS via lori's start_tls(). If refused ('N'), connection fails. CVE-2021-23222 mitigated via expect(1) before SSLRequest.

Authentication: MD5 password only. No SCRAM-SHA-256, Kerberos, SASL, GSS, or certificate auth.

Protocol: Simple query protocol and extended query protocol (parameterized queries via unnamed and named prepared statements). Parameters are text-format only; type OIDs are inferred by the server. No COPY, LISTEN/NOTIFY, or function calls.

PostgreSQL Wire Protocol Reference

Message Structure

Every message (except StartupMessage) follows: Byte1(type) | Int32(length including self but not type byte) | payload. All integers are big-endian. Strings are null-terminated.

Extended Query Protocol (Prepared Statements)

Separates query processing into discrete steps with two objects:

  • Prepared Statement: parsed + analyzed query, not yet executable (no parameter values)
  • Portal: prepared statement + bound parameter values, ready to execute (like an open cursor)

Parse — Create Prepared Statement

Parse (P): Byte1('P') Int32(len) String(stmt_name) String(query) Int16(num_param_types) Int32[](param_type_oids)

  • Query must be a single statement. Parameters: $1, $2, ..., $n.
  • stmt_name = "" for unnamed. Named statements persist until Close or session end.
  • Unnamed statement destroyed by next Parse or simple Query.
  • Named statements MUST be Closed before redefinition.
  • OID 0 = let server infer type.

ParseComplete (1): Byte1('1') Int32(4)

Bind — Create Portal

Bind (B): Byte1('B') Int32(len) String(portal_name) String(stmt_name) Int16(num_param_fmt) Int16[](param_fmts) Int16(num_params) [Int32(val_len) Byte[](val)]* Int16(num_result_fmt) Int16[](result_fmts)

  • Format codes: 0=text, 1=binary. Shorthand: 0 entries=all text, 1 entry=applies to all, N entries=one per column.
  • Named portals persist until Close or transaction end. Unnamed destroyed by next Bind/Query/txn end.
  • ALL portals destroyed at transaction end.
  • Query planning typically happens at Bind time.

BindComplete (2): Byte1('2') Int32(4)

Describe — Request Metadata

Describe (D): Byte1('D') Int32(len) Byte1('S'|'P') String(name)

  • 'S' (statement): responds with ParameterDescription then RowDescription/NoData.
  • 'P' (portal): responds with RowDescription/NoData only.

ParameterDescription (t): Byte1('t') Int32(len) Int16(num_params) Int32[](param_oids)

NoData (n): Byte1('n') Int32(4) — for statements that return no rows.

Execute — Run Portal

Execute (E): Byte1('E') Int32(len) String(portal_name) Int32(max_rows)

  • max_rows 0 = no limit. Only applies to row-returning queries.
  • Responses: DataRow* + CommandComplete (done), DataRow* + PortalSuspended (more rows available), EmptyQueryResponse, or ErrorResponse.

PortalSuspended (s): Byte1('s') Int32(4)

Close — Destroy Statement/Portal

Close (C): Byte1('C') Int32(len) Byte1('S'|'P') String(name)

  • Closing a statement implicitly closes all its portals.
  • Not an error to close nonexistent objects.

CloseComplete (3): Byte1('3') Int32(4)

Sync — Synchronization Point

Sync (S): Byte1('S') Int32(4)

  • Commits (success) or rolls back (error) implicit transactions.
  • On error: backend discards messages until Sync, then sends ReadyForQuery.
  • Exactly one ReadyForQuery per Sync.

Flush — Force Output

Flush (H): Byte1('H') Int32(4)

Forces pending output without ending query cycle or producing ReadyForQuery.

Extended Query Typical Flow

Client: Parse → Bind → Describe(portal) → Execute → Sync
Server: ParseComplete → BindComplete → RowDescription → DataRow* → CommandComplete → ReadyForQuery

Equivalence: a simple Query is roughly Parse(unnamed) + Bind(unnamed, no params) + Describe(portal) + Execute(unnamed, 0) + Close(portal) + Sync.

Named vs Unnamed Lifetime

Object Unnamed Named
Statement Until next Parse/Query Until Close or session end
Portal Until next Bind/Query or txn end Until Close or txn end

Pipelining

Multiple extended query sequences can be sent without waiting. Each Sync is a segment boundary. On error in a segment, backend discards remaining messages until Sync, sends ReadyForQuery, then processes next segment independently.

Asynchronous Messages

Can arrive between any other messages (must always handle):

  • NoticeResponse (N): informational, same format as ErrorResponse
  • NotificationResponse (A): LISTEN/NOTIFY — Int32(pid) String(channel) String(payload)
  • ParameterStatus (S): runtime parameter changes

Parameter Encoding

  • Text (0): default. Human-readable via type's I/O functions. Integers as decimal ASCII, booleans as "t"/"f".
  • Binary (1): type-specific, big-endian for multi-byte, IEEE 754 for floats. May vary across PG versions for complex types.

Common Type OIDs

Type OID Type OID
bool 16 varchar 1043
bytea 17 date 1082
int8 20 time 1083
int2 21 timestamp 1114
int4 23 timestamptz 1184
text 25 interval 1186
json 114 numeric 1700
float4 700 uuid 2950
float8 701 jsonb 3802

Complete Message Type Bytes

Frontend: Q=Query, P=Parse, B=Bind, D=Describe, E=Execute, C=Close, S=Sync, H=Flush, X=Terminate, p=PasswordMessage

Backend: R=Auth, K=BackendKeyData, S=ParameterStatus, Z=ReadyForQuery, T=RowDescription, D=DataRow, C=CommandComplete, I=EmptyQueryResponse, 1=ParseComplete, 2=BindComplete, 3=CloseComplete, t=ParameterDescription, n=NoData, s=PortalSuspended, E=ErrorResponse, N=NoticeResponse, A=NotificationResponse

File Layout

postgres/                         # Main package (29 files)
  session.pony                    # Session actor + state machine traits + query sub-state machine
  ssl_mode.pony                   # SSLDisabled, SSLRequired, SSLMode types
  simple_query.pony               # SimpleQuery class
  prepared_query.pony             # PreparedQuery class
  named_prepared_query.pony       # NamedPreparedQuery class
  query.pony                      # Query union type
  result.pony                     # Result, ResultSet, SimpleResult, RowModifying
  result_receiver.pony            # ResultReceiver interface
  prepare_receiver.pony           # PrepareReceiver interface
  session_status_notify.pony      # SessionStatusNotify interface
  query_error.pony                # ClientQueryError types
  error_response_message.pony     # ErrorResponseMessage + builder
  field.pony                      # Field class
  field_data_types.pony           # FieldDataTypes union
  row.pony                        # Row class
  rows.pony                       # Rows, RowIterator, _RowsBuilder
  _frontend_message.pony          # Client-to-server messages
  _backend_messages.pony          # Server-to-client message types
  _message_type.pony              # Protocol message type codes
  _response_parser.pony           # Wire protocol parser
  _response_message_parser.pony   # Routes parsed messages to session state
  _authentication_request_type.pony
  _authentication_failure_reason.pony
  _md5_password.pony              # MD5 password construction
  _mort.pony                      # Panic primitives
  _test.pony                      # Main test actor + integration tests + SSL negotiation tests
  _test_query.pony                # Query integration tests
  _test_response_parser.pony      # Parser unit tests + test message builders
  _test_frontend_message.pony     # Frontend message unit tests
assets/test-cert.pem              # Self-signed test certificate for SSL unit tests
assets/test-key.pem               # Private key for SSL unit tests
examples/README.md                # Examples overview
examples/query/query-example.pony # Simple query with result inspection
examples/ssl-query/ssl-query-example.pony # SSL-encrypted query with SSLRequired
examples/prepared-query/prepared-query-example.pony # PreparedQuery with params and NULL
examples/named-prepared-query/named-prepared-query-example.pony # Named prepared statements with reuse
examples/crud/crud-example.pony   # Multi-query CRUD workflow
.ci-dockerfiles/pg-ssl/           # Dockerfile for SSL-enabled PostgreSQL CI container