Mirrors Phase 10's read implementation in the opposite direction —
extends the SQ_FILE (98) handler with optype 2 (read-from-client)
support. Users register bytes in cursor.virtual_files; the server's
filetoblob('path', 'client') call streams them up via SQ_FILE_READ
(106) chunks. Same architectural pivot as Phase 10 — avoids the
heavy SQ_FPROUTINE+SQ_LODATA stack.
Wire protocol (per IfxSqli.receiveSQFILE case 2 line 5103+):
* Server sends [short SQ_FILE=98][short optype=2][short bufSize]
[int readAmount][short SQ_EOT]
* Client responds [short 106][int totalAmount] then chunks
[short 106][short chunkSize][padded data]... terminated by SQ_EOT
API:
* Low-level: cur.virtual_files['/sentinel'] = data, then SQL with
filetoblob('/sentinel', 'client')
* High-level: cur.write_blob_column(sql, blob_data, params, clob=False)
— substitutes BLOB_PLACEHOLDER token in the SQL with filetoblob()
(or filetoclob for CLOB columns) and registers the bytes
automatically. Cleans up virtual_files after the call.
The BLOB_PLACEHOLDER design was chosen over magic ?-binding because:
* bytes already maps to BYTE type (legacy in-row blobs) for ?-params
* Method on BlobLocator doesn't work for inserts (no locator yet)
* PLACEHOLDER is unmistakable at the call site
Closes the smart-LOB loop in pure Python — Phase 9's tests and
Phase 10's read fixtures previously used JDBC to seed test data.
Phase 11 eliminated that dependency: tests/test_smart_lob.py and
tests/test_smart_lob_read.py now self-seed via write_blob_column.
Bonus: integration test runtime 5.78s → 2.78s (no more per-fixture
JVM spawns). Project goal "pure Python, no native deps" now true
for the test suite too.
Tests: 9 integration tests in test_smart_lob_write.py covering
* BLOB short, multichunk (51KB), empty, binary-safe (256 values)
* BLOB UPDATE
* BLOB multi-row INSERTs
* CLOB via filetoclob
* validation (rejects SQL without BLOB_PLACEHOLDER)
* virtual_files cleanup
Total: 64 unit + 126 integration = 190 tests.
133 lines
4.6 KiB
Python
133 lines
4.6 KiB
Python
"""Phase 9 integration tests — smart-LOB BLOB/CLOB locator decoding.
|
|
|
|
Smart-LOB columns (BLOB type 102, CLOB type 101) are presented in the
|
|
SQ_DESCRIBE response as ``UDTFIXED`` (type 41) with extended_id 10 (BLOB)
|
|
or 11 (CLOB) and ``encoded_length=72`` (the locator size). The 72 bytes
|
|
in the SQ_TUPLE are an opaque server-side reference, NOT the actual data.
|
|
|
|
This phase surfaces the locator as a typed :class:`informix_db.BlobLocator`
|
|
or :class:`informix_db.ClobLocator` so users can recognize the column
|
|
type and not mistake the raw 72 bytes for actual content. Retrieving
|
|
the actual bytes requires the ``SQ_FPROUTINE`` + ``SQ_LODATA`` wire
|
|
protocols, deferred to Phase 10.
|
|
|
|
Test data is populated via the JDBC reference client (a Java helper)
|
|
since our driver doesn't yet support smart-LOB writes either.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import dataclasses
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
|
|
import informix_db
|
|
from tests.conftest import ConnParams
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _connect(params: ConnParams) -> informix_db.Connection:
|
|
return informix_db.connect(
|
|
host=params.host,
|
|
port=params.port,
|
|
user=params.user,
|
|
password=params.password,
|
|
database=params.database,
|
|
server=params.server,
|
|
connect_timeout=10.0,
|
|
read_timeout=10.0,
|
|
autocommit=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def blob_table_with_data(
|
|
logged_db_params: ConnParams,
|
|
) -> Iterator[str]:
|
|
"""Create a BLOB table and seed it via Phase 11's write_blob_column.
|
|
|
|
Originally (pre-Phase-11) this fixture used a JDBC helper to seed
|
|
the row because our driver couldn't write smart-LOBs. Phase 11
|
|
eliminated that dependency — pure Python end-to-end.
|
|
"""
|
|
table = "t_blob_test"
|
|
with _connect(logged_db_params) as conn:
|
|
cur = conn.cursor()
|
|
with contextlib.suppress(Exception):
|
|
cur.execute(f"DROP TABLE {table}")
|
|
try:
|
|
cur.execute(f"CREATE TABLE {table} (id INT, data BLOB)")
|
|
except informix_db.Error as e:
|
|
pytest.skip(f"sbspace unavailable ({e!r})")
|
|
cur.write_blob_column(
|
|
f"INSERT INTO {table} VALUES (?, BLOB_PLACEHOLDER)",
|
|
b"hello smart-lob bytes",
|
|
(1,),
|
|
)
|
|
try:
|
|
yield table
|
|
finally:
|
|
with _connect(logged_db_params) as conn:
|
|
cur = conn.cursor()
|
|
with contextlib.suppress(Exception):
|
|
cur.execute(f"DROP TABLE {table}")
|
|
|
|
|
|
def test_blob_column_returns_blob_locator(
|
|
logged_db_params: ConnParams, blob_table_with_data: str
|
|
) -> None:
|
|
"""SELECTing a BLOB column returns a :class:`BlobLocator`."""
|
|
with _connect(logged_db_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(f"SELECT id, data FROM {blob_table_with_data}")
|
|
rows = cur.fetchall()
|
|
assert len(rows) == 1
|
|
assert rows[0][0] == 1
|
|
assert isinstance(rows[0][1], informix_db.BlobLocator)
|
|
assert len(rows[0][1].raw) == 72
|
|
|
|
|
|
def test_blob_column_description_metadata(
|
|
logged_db_params: ConnParams, blob_table_with_data: str
|
|
) -> None:
|
|
"""``cursor.description`` for BLOB column reports type=41 (UDTFIXED) size=72."""
|
|
with _connect(logged_db_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(f"SELECT id, data FROM {blob_table_with_data} WHERE 1=0")
|
|
# description is (name, type_code, display_size, internal_size,
|
|
# precision, scale, null_ok)
|
|
assert cur.description is not None
|
|
data_col = cur.description[1]
|
|
assert data_col[0] == "data"
|
|
assert data_col[1] == 41 # UDTFIXED
|
|
assert data_col[2] == 72 # display_size = locator size
|
|
|
|
|
|
def test_blob_locator_is_immutable(
|
|
logged_db_params: ConnParams, blob_table_with_data: str
|
|
) -> None:
|
|
"""BlobLocator is frozen: the 72-byte ref can't be reassigned in place."""
|
|
with _connect(logged_db_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(f"SELECT data FROM {blob_table_with_data}")
|
|
(locator,) = cur.fetchone()
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
locator.raw = b"x" * 72 # type: ignore[misc]
|
|
|
|
|
|
def test_blob_locator_repr_is_safe(
|
|
logged_db_params: ConnParams, blob_table_with_data: str
|
|
) -> None:
|
|
"""``repr(locator)`` doesn't leak the raw bytes (which are opaque/internal)."""
|
|
with _connect(logged_db_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(f"SELECT data FROM {blob_table_with_data}")
|
|
(locator,) = cur.fetchone()
|
|
r = repr(locator)
|
|
assert "BlobLocator" in r
|
|
# raw bytes must NOT leak into repr
|
|
assert locator.raw.hex() not in r
|