Parameter Binding¶
The driver-side parameter-binding contract for pycubrid 1.x.
This document is the authoritative specification for how pycubrid converts
Python values into SQL literals before sending them to CUBRID. Every claim in
this document is backed by a citation to the implementation
(pycubrid/_cursor_common.py) and to a unit test that pins the behavior. Any
change to the rules below is a contract change governed by
RELEASE_POLICY.md.
Table of Contents¶
- Overview
- Placeholder Style
- Type Mapping (Guarantees)
- Decimal parameters
- Typed collection parameters
- Prepared host-variable mapping
- String Escaping
- Escape-mode negotiation
- Literal mode
- Escape-processing mode
- Placeholder Tokenizer
executemanyexecutemany(..., prepared=True)- Non-Guarantees and Explicit Limits
- Compatibility Policy (1.x)
- Pinned Tests
- References
Overview¶
pycubrid performs driver-side literal binding. When you call
cursor.execute(sql, parameters), the driver:
- Tokenizes
sqlinto segments split on unquoted, uncommented?placeholders (split_on_placeholders,pycubrid/_cursor_common.py:46-119). - Validates that the number of placeholders matches
len(parameters)(pycubrid/_cursor_common.py:199-203). - Converts each Python value to a SQL literal string via
format_parameter(pycubrid/_cursor_common.py:141-181). - Concatenates the segments and the rendered literals into a single SQL string
(
pycubrid/_cursor_common.py:204-208). - Sends the fully-rendered SQL to CUBRID via
PrepareAndExecutePacket(pycubrid/cursor.py:139-150,pycubrid/aio/cursor.py:114-117).
The binding implementation is shared verbatim between the synchronous
(Cursor) and asynchronous (AsyncCursor) paths via CursorParamsMixin
(pycubrid/_cursor_common.py:237-257). There is no behavioral divergence
between sync and async binding; parity is enforced by
tests/test_aio_cursor_parity.py and tests/test_split_placeholders.py.
This is not server-side prepared-statement binding. pycubrid does not send
parameter values as a separate typed payload; the broker receives a complete
SQL text per execute. The one exception on the ordinary cursors is the opt-in
executemany(..., prepared=True) (#839), which
sends typed host variables with PREPARE + EXECUTE_ARRAY. See
Non-Guarantees and Explicit Limits.
The separate, explicit sync-only pycubrid.compat.native prepared cursor
does send typed values (INT, BIGINT, DOUBLE, NUMERIC, DATE, TIME, DATETIME,
DATETIMETZ, VARBIT, string and SQL NULL) through FC2/FC3; see
Prepared host-variable mapping. It is
not a replacement for this ordinary 1.x contract; see its bounded
#418 typed CAS design and
API reference.
Placeholder Style¶
paramstyle = "qmark"(pycubrid/__init__.py:46), per PEP 249.- Placeholders are positional
?. There are no named, numeric, or pyformat placeholders. - The
parametersargument toexecute()must be aSequenceother thanstr/bytes/bytearray. Mappings are rejected withProgrammingError(pycubrid/_cursor_common.py:194-197). The exact message text is informative; see Non-Guarantees and Explicit Limits. - A placeholder count mismatch raises
ProgrammingError(pycubrid/_cursor_common.py:199-203). The exact message text is informative; see Non-Guarantees and Explicit Limits.
Type Mapping (Guarantees)¶
The following table is the authoritative type-to-literal mapping for 1.x.
Every row cites the implementing function in pycubrid/_cursor_common.py and the
test that pins the behavior.
The exception class raised for each error case (e.g.
ProgrammingError) is part of the contract; the message text shown in the table is illustrative only and may be refined within 1.x. See Non-Guarantees and Explicit Limits.
Temporal subclasses in this table require the active C _datetime
implementation; see the fallback restrictions below.
| Python type | SQL literal | Implementation | Pinned by |
|---|---|---|---|
None |
NULL |
_cursor_common.format_parameter |
tests/test_param_security.py:95-97 |
bool |
1 (True) / 0 (False) |
_cursor_common.format_parameter |
tests/test_param_security.py:98-102 |
int (and subclasses such as IntEnum/IntFlag) |
int.__repr__(value) (decimal digits of the value). See Numeric subclasses |
_cursor_common.format_parameter |
tests/test_param_security.py::TestFormatParameterTypes::test_int, ::test_numeric_subclass_renders_by_value |
float (and subclasses) |
float.__repr__(value) (shortest round-trip form, identical to str() of a plain float, e.g. 1e+20), except -0.0, sent as -0.0e0, and a subnormal value (nonzero, below sys.float_info.min in magnitude), sent as the exact product (m * 2.409919865102884e-181) of two normal doubles (#988); nan/inf/-inf raise ProgrammingError (current message: "nan and inf are not supported by CUBRID") |
_cursor_common.format_parameter |
tests/test_param_security.py::TestFormatParameterTypes::test_float*, ::test_numeric_subclass_renders_by_value; tests/test_integration_float_literal.py |
decimal.Decimal (and subclasses) |
Plain fixed-point digits (format(value, "f") on the value converted to a plain Decimal, unquoted, never E notation); sign, trailing zeros and scale kept; more than 38 literal digits raise DataError; NaN/Infinity raise ProgrammingError (current message: "nan and inf are not supported by CUBRID"); subclasses raise ProgrammingError when the C decimal module is unavailable. See Decimal parameters and Numeric subclasses |
_cursor_common.format_parameter |
tests/test_param_security.py::TestFormatParameterTypes::test_decimal*, ::TestPureDecimalFallback; tests/test_parity_integration.py::TestParityDecimalLiterals |
str (and subclasses) |
Single-quoted literal; escaping per String Escaping, applied to a plain str copy of the value; NUL (U+0000) and Ctrl-Z (U+001A, \x1a) each raise ProgrammingError (current messages: "string parameter contains null byte", "string parameter contains Ctrl-Z (0x1A) byte"). See Text, binary and temporal subclasses |
_cursor_common.format_parameter / _cursor_common.escape_string |
tests/test_param_security.py:27-84, ::TestStrSubclassEscaping |
bytes, bytearray (and subclasses) |
X'<hex>' (lowercase hex, bytes.hex(value) / bytearray.hex(value)) |
_cursor_common.format_parameter |
tests/test_param_security.py:104-106, 144-145, ::TestBinarySubclassRendering |
datetime.datetime (naive, and subclasses) |
DATETIME'YYYY-MM-DD HH:MM:SS.mmm' — year zero-padded to 4 digits; microseconds truncated to milliseconds (microsecond // 1000) |
_cursor_common.format_parameter |
tests/test_param_security.py:124-127, ::TestTemporalSubclassRendering |
datetime.datetime (tz-aware, and subclasses) |
DATETIMETZ'YYYY-MM-DD HH:MM:SS.mmm <tz>' where <tz> is tzinfo.key when present (e.g. Asia/Seoul; for fold=1 in a repeated DST hour followed by the abbreviation, or replaced by the offset, see below), otherwise a ±HH:MM numeric offset. A non-empty key must be a plain str matching [A-Za-z0-9_+/-]+, otherwise ProgrammingError (current message: "time zone key must be an IANA name matching [A-Za-z0-9_+/-]+") |
_cursor_common.format_parameter / _cursor_common._format_tz |
tests/test_param_security.py:147-169, ::TestTzinfoKey |
datetime.date (and subclasses) |
DATE'YYYY-MM-DD' — year zero-padded to 4 digits |
_cursor_common.format_parameter |
tests/test_param_security.py:116-118, ::TestTemporalSubclassRendering |
datetime.time (and subclasses) |
TIME'HH:MM:SS' — microseconds and tzinfo dropped |
_cursor_common.format_parameter |
tests/test_param_security.py:120-122, ::TestTemporalSubclassRendering |
pycubrid.types.Set / Multiset / Sequence |
SET{e1, e2, ...} / MULTISET{...} / SEQUENCE{...} (each keyword renders as KEYWORD{} when empty, e.g. SET{}); each element rendered by the rows of this table with the connection's escape mode. Nested typed collections raise ProgrammingError (current message: "nested collection parameters are not supported"); plain containers as elements are rejected as below. See Typed collection parameters |
_cursor_common.py format_parameter typed-collection branch |
tests/test_typed_collections.py; tests/test_replay_parity.py::typed_collection_parameters, ::executemany_typed_collection_parameters, ::typed_collection_backslash_escape_processing; tests/test_integration_collections.py::TestTypedCollectionParameters |
anything else, including objects that only claim a supported type through __class__ |
ProgrammingError (current message: "unsupported parameter type") |
_cursor_common.format_parameter |
tests/test_param_security.py:128-130, ::TestClassSpoofing; tests/test_cursor.py:233-235 |
Integers are converted directly to decimal strings without conversion to float,
including values such as 10**1000 and -(10**1000) that exceed the float range.
This formatting behavior does not guarantee that CUBRID can store the value;
server numeric limits and Python's integer-to-string conversion limits still apply.
Pinned by tests/test_param_security.py::TestFormatParameterTypes::test_large_int
and ::test_bind_large_int.
Numeric subclasses¶
Subclasses of int, float and decimal.Decimal are rendered from their
numeric value through the base-class methods (int.__repr__, float.__repr__,
and format(Decimal(value), "f")), never through str(), repr() or
format() on the object itself. Before #518 they were rendered with
str(value), so a subclass that overrides __str__ changed the SQL text: on
Python 3.10 enum.IntEnum members were sent as Color.RED and enum.IntFlag
combinations as Perm.R|W, and a user subclass whose __str__ returned
1; DROP TABLE t injected that text verbatim. Color.RED is now sent as 1
and Perm.R | Perm.W as 6. A Decimal subclass is converted to a plain
Decimal first, so overridden __format__, is_nan() or as_tuple() cannot
alter the literal or bypass the NaN/Infinity and 38-digit checks. bool is
checked before int and still renders as 1/0; it cannot be subclassed.
The Decimal copy is only tamper-proof with CPython's C decimal module
(_decimal): the pure-Python fallback (_pydecimal) copies _sign, _int
and _exp through ordinary attribute reads, which a subclass can forge (#528).
When the C module is unavailable, a Decimal subclass raises
ProgrammingError; a plain Decimal is still accepted.
Pinned by tests/test_param_security.py::TestFormatParameterTypes::test_numeric_subclass_renders_by_value,
::TestPureDecimalFallback and tests/test_parity_integration.py::TestParityNumericSubclassLiterals.
Text, binary and temporal subclasses¶
Subclasses of str, bytes, bytearray, datetime.datetime,
datetime.date and datetime.time are rendered from their stored value, never
through methods the subclass can override (#528):
str: the value is first copied to a plainstr(str.__str__(value)called on the base class), and the NUL/Ctrl-Z checks and escaping run on that copy. An overriddenreplace(),__contains__(),__str__()or__format__()is never called. Before #528 a subclass whosereplace()returnedx'; DROP TABLE users; --had that text sent unescaped.bytes/bytearray:bytes.hex(value)/bytearray.hex(value)read the buffer directly; an overriddenhex()or__bytes__()is never called.- Dates and times: the literal is built from the integer fields read through
the base-class descriptors (
datetime.date.year,datetime.datetime.hour, ...) with explicit zero padding, instead ofstrftime(). Overridingstrftime(),isoformat()or theyear/hour/... properties does not change the literal. The UTC offset comes fromdatetime.datetime.utcoffset()called on the base class, and itsdays/seconds/microsecondsfields are read the same way. - Without the active C
_datetimeimplementation, its pure-Python descriptors read attributes such as_yearand_seconds, which subclasses can forge.date,datetimeandtimesubclasses therefore raiseProgrammingErrorbefore any field read or timezone callback; pass exact base-class values. Atimedeltasubclass returned bytzinfo.utcoffset()is also rejected before key lookup or driver offset-field reads. Plain fallback temporal values and plaintimedeltaoffsets remain accepted; C-backed subclasses are unchanged. - Years below 1000 are zero-padded to four digits (
date(99, 1, 2)is sent asDATE'0099-01-02').strftime("%Y")does not pad them on Linux, and CUBRID readsDATE'99-01-02'as 1999-01-02, so two-digit years were silently stored with the wrong year (#519); one- and three-digit years happened to round-trip and are unchanged in value. tzinfo.key: a non-empty key is spliced into theDATETIMETZliteral only when it is a plainstrmatching[A-Za-z0-9_+/-]+(every IANA name, such asAsia/Seoul,Etc/GMT+5orAmerica/Port-au-Prince, qualifies); anything else raisesProgrammingError. A missing,Noneor empty key still falls back to the numeric±HH:MMoffset.- Numeric offsets must be a whole number of minutes (#898). An offset with
seconds or microseconds, such as
+05:30:45, raisesDataErrorin the literal and prepared paths instead of being truncated to+05:30, which stored an instant 45 seconds off. Atzinfo.keyregion is unaffected. - Repeated wall time (#819): CUBRID resolves a region-only literal in a
repeated DST hour to the first occurrence, so a
fold=0value keeps the region-only literal (America/New_York), byte-identical to earlier releases. When thefold=0offset of the value's wall time is larger than thefold=1offset andvalue.foldis 1, the literal names the second occurrence:<key> <abbreviation>when bothtzname()results are plainstrs that differ and the second one matches[A-Za-z]{3,6}(America/New_York EST), otherwise the±HH:MMoffset of the second occurrence alone (-05:00), which loses the region but keeps the instant. Atzname()result is never spliced into SQL unless it matches that pattern. Other wall times keep the region alone. A skipped wall time (spring forward,fold=1offset larger) is also sent with the region alone; CUBRID 10.2 and 11.4 reject it (Invalid utime) in every literal form. - Ordinary exceptions from timezone callbacks, key lookup or offset-field
reads raise
ProgrammingError(current message:"invalid tzinfo on datetime parameter") with the original exception as the cause, without formatting caller-controlled exception text. Invalid key values keep their specific error above, and an offset ofNonestill renders a naiveDATETIMEwithout reading the key.BaseExceptioninterruptions propagate.
Dispatch uses type(value), not isinstance(), which also trusts an
overridden __class__. An object that only claims to be one of the supported
types through __class__ (for example a transparent proxy) raises
ProgrammingError("unsupported parameter type"); unwrap it before binding.
escape_string() likewise raises ProgrammingError for a non-str argument.
Plain values render exactly as before, except the year padding.
Pinned by tests/test_param_security.py::TestPlainLiteralsUnchanged,
::TestStrSubclassEscaping, ::TestBinarySubclassRendering,
::TestTemporalSubclassRendering, ::TestTzinfoKey, ::TestHostileTzinfo,
::test_temporal_subclasses_in_real_pure_python_fallback, ::TestClassSpoofing,
and live on CUBRID 10.2 and 11.4 (sync and async) by
tests/test_parity_integration.py::TestParityLiteralHardening. The repeated
DST hour behavior is pinned by tests/test_param_security.py::TestRepeatedWallTime
and live on CUBRID 10.2 and 11.4 (sync and async, DATETIMETZ and
TIMESTAMPTZ) by
tests/test_integration_timezone.py::test_repeated_hour_binds_both_occurrences.
Decimal parameters¶
A finite decimal.Decimal is rendered in plain fixed-point notation, never with
an exponent: Decimal("1E-7") becomes 0.0000001, Decimal("1E+5") becomes
100000. CUBRID parses a numeric literal written with E as DOUBLE, so the
previous str(value) rendering (1E-7) silently turned such values into
floating point and lost digits on insert (#517). The sign, trailing zeros and
scale are kept as written: Decimal("1.10") is sent as 1.10 (CUBRID types it
NUMERIC(3,2)) and Decimal("-0.00") as -0.00.
CUBRID accepts a plain numeric literal of at most 38 digits (the NUMERIC
maximum precision) and rejects a longer one with error -494 "Invalid
numeric". The digit count is that of the rendered literal: every digit of a
nonzero integer part plus every fractional digit, including leading fractional
zeros (0.0000001 has 7) and trailing zeros; a lone 0 integer part is not
counted. A Decimal whose plain literal would need more than 38 digits raises
DataError before anything is sent, instead of falling back to DOUBLE. That
covers Decimal("1E-39"), 39 significant digits and any huge exponent such as
Decimal("1E+999999999"), which is rejected without being expanded. Round or
quantize such values before binding, or bind a float if DOUBLE semantics
are intended.
A fractional literal is NUMERIC(p,s) on the server. An integral value without
fractional digits (Decimal("42"), Decimal("1E+5")) renders as a plain
integer literal, which CUBRID types by magnitude (INTEGER, BIGINT or
NUMERIC(p,0)); the value stays exact. When the target column has a smaller
scale than the literal, CUBRID rounds on assignment as for any literal (see
Non-Guarantees). NaN and Infinity
still raise ProgrammingError.
Pinned by tests/test_param_security.py::TestFormatParameterTypes::test_decimal_plain_notation,
::test_decimal_precision_38_accepted, ::test_decimal_precision_over_38_raises
and ::test_bind_decimal_plain_notation, and live on CUBRID 10.2 and 11.4 (sync
and async) by tests/test_parity_integration.py::TestParityDecimalLiterals.
Typed collection parameters¶
Wrap the elements in pycubrid.types.Set, Multiset or Sequence (also
exported as pycubrid.Set, pycubrid.Multiset, pycubrid.Sequence) to bind a
CUBRID collection through an ordinary sync or async cursor (#567):
from pycubrid.types import Multiset, Sequence, Set
cur.execute(
"INSERT INTO t (tags, words, steps) VALUES (?, ?, ?)",
(Set([1, 2, 3]), Multiset(["a", "a"]), Sequence([3, 1, 2])),
)
cur.execute("SELECT id FROM t WHERE tags SUBSETEQ ?", (Set([1, 2, 3, 4]),))
- Each type takes one iterable and stores its elements as a
tuple(.elements); the objects are immutable, compare equal only to the same type with equal elements, and cannot be subclassed. A singlestr,bytesorbytearrayargument raisesTypeErrorinstead of being split into characters. - The literal keyword follows the type:
SET{...},MULTISET{...},SEQUENCE{...}(CUBRID'sLIST{...}is the same type). The server applies the collection semantics:SETdrops duplicates,MULTISETkeeps duplicates but not their order,SEQUENCEkeeps both. - Every element goes through the same override-proof renderer as a scalar
parameter, so the element types are exactly the scalar rows of the table above
(
None,bool,int,float,Decimal,str,bytes,bytearray,date,time,datetime), including their subclass hardening. The server converts the elements to the column's element type, as for a literal. - Nested collections are rejected (
ProgrammingError): a typed collection inside another, or a plainlist/tuple/set/frozenset/dictelement. - A
dictis rejected at construction time (TypeError) for all three classes: iterating it would silently use only its keys and drop the values.Sequenceadditionally rejects aset/frozenset(TypeError): their iteration order is not guaranteed, which would makeSequence's element order nondeterministic between runs.SetandMultisetaccept aset/frozensetsince their own server-side semantics do not depend on input order. executemany()accepts typed collections in each parameter set, including through the DML batch path (EXECUTE_BATCH).- The instances are immutable and safe to
copy.copy()(always returns the same object),copy.deepcopy()(the same object when every element is itself immutable; an independent copy, with independently copied elements, when an element such asbytearrayis mutable) andpickle; re-invoking__init__on an existing instance cannot mutate it either. - Fetching is unchanged: with
decode_collections=TrueaSETcolumn still decodes tofrozensetandMULTISET/SEQUENCEtolist(rawbytesotherwise). Decoded values are not wrapped back into these types; wrap them again (for exampleSet(row[0])) to bind them.
Explicitly unsupported as a bound value¶
datetime.timedelta— no branch; raisesProgrammingError("unsupported parameter type").pycubrid.Lob—Lobinstances are not converted to SQL literals. Insert rawbytesforBLOB/BIT-typed columns; for large object workflows useConnection.create_lob()plus the LOB write API (pycubrid/lob.py,pycubrid/connection.py:333-339).- Collections (
list,tuple,set,frozenset,dict) as a single bound value — raiseProgrammingErrorwith an actionable message (current text:cannot bind a collection (list/tuple/set/frozenset/dict) as a single parameter; pycubrid does not auto-expand IN (?, ?, ...) — expand the placeholders explicitly in the SQL, or wrap the elements in pycubrid.types.Set, Multiset or Sequence to bind a CUBRID collection). There is no automaticIN (?, ?, ?)expansion; expand placeholders explicitly in the SQL. To bind a CUBRID collection, wrap the elements in a typed collection parameter; the message also says so. - Arbitrary Python objects — raises
ProgrammingError("unsupported parameter type").
Prepared host-variable mapping¶
This section does not change the literal contract above. It describes the
typed values that the explicit pycubrid.compat.native cursor's
bind_param() sends through FC3 (pycubrid/_prepared_binding.py
_encode_prepared_scalar, re-exported from pycubrid.protocol). Since #840 it accepts the same Python types as the
literal path, except typed collections (bind those with bind_set()) and empty
bytes. Each
value is sent as one type byte and its value bytes; nothing is rendered into
SQL. The checks match the literal path: nan/inf raise ProgrammingError, a
Decimal that needs more than 38 digits raises DataError, and forged or
fallback-module subclasses are refused in the same way. Values are read
through the base-class methods, so a subclass cannot change the bytes. Error
messages never contain the value.
| Python value | Literal path | Prepared type and value bytes |
|---|---|---|
None |
NULL |
NULL (0), no value bytes |
bool |
1 / 0 |
INT (8), int32 1 / 0: the literal path's INTEGER value |
int in signed 32-bit range |
decimal digits | INT (8), int32 (unchanged since #439) |
int in signed 64-bit range, outside 32-bit |
decimal digits | BIGINT (21), int64 |
int outside 64-bit, at most 38 digits |
decimal digits (a NUMERIC literal) | NUMERIC (7), decimal text and NUL; more digits raise DataError |
float |
float.__repr__ |
DOUBLE (12), IEEE 754 binary64 |
decimal.Decimal |
fixed-point digits | NUMERIC (7), the same fixed-point text with a lone leading 0 dropped (.5, -.05) and a NUL |
str |
quoted literal | CHAR (1), connection-charset bytes and NUL (unchanged) |
bytes, bytearray |
X'<hex>' |
VARBIT (6), the bytes |
datetime.datetime, naive |
DATETIME'…' |
DATETIME (22), seven int16 fields with milliseconds |
datetime.datetime, aware |
DATETIMETZ'… <tz>' |
DATETIMETZ (31), the seven fields and the same <tz> text, without NUL |
datetime.date |
DATE'…' |
DATE (13), year, month, day and four zero fields |
datetime.time |
TIME'…' |
TIME (14), three zero fields, hour, minute, second and a zero field; tzinfo is ignored, as in the literal path |
Every temporal value uses the 14-byte DATETIME layout, because the broker reads
TIME from offset 6 and does not check the length. Microseconds are truncated to
milliseconds for DATETIME and dropped for TIME, as in the literal path. The
leading zero is dropped from NUMERIC text because the broker counts every
character except the sign and the point as precision, so 0. followed by 38
digits would otherwise fail where the literal succeeds.
Host variables are coerced by the server as host variables, not parsed as SQL
text, so some results differ from the literal path. Verified on CUBRID 10.2,
11.0, 11.2 and 11.4 (tests/test_integration_prepared_wide_types.py):
- Empty bytes.
b""andbytearray()raiseNotSupportedError: the broker reads a zero-length value as NULL, while the literalX''is an empty bit string. - Floats. The literal
1.5is a NUMERIC literal in CUBRID (only exponent forms such as1e-07are DOUBLE); the prepared value is always DOUBLE. Stored column values are the same, but the result type ofSELECT ?and of expressions such as? / 3can differ. CUBRID rejects subnormal literals and reads the literal-0.0as NUMERIC zero, so the literal path sends these two cases as DOUBLE expressions ((m * 2.409919865102884e-181)and-0.0e0) to store the same value as the prepared path (#988). - Server-side conversion errors. A value the target type cannot hold fails
with error -494 (
ProgrammingError, "Cannot coerce host var to type …") instead of the literal path's message. Examples: a BIGINT into an INTEGER column when it is out of range, and a TIME into a DATETIME target. - Typed collections.
Set,MultisetandSequenceare rendered by the literal path but raiseProgrammingErrorinbind_param(); useconnection.set()andbind_set().executemany(..., prepared=True)sends them as collections of string elements (see below). - Integers past 38 digits raise
DataErrorbefore any I/O. The literal path sends the digits and the server rejects them (-494, "Invalid numeric").
The pycubrid.compat.cubriddb wrapper cursors still accept only int, str
and None scalars, so their bool, float, bytes and temporal arguments
keep raising ProgrammingError. Their int arguments reach bind_param(), so
a value past signed 32-bit now binds BIGINT or NUMERIC instead of raising
DataError.
String Escaping¶
String escaping is performed by escape_string
(pycubrid/_cursor_common.py). The behavior depends on the
no_backslash_escapes connection flag
(pycubrid/_connection_common.py). By default the flag is auto-negotiated
from the live server at connect time (see
Escape-mode negotiation); pass it explicitly to
pycubrid.connect(..., no_backslash_escapes=True|False) to override detection.
Automatic detection runs for each newly opened physical session, including
explicit ping(reconnect=True) recovery, before parameterized SQL can use it.
In every mode:
- The literal is wrapped in single quotes.
- NUL (
U+0000) in the input raisesProgrammingError(pycubrid/_cursor_common.py:130-131). This is unconditional and applies in both modes. The exact message text is informative; see Non-Guarantees and Explicit Limits. - Single quotes are doubled (
'→''). - Unicode code points (including non-BMP characters that UTF-16 would encode
as a surrogate pair) are passed through unchanged
(
tests/test_param_security.py::TestEscapeString::test_unicode_passthrough,::test_unicode_non_bmp_passthrough).
Escape-mode negotiation¶
CUBRID's no_backslash_escapes system parameter defaults to yes — a
backslash is an ordinary literal character, not an escape marker
(CUBRID manual, literal.rst:
"An escape using a backslash can be used if you set no_backslash_escapes in
cubrid.conf as no. But this default value is yes."). If the driver blindly
doubled backslashes against such a server, C:\temp\file would be stored as
C:\\temp\\file — silent data corruption (issue #255).
To stay correct against whatever the server is actually configured for, when
no_backslash_escapes is not passed to connect(), the driver probes the
live server once at connect time with SELECT CHAR_LENGTH('\\') (the SQL
literal '\\', two backslash characters):
- result
2→ the server left both backslashes intact → literal mode, so the driver pinsno_backslash_escapes=True(does NOT double backslashes). - result
1→ the server unescaped the pair → escape-processing mode, so the driver pinsno_backslash_escapes=False. - any other value or a probe error → raises
OperationalError. The driver refuses to guess the escape mode, because a wrong value silently corrupts string escaping (and can enable SQL injection). Passno_backslash_escapesexplicitly to skip detection when the probe cannot run.
Passing no_backslash_escapes=True or False explicitly skips the probe and
retains that choice across reconnections. Without an explicit value, a newly
opened physical session is probed before use; a healthy same-session
ping() does not re-probe. A failed probe prevents direct connection setup;
during ping(reconnect=True), it retires the replacement session and returns
False. Neither path guesses a mode or replays interrupted SQL. In the sync and
async paths, parameterized SQL bound before a session replacement is rejected before
send if its session generation changed; the caller must deliberately retry
the operation. Normal CAS_INFO=OUT_TRAN responses keep the session; only a
failed pre-request CHECK_CAS reconnects (#485). Cursors run that check before
rendering parameters; SQL rendered for a session that is replaced afterwards is
rejected before send in sync and async alike. SQL strings passed directly to
executemany_batch() are rendered by the caller and are not generation-fenced.
The 10.2 and
11.4 CUBRID manuals
do not classify no_backslash_escapes as dynamically changeable. This driver
behavior does not imply support for a per-session SET toggle or prove
heterogeneous failover between differently configured servers. An explicit
mode should be used only when every possible target is known to match it.
Literal mode (no_backslash_escapes=True)¶
This is what auto-negotiation selects against a stock CUBRID server
(no_backslash_escapes=yes):
- Single quotes are doubled (
'→''). - Backslashes and control characters are left untouched (the server treats them as ordinary characters, so they round-trip byte-for-byte).
- NUL rejection still applies.
- The result is wrapped in single quotes.
Pinned by tests/test_aio_cursor_parity.py:99-105,
tests/test_backslash_negotiation.py, and the live round-trip suite
tests/test_integration.py::TestBackslashRoundTrip.
Escape-processing mode (no_backslash_escapes=False)¶
Selected when the server runs no_backslash_escapes=no, or when pinned
explicitly. The driver doubles backslashes so the server unescapes them back:
- Backslashes are doubled (
\→\\). - Single quotes are doubled (
'→''). \rand\nare each prefixed with a backslash (\n→\\n, etc.).\x1a(Ctrl-Z) has no safe CUBRID literal escape and raisesProgrammingErrorin both escape modes (see String Escaping).- The result is wrapped in single quotes.
Pinned by tests/test_param_security.py:27-55 and
tests/test_aio_cursor_parity.py:87-96.
Placeholder Tokenizer¶
split_on_placeholders (pycubrid/_cursor_common.py:46-119) parses the SQL
text and returns segments split only on ? characters that appear in
executable SQL — never inside string literals, identifier quotes, or
comments. Specifically the tokenizer recognizes and skips:
- Single-quoted string literals (
'...'), including doubled-quote escapes (''). - Double-quoted identifiers (
"..."), including doubled-quote escapes (""). - Line comments (
-- ... <EOL>). - Block comments (
/* ... */).
A ? inside any of the above is part of the SQL text and is not treated as
a placeholder. This is pinned by tests/test_split_placeholders.py in its
entirety.
The CUBRID broker counts markers itself before running execute()'s statement,
and it skips only comments and single-quoted strings: a ? inside a quoted
identifier ("a?b", `a?b`, [a?b]) counts as a marker there
(get_num_markers in src/broker/cas_execute.c). Such a statement fails with
ProgrammingError (errno=-1007, error_source="cas"), whose message names
this cause, even though the driver rendered it correctly (#990). Rename the
identifier or alias. Pinned by tests/test_error_source.py and, on a live
server, tests/test_integration_quoted_identifier_marker.py. The prepared paths
see the same inflated count, because PREPARE reports the broker's marker count:
executemany('INSERT INTO t (v) SELECT ? AS "q?" FROM db_root', [(5,)],
prepared=True) raises the driver's ProgrammingError "wrong number of
parameters", and [(5, 6)] runs while the broker silently ignores the extra
value (verified on CUBRID 11.4, #992).
The replacement step concatenates segments and rendered literals
(pycubrid/_cursor_common.py:204-208); it never performs a naïve
str.replace("?", ...), so a literal ? in the rendered value of one
parameter cannot inadvertently consume the next placeholder.
A rendered value never joins the SQL text before it into a comment (#893).
When a literal that starts with - (a negative int, float or Decimal,
including -0.0 and Decimal("-0")) directly follows a - in the SQL text,
one space is inserted between them, so x-? with -5 is sent as x- -5
(x - (-5)) instead of x--5, which CUBRID reads as a line comment that drops
the rest of the statement. Nothing else changes: a negative value after any
other character (= -5, IN (-1, -2), LIMIT -1, 1+-2), a non-negative
value after - (1-2) and collection elements (SET{-1, -2}, whose elements
follow { or ,) render exactly as before. No other rendered literal starts
with - or /, or ends with -, / or *, so no value can open --, //
or /* with the surrounding SQL. A placeholder written directly against other
SQL text (1?, ?1, x?) still pastes the value next to it; write a space or
an operator there. Pinned by tests/test_negative_literal_comment.py and, on a
live server, tests/test_integration_negative_literal.py.
executemany¶
For DML verbs (INSERT, UPDATE, DELETE, MERGE), executemany:
- Renders every parameter row with
CursorParamsMixin._bind_parameter_sets()(pycubrid/_cursor_common.py). On the first row it resolves the escape mode, checks the row and splits the SQL on its placeholders, once per call (#837). Every row is then checked and rendered with that split. Each rendered string is byte-identical to what_bind_parameters(sql, params)returns for that row, and the first bad row raises the same error. - Sends the list of rendered SQL strings in a single
BatchExecutePacketthrough the private_executemany_batch()ofCursor(pycubrid/cursor.py) andAsyncCursor(pycubrid/aio/cursor.py).
For non-DML statements, executemany falls back to a per-row execute loop
(Cursor._executemany_loop(), and the loop inside AsyncCursor.executemany()).
Each row is bound independently with the same type-mapping rules above.
executemany(..., prepared=True)¶
With prepared=True (#839) no SQL is rendered. For INSERT, UPDATE,
DELETE and MERGE the cursor sends one PREPARE and one EXECUTE_ARRAY
request (function code 21) whose arguments are one type byte and value per
marker per row, in row order. Values follow the
prepared host-variable mapping, with the
same checks and the same differences from literals (host-variable coercion,
float as DOUBLE, empty bytes rejected). In addition:
Set,MultisetandSequenceare sent as collections of STRING elements:strelements as they are,intelements (notbool) as decimal text andNoneas a NULL element; the server converts them to the column's element type. Other element types raiseProgrammingError. AMultisetis sent with the SEQUENCE kind, because the 10.2-11.4 brokers reject the MULTISET bind kind (-454) and a SET kind would drop duplicates.- A
pycubrid.Lobof the same connection is bound by its handle, only with autocommit off. - Every row is encoded before anything is sent; a rejected value names its
position (
rows[i][j]) and never its contents. Each value carries its own type, so a column may hold anintin one row and aDecimalorNonein the next. seq_of_parametersmay be any iterable. A generator is consumed once, and every encoded row is retained before network I/O, so memory use scales with the whole request rather than a streamed chunk. Use finite, bounded batches.- Other statements raise
NotSupportedError; there is no fallback to the literal batch.
Non-Guarantees and Explicit Limits¶
The following behaviors are explicitly outside the contract and may change without a major version bump. They are listed so that callers do not depend on them implicitly.
- Ordinary sync/async cursors do not use server-side prepared binding.
pycubrid.Cursorandpycubrid.aio.AsyncCursorrender parameters into SQL text on the client, so theirexecute()sends a complete SQL string without a typed value payload or statement-handle cache. Only the opt-inexecutemany(..., prepared=True)(#839) sends typed host variables; it prepares the statement once per call and caches no handle across calls. The separate, opt-inpycubrid.compat.nativesync cursor has a narrow typed scalar contract; its performance and plan-cache effects are not claimed here. - Identifiers are not escaped. Code paths that interpolate identifiers
(notably
Cursor.callproc,pycubrid/cursor.py:328-336) embed the identifier into the SQL text without quoting. Application code must validate any identifier it accepts from untrusted input. Parameter binding (?) applies only to values, never to identifiers. - Type-object identity at the binding layer. PEP 249 type objects
(
STRING,BINARY, etc.) describecursor.description; they are not consulted during parameter binding. - Server-side type coercion is not normalized. The driver renders a SQL literal; CUBRID then coerces the literal to the target column type per its own rules. The driver does not adjust precision, scale, or charset to match the destination column.
- No driver-enforced length limits. Strings, byte buffers, and rendered SQL are bounded only by Python memory and CUBRID server limits.
- No automatic
IN-clause expansion. Alist/tuple/setpassed as a single bound value raisesProgrammingError. Expand placeholders in the SQL text yourself (e.g.,f"... WHERE id IN ({','.join('?' * len(ids))})"withparams=tuple(ids)). - Exception messages. The exact text of
ProgrammingErrormessages ("unsupported parameter type", "wrong number of parameters", "string parameter contains null byte", "nan and inf are not supported by CUBRID", "parameters must be a sequence") is not part of the contract. The exception class is; the message text may be refined. - Behavioral subtleties not listed above. Anything not enumerated in
Type Mapping or String Escaping
is not a guarantee. The public-API surface gate
(
scripts/check_public_api.py) does not detect behavioral drift; it only detects structural surface changes. SeeRELEASE_POLICY.md§"What the gate does not detect".
Compatibility Policy (1.x)¶
Within the 1.x line, the following changes are governed by
RELEASE_POLICY.md:
Allowed in a minor (1.y → 1.y+1) release¶
- Adding support for a new Python type (e.g.,
uuid.UUID,datetime.timedelta) — additive only, with a new row added to the Type Mapping table. - Improving an exception message while keeping the exception class unchanged.
- Internal refactoring of
escape_string,format_parameter, orsplit_on_placeholdersthat does not change the produced SQL literal or the raised exception class for any input already covered above. - Adding new connection flags that modify binding behavior, provided the default value preserves the rules in this document.
Requires a major (1.y → 2.0) release¶
- Removing or renaming any row in the Type Mapping table.
- Changing the SQL literal produced for any input already in the table (for
example: switching
bytesfromX'<hex>'to another representation, changing the defaultdatetimeprecision, or changing the tz suffix format). - Tightening or loosening NUL / NaN / Inf rejection.
- Changing the default value of
no_backslash_escapes. - Changing the
paramstylefrom"qmark". - Changing the rejection of
Mappingparameters (i.e., reintroducing or removing named-parameter support).
Deprecation flow¶
Behaviors slated for removal in a future major release are first marked
deprecated in CHANGELOG.md > ### Deprecated, with a documented
replacement and a migration window of at least one minor release before the
major-version removal.
Pinned Tests¶
The following test modules pin the behavior documented above. CI failures in any of these are contract regressions:
tests/test_param_security.py— per-type formatting, NUL rejection, NaN/Inf rejection, datetime tz rendering, Decimal handling, bytes hex rendering.tests/test_split_placeholders.py— placeholder tokenizer behavior across quoted strings, quoted identifiers, line comments, block comments, and integration with_bind_parameters.tests/test_cursor.py(lines 183-235) — end-to-end binding of a multi-type parameter sequence, placeholder-count validation, andMapping/strparameter rejection.tests/test_aio_cursor_parity.py(lines 76-105) — sync/async parity for NUL rejection, default escaping, andno_backslash_escapesmode.tests/test_typed_collections.py— typedSet/Multiset/Sequencerendering, element hardening and nested-collection rejection; sync/async parity intests/test_replay_parity.py::typed_collection_parameters.tests/test_execute_array.py—executemany(..., prepared=True): the EXECUTE_ARRAY request and reply bytes, per-row value encoding and position messages, the statement and LOB rules, and sync/async handle ownership.tests/test_integration_execute_array.py— live prepared batches on CUBRID 10.2-11.4: partial failure, autocommit,rowcount, mixed types, NULLs, collections and LOB values against the literal batch.
Together these tests provide the executable specification of this contract.
References¶
- Implementation:
pycubrid/_cursor_common.py - Sync entry points:
pycubrid/cursor.py - Async entry points:
pycubrid/aio/cursor.py - Connection flag:
pycubrid/_connection_common.py - Release policy:
RELEASE_POLICY.md - Type system (fetch / description side):
docs/TYPES.md - API reference:
docs/API_REFERENCE.md