From 5b5e850fa66d943d451f306569449bfd9f347d09 Mon Sep 17 00:00:00 2001 From: Seva D Date: Thu, 16 Jul 2026 12:03:09 +0400 Subject: [PATCH] Support custom PostgreSQL column types: RawText binds + select_as_text reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom column types (pgvector's vector, inet, citext, ...) were unusable on PostgreSQL: a text rendering from to_db bound with a declared text type, which the server will not implicitly cast to the column's type (SQLSTATE 42804), and reads failed because the engine requests binary result values it cannot decode for such types — the custom-fields guide's own pgvector example hit both. - yara_orm.RawText (a thin str subclass, like Array) binds as an *untyped* parameter on PostgreSQL: the engine declares OID 0 so the server infers the type from context, and sends the value with the text format code so the target type's input function parses it. The Rust Value gets a RawText variant (pg_type None, encode_format Text); every other backend treats it exactly like Text. - Field.select_as_text (new class attribute) makes PostgreSQL SELECT and RETURNING projections read the column through CAST(col AS text) — a bare column cast keeps the column's result name, so decode plans are unaffected. Rendering goes through a new BaseDialect.select_column hook applied at every projection site: full-row selects, only()/defer(), select_related, values()/values_list(), m2m prefetch and RETURNING. Comparison and grouping references keep the column's real type. - The custom-fields guide documents both on a complete pgvector VectorField. - tests/test_raw_text_custom_types.py exercises the shape on every backend via a custom inet field kind (a built-in PostgreSQL type with no implicit text cast and no engine decoder — the exact pgvector failure mode, minus the extension dependency): create/get/only, filter/update/values_list, bulk_create/save, select_related and m2m prefetch. --- docs/guides/custom-fields.md | 45 ++++++++ python/yara_orm/__init__.py | 3 +- python/yara_orm/dialects.py | 43 ++++++- python/yara_orm/expressions.py | 18 +++ python/yara_orm/fields.py | 8 ++ python/yara_orm/models.py | 4 +- python/yara_orm/prefetch.py | 2 +- python/yara_orm/queryset.py | 10 +- rust/src/backend/mssql.rs | 2 +- rust/src/backend/mysql.rs | 2 +- rust/src/backend/oracle.rs | 2 +- rust/src/value.rs | 53 ++++++++- tests/test_raw_text_custom_types.py | 167 ++++++++++++++++++++++++++++ 13 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 tests/test_raw_text_custom_types.py 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"]