Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/guides/custom-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion python/yara_orm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,6 +76,7 @@ class User(Model):
"Case",
"When",
"RawSQL",
"RawText",
"Subquery",
"Value",
"Array",
Expand Down
43 changes: 42 additions & 1 deletion python/yara_orm/dialects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(<part> FROM col)``.

Expand Down
18 changes: 18 additions & 0 deletions python/yara_orm/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
8 changes: 8 additions & 0 deletions python/yara_orm/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion python/yara_orm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion python/yara_orm/prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)} "
Expand Down
10 changes: 6 additions & 4 deletions python/yara_orm/queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ""
Expand Down
2 changes: 1 addition & 1 deletion rust/src/backend/mssql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion rust/src/backend/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion rust/src/backend/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
53 changes: 47 additions & 6 deletions rust/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -49,6 +49,9 @@ static DECIMAL_TYPE: PyOnceLock<Py<PyType>> = 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<Py<PyType>> = 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<Py<PyType>> = PyOnceLock::new();
// `enum.Enum` base, to coerce enum members inside a JSON value (their `.value`).
static ENUM_TYPE: PyOnceLock<Py<PyType>> = PyOnceLock::new();

Expand All @@ -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")
}
Expand Down Expand Up @@ -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<u8>),
Json(serde_json::Value),
Array(Vec<Value>),
Expand Down Expand Up @@ -137,6 +151,14 @@ impl<'a, 'py> FromPyObject<'a, 'py> for Value {
return Ok(Value::Float(ob.extract::<f64>()?));
}
if ob.is_instance_of::<PyString>() {
// ``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::<PyString>()
&& ob.is_instance(raw_text_type(ob.py())?.as_any())?
{
return Ok(Value::RawText(ob.extract::<String>()?));
}
return Ok(Value::Text(ob.extract::<String>()?));
}
// datetime must be checked before date (datetime subclasses date).
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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
}
}
}
}
Expand Down Expand Up @@ -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),
Expand All @@ -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!();
}

Expand Down Expand Up @@ -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()),
Expand All @@ -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.
Expand Down
Loading