diff --git a/docs/guides/custom-fields.md b/docs/guides/custom-fields.md index 2f9c6db..b159af1 100644 --- a/docs/guides/custom-fields.md +++ b/docs/guides/custom-fields.md @@ -129,6 +129,51 @@ bindable value), `to_python` (database value → Python value, set `read_identity = False` so it runs on reads) and `to_python_value` (assignment coercion), exactly like the built-in fields do. +### PostgreSQL custom column types: `RawText` + `select_as_text` + +A custom *column type* (pgvector's `vector`, `inet`, `citext`, ...) needs two +extra declarations on PostgreSQL, because the engine's driver knows neither +the type's parameter encoding nor its binary result format: + +- **Writes** — return the value's text rendering wrapped in + `yara_orm.RawText` from `to_db`. A plain `str` binds with a declared `text` + type, which PostgreSQL will not implicitly cast to the column's type + (SQLSTATE 42804); `RawText` binds *untyped*, so the server infers the type + from the target column and parses the text through the type's own input + function. On every other backend `RawText` binds exactly like a plain + string. +- **Reads** — set `select_as_text = True` on the field class. SELECT and + RETURNING projections then read the column through `CAST(col AS text)` on + PostgreSQL, and your `to_python` parses the text form (set + `read_identity = False` alongside). Other backends ignore the flag — there + the column already stores text. + +The complete pgvector field: + +```python +from yara_orm import RawText, fields, register_field_kind + + +class VectorField(fields.Field): + field_kind = "vector" + read_identity = False # to_python runs on reads + select_as_text = True # PostgreSQL reads via CAST(col AS text) + + def __init__(self, dim: int = 3, **kwargs): + super().__init__(**kwargs) + self.type_params = {"dim": dim} + + def to_db(self, value): + if isinstance(value, (list, tuple)): + return RawText("[" + ",".join(map(str, value)) + "]") + return value + + def to_python(self, value): + if isinstance(value, str): + return [float(x) for x in value.strip("[]").split(",") if x] + return value +``` + ## See also - [Models & fields](models-and-fields.md) diff --git a/python/yara_orm/__init__.py b/python/yara_orm/__init__.py index a8d1d6e..9063ca0 100644 --- a/python/yara_orm/__init__.py +++ b/python/yara_orm/__init__.py @@ -48,7 +48,7 @@ class User(Model): UnSupportedError, ValidationError, ) -from .expressions import Array, Case, F, RawSQL, Subquery, Value, When +from .expressions import Array, Case, F, RawSQL, RawText, Subquery, Value, When from .fields import register_field_kind, unregister_field_kind from .functions import Coalesce, Concat, Length, Lower, Random, Trim, Upper from .manager import Manager @@ -76,6 +76,7 @@ class User(Model): "Case", "When", "RawSQL", + "RawText", "Subquery", "Value", "Array", diff --git a/python/yara_orm/dialects.py b/python/yara_orm/dialects.py index ccfbf5d..1a9f31e 100644 --- a/python/yara_orm/dialects.py +++ b/python/yara_orm/dialects.py @@ -235,6 +235,25 @@ def placeholder(self, index: int) -> str: """ raise NotImplementedError + def select_column(self, field: Field, ref: str) -> str: + """Render a column reference for a SELECT/RETURNING projection list. + + The default is the reference unchanged. PostgreSQL overrides this to + read a ``select_as_text`` column through ``CAST(ref AS text)`` (the + engine cannot decode a custom type's binary result format); the cast + keeps the source column's result name, so decode plans are unaffected. + Applies to *projection* positions only — comparison and grouping + references keep the column's real type. + + Args: + field: The field the reference projects. + ref: The already-quoted (possibly table-qualified) column reference. + + Returns: + The projection SQL fragment. + """ + return ref + # -- query lookups ---------------------------------------------------- def date_part_sql(self, part: str, col: str) -> str: """Render an expression extracting a date/time part from a column. @@ -668,7 +687,9 @@ def insert_returning_clause(self, fields: Sequence[Field]) -> str: Returns: The clause fragment with a leading space. """ - return " RETURNING " + ", ".join(self.quote(f.db_column) for f in fields) + return " RETURNING " + ", ".join( + self.select_column(f, self.quote(f.db_column)) for f in fields + ) # -- type rendering --------------------------------------------------- def _kind_template(self, kind: str) -> str: @@ -1756,6 +1777,26 @@ class PostgresDialect(BaseDialect): supports_extensions = True supports_for_update = True + def select_column(self, field: Field, ref: str) -> str: + """Read a ``select_as_text`` column through ``CAST(ref AS text)``. + + The engine requests binary result values, which it cannot decode for a + custom column type (pgvector ``vector``, ``inet``, ...); the cast makes + the server send the text form, which the field's ``to_python`` parses. + PostgreSQL names a bare column cast after the column itself, so named + and positional decode plans see the usual column name. + + Args: + field: The field the reference projects. + ref: The already-quoted (possibly table-qualified) reference. + + Returns: + The projection SQL fragment. + """ + if field.select_as_text: + return f"CAST({ref} AS text)" + return ref + def date_part_sql(self, part: str, col: str) -> str: """Render ``EXTRACT( FROM col)``. diff --git a/python/yara_orm/expressions.py b/python/yara_orm/expressions.py index 9791cda..5cdf704 100644 --- a/python/yara_orm/expressions.py +++ b/python/yara_orm/expressions.py @@ -176,6 +176,24 @@ class Array(list): # noqa: FURB189 - a thin marker subclass, not a full list re """ +class RawText(str): # noqa: FURB189 - a thin marker subclass, not a str reimpl + """A string bound as an *untyped* text parameter on PostgreSQL. + + A plain ``str`` binds with a declared ``text`` type, which PostgreSQL will + not implicitly cast to a custom column type (SQLSTATE 42804) — a pgvector + ``vector`` column rejects a text parameter, for example. Wrapping the text + rendering in ``RawText`` leaves the parameter's type to the server (it is + inferred from context, e.g. the target column) and sends the value in the + text format, so the server parses it through the type's own input + function. Custom field kinds return it from ``to_db``:: + + def to_db(self, value): + return RawText("[" + ",".join(map(str, value)) + "]") + + On every other backend ``RawText`` binds exactly like a plain string. + """ + + class Value(Expression): """A literal value usable where an expression is expected (compat shim). diff --git a/python/yara_orm/fields.py b/python/yara_orm/fields.py index 8025c1a..0ebf6ce 100644 --- a/python/yara_orm/fields.py +++ b/python/yara_orm/fields.py @@ -117,6 +117,14 @@ class Field(Generic[VT]): #: When True, the engine already returns this field's native Python type, so #: the read path can assign DB values directly and skip ``to_python``. read_identity: bool = True + #: When True, PostgreSQL SELECT/RETURNING lists read this column through + #: ``CAST(col AS text)``. The engine requests binary result values, which it + #: cannot decode for a custom column type (pgvector ``vector``, ``inet``, + #: ...); the cast makes the server send the type's text form instead, which + #: ``to_python`` then parses (set ``read_identity = False`` alongside). + #: Other backends ignore the flag. Pair with returning :class:`RawText` + #: from ``to_db`` so writes bind untyped — see the custom-fields guide. + select_as_text: bool = False #: Compatibility flag: every concrete yara field backs a real column, so #: code that branches on ``field.has_db_field`` keeps working. has_db_field: bool = True diff --git a/python/yara_orm/models.py b/python/yara_orm/models.py index 242cf61..3347b8e 100644 --- a/python/yara_orm/models.py +++ b/python/yara_orm/models.py @@ -375,7 +375,9 @@ def compile(self, dialect: BaseDialect) -> None: self._partial_plan_cache = {} self._build_decode_plan() q = dialect.quote - self.columns_sql = ", ".join(q(f.db_column) for f in self.field_list) + self.columns_sql = ", ".join( + dialect.select_column(f, q(f.db_column)) for f in self.field_list + ) self.select_prefix = f"SELECT {self.columns_sql} FROM {q(self.table)}" # Single-row INSERT for the common case of an unset auto-increment pk. diff --git a/python/yara_orm/prefetch.py b/python/yara_orm/prefetch.py index 2fbe8e5..58e1b87 100644 --- a/python/yara_orm/prefetch.py +++ b/python/yara_orm/prefetch.py @@ -384,7 +384,7 @@ async def run_one(owner_id: Any) -> list[Any]: _assign(instances, name, to_attr, {i: grouped.get(i.pk, []) for i in instances}) return - cols = ", ".join(f"{ttbl}.{q(f.db_column)}" for f in tmeta.field_list) + cols = ", ".join(dialect.select_column(f, f"{ttbl}.{q(f.db_column)}") for f in tmeta.field_list) sql = ( f"SELECT {through}.{q(near_key)}, {cols} FROM {ttbl} " f"JOIN {through} ON {ttbl}.{q(tmeta.pk_field.db_column)} = {through}.{q(far_key)} " diff --git a/python/yara_orm/queryset.py b/python/yara_orm/queryset.py index 36a79bb..80e8663 100644 --- a/python/yara_orm/queryset.py +++ b/python/yara_orm/queryset.py @@ -1935,7 +1935,7 @@ def _plain_select_sql( tail = f"{self._order_sql(dialect)}{self._tail_sql(dialect)}{self._lock_sql(dialect)}" if self._only is not None or self._defer: sel = self._selected_fields() - cols = ", ".join(dialect.quote(f.db_column) for f in sel) + cols = ", ".join(dialect.select_column(f, dialect.quote(f.db_column)) for f in sel) prefix = self._distinct_prefix(f"SELECT {cols} FROM {dialect.quote(meta.table)}") return f"{prefix}{where}{tail}", params, sel prefix = self._distinct_prefix(meta.select_prefix) @@ -2097,7 +2097,7 @@ def _select_related_plan( # loaded in full); the JOINs reference the FK columns directly, so # restricting the SELECT does not affect them. base_fields = self._selected_fields() - select = [f"{table}.{q(f.db_column)}" for f in base_fields] + select = [dialect.select_column(f, f"{table}.{q(f.db_column)}") for f in base_fields] joins: list[str] = [] # One node per (possibly nested) relation path, in join order. Each # records its parent path (None = base), the last segment, target model @@ -2140,7 +2140,9 @@ def ensure_path(path: str) -> dict[str, Any]: f"ON {left_alias}.{fk_col} = {alias}.{q(tmeta.pk_field.db_column)}" ) proj_fields, partial = self._related_projected_fields(path, tmeta) - select.extend(f"{alias}.{q(f.db_column)}" for f in proj_fields) + select.extend( + dialect.select_column(f, f"{alias}.{q(f.db_column)}") for f in proj_fields + ) node = { "parent": parent, "seg": seg, @@ -2509,7 +2511,7 @@ async def _fetch_columns(self, field_paths: tuple[str, ...]) -> list[Any]: # One traversal per path yields both the column reference (for SQL) and # the terminal field (for the read decoder). resolved = [self._resolve_column_field(p, dialect, joins) for p in field_paths] - cols = ", ".join(col for col, _ in resolved) + cols = ", ".join(dialect.select_column(field, col) for col, field in resolved) where, params, _ = self._compile_conditions(dialect) table = dialect.quote(meta.table) distinct = "DISTINCT " if self._distinct else "" diff --git a/rust/src/backend/mssql.rs b/rust/src/backend/mssql.rs index 08b6fbb..04696be 100644 --- a/rust/src/backend/mssql.rs +++ b/rust/src/backend/mssql.rs @@ -85,7 +85,7 @@ impl ToSql for Param<'_> { Value::Bool(b) => ColumnData::Bit(Some(*b)), Value::Int(i) => ColumnData::I64(Some(*i)), Value::Float(f) => ColumnData::F64(Some(*f)), - Value::Text(s) => ColumnData::String(Some(s.as_str().into())), + Value::Text(s) | Value::RawText(s) => ColumnData::String(Some(s.as_str().into())), Value::Bytes(b) => ColumnData::Binary(Some(b.as_slice().into())), Value::Uuid(u) => ColumnData::Guid(Some(*u)), Value::Decimal(d) => d.to_sql(), diff --git a/rust/src/backend/mysql.rs b/rust/src/backend/mysql.rs index d763e7d..eb3b046 100644 --- a/rust/src/backend/mysql.rs +++ b/rust/src/backend/mysql.rs @@ -75,7 +75,7 @@ fn to_my_value(v: &Value) -> MyValue { Value::Bool(b) => MyValue::Int(i64::from(*b)), Value::Int(i) => MyValue::Int(*i), Value::Float(f) => MyValue::Double(*f), - Value::Text(s) => MyValue::Bytes(s.clone().into_bytes()), + Value::Text(s) | Value::RawText(s) => MyValue::Bytes(s.clone().into_bytes()), Value::Bytes(b) => MyValue::Bytes(b.clone()), Value::Json(j) => MyValue::Bytes(j.to_string().into_bytes()), // MySQL has no array type; store as a JSON text array (like SQLite). diff --git a/rust/src/backend/oracle.rs b/rust/src/backend/oracle.rs index 965c0ee..762802b 100644 --- a/rust/src/backend/oracle.rs +++ b/rust/src/backend/oracle.rs @@ -173,7 +173,7 @@ fn to_ora(v: &Value) -> OraValue { Value::Bool(b) => OraValue::Integer(i64::from(*b)), Value::Int(i) => OraValue::Integer(*i), Value::Float(f) => OraValue::Float(*f), - Value::Text(s) => OraValue::String(s.clone()), + Value::Text(s) | Value::RawText(s) => OraValue::String(s.clone()), Value::Bytes(b) => OraValue::Bytes(b.clone()), Value::Json(j) => OraValue::String(j.to_string()), // Oracle has no array type; store as a JSON text array (like SQLite/MySQL). diff --git a/rust/src/value.rs b/rust/src/value.rs index c4340f4..53f3bbf 100644 --- a/rust/src/value.rs +++ b/rust/src/value.rs @@ -17,7 +17,7 @@ use pyo3::types::{ PySet, PyString, PyTime, PyTuple, PyTzInfo, PyTzInfoAccess, }; use pyo3::Borrowed; -use tokio_postgres::types::{to_sql_checked, IsNull, ToSql, Type}; +use tokio_postgres::types::{to_sql_checked, Format, IsNull, ToSql, Type}; use crate::error::EngineError; @@ -49,6 +49,9 @@ static DECIMAL_TYPE: PyOnceLock> = PyOnceLock::new(); // `yara_orm.Array` marks a sequence to bind as a PostgreSQL array (a bare list // binds as JSON). Resolved by import and cached like the scalar types above. static ARRAY_TYPE: PyOnceLock> = PyOnceLock::new(); +// `yara_orm.RawText` marks a string to bind as an untyped text parameter on +// PostgreSQL (custom column types with no implicit cast from `text`). +static RAW_TEXT_TYPE: PyOnceLock> = PyOnceLock::new(); // `enum.Enum` base, to coerce enum members inside a JSON value (their `.value`). static ENUM_TYPE: PyOnceLock> = PyOnceLock::new(); @@ -64,6 +67,10 @@ fn array_type(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> { ARRAY_TYPE.import(py, "yara_orm", "Array") } +fn raw_text_type(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> { + RAW_TEXT_TYPE.import(py, "yara_orm", "RawText") +} + fn enum_type(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> { ENUM_TYPE.import(py, "enum", "Enum") } @@ -101,6 +108,13 @@ pub enum Value { Int(i64), Float(f64), Text(String), + /// A `yara_orm.RawText` string: bound as an *untyped* text parameter so + /// PostgreSQL infers the type from context and parses the text through the + /// target type's input function. This is how a custom field kind (pgvector + /// `vector`, `inet`, `citext`, ...) binds a text rendering into a column + /// that has no implicit cast from `text` (SQLSTATE 42804 otherwise). On + /// every other backend it behaves exactly like `Text`. + RawText(String), Bytes(Vec), Json(serde_json::Value), Array(Vec), @@ -137,6 +151,14 @@ impl<'a, 'py> FromPyObject<'a, 'py> for Value { return Ok(Value::Float(ob.extract::()?)); } if ob.is_instance_of::() { + // ``yara_orm.RawText`` (a str subclass) binds untyped on PostgreSQL + // (server-inferred type, text encoding); the exact-type test keeps + // the plain-str hot path to a single cheap check. + if !ob.is_exact_instance_of::() + && ob.is_instance(raw_text_type(ob.py())?.as_any())? + { + return Ok(Value::RawText(ob.extract::()?)); + } return Ok(Value::Text(ob.extract::()?)); } // datetime must be checked before date (datetime subclasses date). @@ -307,7 +329,7 @@ impl<'py> IntoPyObject<'py> for Value { Value::Bool(v) => v.into_pyobject(py)?.to_owned().into_any(), Value::Int(v) => v.into_pyobject(py)?.into_any(), Value::Float(v) => v.into_pyobject(py)?.into_any(), - Value::Text(v) => v.into_pyobject(py)?.into_any(), + Value::Text(v) | Value::RawText(v) => v.into_pyobject(py)?.into_any(), Value::Bytes(v) => PyBytes::new(py, &v).into_any(), Value::Json(v) => json_to_py(py, &v)?, Value::Array(items) => { @@ -393,7 +415,9 @@ impl Value { Value::Time(_) => Type::TIME, // Defer arrays to the server: the element type comes from the // ``::type[]`` cast or the target column, not from the value alone. - Value::Null | Value::Json(_) | Value::Array(_) => return None, + // RawText is untyped by design — the server infers the target type + // (e.g. a pgvector column) and parses the text form itself. + Value::Null | Value::Json(_) | Value::Array(_) | Value::RawText(_) => return None, }) } @@ -413,7 +437,9 @@ impl Value { Value::TimestampTz(v) => Some(v.to_rfc3339()), Value::Date(v) => Some(v.format(FMT_DATE).to_string()), Value::Time(v) => Some(v.format(FMT_TIME).to_string()), - Value::Null | Value::Text(_) | Value::Bytes(_) | Value::Array(_) => None, + Value::Null | Value::Text(_) | Value::RawText(_) | Value::Bytes(_) | Value::Array(_) => { + None + } } } } @@ -488,6 +514,10 @@ impl ToSql for Value { } v.to_sql(ty, out) } + // Untyped text: the bytes are the value's text form, sent with the + // *text* format code (see `encode_format`), so the server parses + // them through the inferred type's input function. + Value::RawText(v) => v.to_sql(ty, out), Value::Bytes(v) => v.to_sql(ty, out), Value::Json(v) => v.to_sql(ty, out), Value::Array(items) => items.to_sql(ty, out), @@ -506,6 +536,17 @@ impl ToSql for Value { true } + fn encode_format(&self, _ty: &Type) -> Format { + // RawText carries a text rendering of a type the client doesn't know + // (pgvector etc.): declare the text format so the server runs the + // value through the target type's input function. Everything else + // keeps the binary format the `to_sql` arms encode. + match self { + Value::RawText(_) => Format::Text, + _ => Format::Binary, + } + } + to_sql_checked!(); } @@ -627,7 +668,7 @@ pub(crate) fn value_to_json(v: &Value) -> serde_json::Value { // preserve the value as its textual form, like Decimal/Uuid below. Value::Float(f) if !f.is_finite() => J::String(f.to_string()), Value::Float(f) => J::from(*f), - Value::Text(s) => J::String(s.clone()), + Value::Text(s) | Value::RawText(s) => J::String(s.clone()), Value::Json(j) => j.clone(), Value::Array(items) => J::Array(items.iter().map(value_to_json).collect()), Value::Uuid(u) => J::String(u.to_string()), @@ -651,7 +692,7 @@ impl rusqlite::types::ToSql for Value { Value::Bool(b) => ToSqlOutput::Owned(S::Integer(if *b { 1 } else { 0 })), Value::Int(i) => ToSqlOutput::Owned(S::Integer(*i)), Value::Float(f) => ToSqlOutput::Owned(S::Real(*f)), - Value::Text(s) => ToSqlOutput::Borrowed(R::Text(s.as_bytes())), + Value::Text(s) | Value::RawText(s) => ToSqlOutput::Borrowed(R::Text(s.as_bytes())), Value::Bytes(b) => ToSqlOutput::Borrowed(R::Blob(b)), Value::Json(j) => ToSqlOutput::Owned(S::Text(j.to_string())), // SQLite has no array type; store as a JSON text array. diff --git a/tests/test_raw_text_custom_types.py b/tests/test_raw_text_custom_types.py new file mode 100644 index 0000000..adbf7de --- /dev/null +++ b/tests/test_raw_text_custom_types.py @@ -0,0 +1,167 @@ +"""Custom PostgreSQL column types: ``RawText`` binds + ``select_as_text`` reads. + +The engine binds a plain ``str`` with a declared ``text`` type, which +PostgreSQL will not implicitly cast to a custom column type (SQLSTATE 42804 — +the pgvector ``vector`` failure mode), and it requests binary result values, +which it cannot decode for such types. The fix is declared on the field class: +``to_db`` returns a ``yara_orm.RawText`` (bound untyped, so the server infers +the column's type and parses the text form itself) and ``select_as_text = +True`` makes PostgreSQL projections read the column through +``CAST(col AS text)``. + +The cross-backend model uses ``inet`` — a built-in PostgreSQL type with no +implicit cast from ``text`` and no engine decoder, i.e. exactly the shape of a +pgvector column, but needing no extension. On every other backend the kind is +plain text and ``RawText`` binds like a normal string. +""" + +import pytest + +from yara_orm import Model, RawText, fields, register_field_kind +from yara_orm.dialects import PostgresDialect, SqliteDialect + + +class InetField(fields.Field): + """An IP-network column: PostgreSQL ``inet``, plain text elsewhere.""" + + field_kind = "ipaddr" + read_identity = False + select_as_text = True + + def to_db(self, value): + return None if value is None else RawText(str(value)) + + def to_python(self, value): + return value + + +register_field_kind( + "ipaddr", + field_cls=InetField, + sql={ + "postgres": "inet", + "sqlite": "TEXT", + "mysql": "VARCHAR(64)", + "oracle": "VARCHAR2(64)", + "mssql": "NVARCHAR(64)", + }, +) + + +class Host(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=50) + addr = InetField(null=True) + + class Meta: + table = "rawtext_host" + + +class HostGroup(Model): + id = fields.IntField(pk=True) + gateway = fields.ForeignKeyField("Host", related_name="groups") + members = fields.ManyToManyField("Host", related_name="member_of", through="rawtext_group_host") + + class Meta: + table = "rawtext_hostgroup" + + +MODELS = [Host, HostGroup] + + +def test_rawtext_is_a_str(): + """ + GIVEN a RawText value + WHEN treated as a string + THEN it behaves exactly like the wrapped str (thin marker subclass) + """ + v = RawText("10.0.0.1") + assert isinstance(v, str) + assert v == "10.0.0.1" + assert v.upper() == "10.0.0.1" + + +def test_select_column_casts_only_flagged_fields_on_postgres(): + """ + GIVEN the postgres and sqlite dialects + WHEN select_column renders a flagged and an unflagged field + THEN only postgres casts the flagged column, and keeps its reference name + """ + pg, lite = PostgresDialect(), SqliteDialect() + flagged, plain = InetField(), fields.CharField(max_length=10) + assert pg.select_column(flagged, '"addr"') == 'CAST("addr" AS text)' + assert pg.select_column(plain, '"name"') == '"name"' + assert lite.select_column(flagged, '"addr"') == '"addr"' + + +@pytest.mark.asyncio +async def test_create_and_get_roundtrip(db): + """ + GIVEN a custom-typed column with RawText binds and select_as_text reads + WHEN a row is created and fetched back (full row and only()) + THEN the value round-trips as its text form on every backend + """ + created = await Host.create(name="a", addr="10.1.2.3/32") + assert created.addr == "10.1.2.3/32" + fetched = await Host.get(id=created.id) + assert fetched.addr == "10.1.2.3/32" + partial = await Host.filter(id=created.id).only("id", "addr").first() + assert partial.addr == "10.1.2.3/32" + empty = await Host.create(name="b") + assert (await Host.get(id=empty.id)).addr is None + + +@pytest.mark.asyncio +async def test_filter_update_and_values_paths(db): + """ + GIVEN rows with custom-typed values + WHEN filtering on, updating and projecting the column + THEN the untyped bind matches the column type and values() decodes text + """ + row = await Host.create(name="a", addr="10.1.2.3/32") + await Host.create(name="b", addr="10.9.9.9/32") + + assert (await Host.filter(addr="10.1.2.3/32").count()) == 1 + got = await Host.filter(addr="10.1.2.3/32").first() + assert got.id == row.id + + await Host.filter(id=row.id).update(addr="172.16.0.1/32") + assert (await Host.get(id=row.id)).addr == "172.16.0.1/32" + + values = dict(await Host.all().order_by("name").values_list("name", "addr")) + assert values == {"a": "172.16.0.1/32", "b": "10.9.9.9/32"} + + +@pytest.mark.asyncio +async def test_bulk_create_and_save_paths(db): + """ + GIVEN instances carrying custom-typed values + WHEN persisted via bulk_create and instance save() + THEN both write paths bind the untyped text form successfully + """ + await Host.bulk_create([Host(name=f"h{i}", addr=f"10.0.0.{i}/32") for i in range(1, 4)]) + assert (await Host.all().count()) == 3 + + one = await Host.get(name="h1") + one.addr = "10.0.0.100/32" + await one.save() + assert (await Host.get(name="h1")).addr == "10.0.0.100/32" + + +@pytest.mark.asyncio +async def test_select_related_and_prefetch_read_casted_columns(db): + """ + GIVEN a relation whose target model carries a custom-typed column + WHEN loaded via select_related and prefetch_related + THEN the joined/prefetched projections decode the text form + """ + gw = await Host.create(name="gw", addr="192.168.1.1/32") + group = await HostGroup.create(gateway=gw) + await group.members.add(gw) + + joined = await HostGroup.all().select_related("gateway").first() + assert joined.gateway.addr == "192.168.1.1/32" + + fetched = (await HostGroup.all().prefetch_related("members"))[0] + members = await fetched.members + assert [h.addr for h in members] == ["192.168.1.1/32"]