Skip to content

Connection-level defaults for statement properties (starting with local_time_zone) #168

Description

Connection-level defaults for statement properties (starting with local_time_zone)

Motivation

Unlike psycopg/asyncpg/mysql, this driver has no persistent session. Those drivers hold a live
connection to the server, so a session parameter like timezone is set once and the server carries
it across every subsequent statement. Our Connection is a client-side simulation: each statement is
an independent REST submission carrying its own spec.properties, and nothing server-side remembers
anything between them. There is no session GUC to set.

So the only way to get session-like behavior -- the same local_time_zone on every statement a
workload runs -- is for the driver to re-apply the value on each submission. This isn't a
convenience layered on top of a session; in the absence of a session it is the mechanism. The proof
is already in the codebase: sql.current-catalog and sql.current-database are re-stamped from
connection state onto every statement for exactly this reason. This issue generalizes that
established move to caller-facing, caller-overridable defaults, with local_time_zone first.

Proposed API

A connect() / Connection() keyword argument that seeds a read/write Connection.local_time_zone
property. The kwarg is the familiar, eagerly-validated, discoverable entry point -- mirroring the
true siblings database and compute_pool_id, the other connection-level inputs stamped onto every
statement submission (database becomes sql.current-database exactly as local_time_zone would
become sql.local-time-zone). The property lets a caller read the current value back and, if they
want, change it for later statements:

conn = connect(..., local_time_zone="America/New_York")
cursor.execute("SELECT LOCALTIMESTAMP")    # renders in America/New_York, no per-call property
conn.local_time_zone = "America/Chicago"   # affects statements submitted after this line

Unset by default (None) -> nothing emitted, so behavior is unchanged for callers who never touch it
(server default of UTC still applies).

On having both a kwarg and a writeable property: this is not two competing spellings of one
value. The epic's "exactly one way to spell a property" guard is about extra colliding with a typed
field inside a single construction -- a genuine ambiguity we raise on. Here there is one piece of
connection state with an init path (the kwarg) and a read/mutate path (the property); last write wins,
unambiguously. The connect-vs-mutable question that matters for session-holding drivers is moot for us
anyway: with no session, connect-time carries no server semantics, so both paths reduce to the same
"stamp onto future submissions" behavior. The one caveat to document: mutating the property affects
only statements submitted afterward -- there is no server-side session setting that changes
retroactively.

Blending / precedence

During statement-property determination, layers combine low-to-high, each overriding the last:

  1. Server defaults -- implicit; nothing emitted.
  2. Connection-level defaults (local_time_zone, ...) -- emitted only when set on the connection.
  3. Per-call properties (the properties= dict or StatementProperties) -- the caller's explicit
    per-statement intent; overrides the connection default for that key.
  4. Driver system overlay (sql.current-catalog / -database / snapshot.mode) -- always
    applied, non-overridable, unchanged.

So a connection default fills in only where the per-call properties are silent, and never displaces
either an explicit per-call value or the driver overlay. The reserved-key rejection continues to
apply to the per-call (caller) layer only.

Constraints

  • Only non-driver-owned properties are eligible for connection-level defaults. The reserved trio
    (catalog/database/snapshot.mode) is already connection/execution-derived and stays in the system
    overlay; a connection default must never be able to set one.
  • Start with local_time_zone only. The mechanism should be shaped to generalize, but each
    additional default (candidates: state_ttl, scan_startup_mode) needs its own judgment that a
    connection-wide value is actually meaningful for it -- don't blanket-promote every field.

Open questions

  • Shape of the store: individual typed properties (Connection.local_time_zone: str | None) as
    sketched, versus the connection holding a single StatementProperties of defaults and blending its
    to_properties_dict() in at layer 2. The latter reuses Frozen dataclass StatementProperties #163 wholesale and generalizes for free;
    the former is a smaller, more discoverable surface. Lean: start with the individual property, keep
    the door open to the held-object form if more defaults arrive.
  • Eager vs post-construction? Resolved (see Proposed API): a connect() kwarg that seeds a
    read/write Connection.local_time_zone property -- both, init + read/mutate over one piece of
    state, not competing spellings. Matches the database/compute_pool_id siblings for the kwarg.
  • Forcing the server default per statement: with None meaning "unset", a caller who wants one
    statement to ignore the connection default falls back to the explicit value (local_time_zone="UTC")
    rather than a sentinel. Confirm that's sufficient and no "reset to server default" sentinel is
    needed.
  • Mutability / thread-safety: the property is mutable across the connection's life and affects
    only subsequently submitted statements. That's consistent with threadsafety = 1 (connections are
    not shared across threads); note it in the docstring.

Prior art in other Python DB drivers

Session parameters like timezone are configured at connect time across the ecosystem; none of
these expose a mutable post-construction attribute as the primary knob (post-connection changes are
done by running SET/set_config SQL):

  • mysql-connector-python -- dedicated per-property connect kwarg: connect(time_zone="PST")
    issues SET time_zone at connection time (alongside init_command, sql_mode). Precedent for the
    individual-typed-kwarg form.
  • asyncpg -- connect(server_settings={"timezone": "UTC", ...}), a dict of arbitrary PostgreSQL
    GUCs applied at connect. Precedent for the held-defaults-object form (a connection carrying a
    bag of statement-property defaults, i.e. the StatementProperties-of-defaults alternative above).
  • psycopg 3 -- no dedicated timezone setter; you pass libpq options="-c timezone=..." at connect
    or run SELECT set_config('TimeZone', ..., false). Notably it exposes the effective, resolved
    value read-only as conn.info.timezone (a zoneinfo.ZoneInfo) -- setting and introspection are
    separate concerns.

Takeaways for this issue:

  • Favor a connect() kwarg as the primary surface (matches mysql/asyncpg/psycopg); a mutable
    Connection.local_time_zone property (our http_user_agent pattern) can ride along as a
    convenience but isn't the ecosystem norm.
  • The held-defaults form generalizes exactly like asyncpg's server_settings -- worth keeping in
    view if more than one default lands.
  • These drivers have no per-statement "blend" because they don't need one: a real session holds the
    GUC server-side, and a one-off override is a SQL SET LOCAL inside a transaction. We have no
    session (see Motivation), so the connection-default-plus-per-call blend isn't us going beyond the
    pattern -- it's us reconstructing, client-side and per submission, the session state those drivers
    get for free. Borrow their connect-time surface, not their assumption of a persistent session.

Testing

  • Unit: connection default reaches the wire when the call omits the key; a per-call value overrides
    the connection default; the driver overlay still wins; unset connection default emits nothing.
  • Integration: the local_time_zone round-trip (as in test_local_time_zone_shifts_naive_timestamp)
    but driven by Connection.local_time_zone instead of a per-call StatementProperties.

Relationships

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions