"""
aaiclick.data.object - Core Object class for the aaiclick framework.

This module provides the Object class that represents data in ClickHouse tables
and supports operations through operator overloading.
"""

from __future__ import annotations

import asyncio
import functools
import sys
from typing import Any

from typing_extensions import Self

from aaiclick.oplog.oplog_api import oplog_record_sample
from aaiclick.snowflake import get_snowflake_id

from ..data_context import (
    create_object_from_value,
    decref,
    get_ch_client,
    incref,
    register_object,
)
from ..data_context.ch_client import execute_for_stats, export_query_to_file
from ..formats import format_for_extension
from ..models import (
    AAI_ID_COLUMN,
    FIELDTYPE_ARRAY,
    FIELDTYPE_DICT,
    FIELDTYPE_SCALAR,
    GB_ANY,
    GB_COUNT,
    GB_GROUP_ARRAY_DISTINCT,
    GB_MAX,
    GB_MEAN,
    GB_MIN,
    GB_STD,
    GB_SUM,
    GB_VAR,
    ORIENT_DICT,
    AggSpec,
    ColumnInfo,
    Computed,
    CopyInfo,
    GroupByInfo,
    IngestQueryInfo,
    QueryInfo,
    QueryStats,
    Schema,
    ValueScalarType,
    ValueType,
    ViewSchema,
    build_order_by_clause,
    parse_ch_type,
)
from ..scope import NamedScope, ObjectScope, is_persistent_table, scope_of
from ..sql_utils import escape_sql_string, quote_identifier
from . import data_extraction, ingest, operators
from . import join as join_module
from ._url_retry import DEFAULT_BACKOFF_FACTOR, DEFAULT_RETRIES, with_url_retry
from .refs import ObjectRef, ViewRef
from .schema_compute import (
    UNARY_TRANSFORMS,
    _compute_operator_schema,
    _preview_agg_schema,
    _preview_count_if_schema,
    _preview_nunique_schema,
    _preview_quantile_schema,
    _preview_unary_schema,
    _preview_unique_schema,
    _scalar_to_schema,
)

# Sentinel for "caller did not pass this kwarg" — distinguishes from None
# which means "explicitly no value" (e.g. limit=None to disable the safety cap).
_UNSET: Any = object()


def _require_explicit_order_for_cross_table(a: Object, b: Object) -> None:
    """Enforce the cross-table operator contract.

    Binary elementwise ops between two array Objects from different tables
    need an explicit row order on both sides — otherwise the JOIN
    ``row_number() OVER ()`` pairs rows arbitrarily. Same-table ops and
    scalar broadcast skip the check (they have a natural alignment).

    Uses the public ``.order_by`` property (``None`` on base Object, the
    stored ``_order_by`` on View) so the helper stays symmetric and
    doesn't reach into private attributes.
    """
    if a._schema.fieldtype != FIELDTYPE_ARRAY or b._schema.fieldtype != FIELDTYPE_ARRAY:
        return
    if a.table == b.table:
        return
    if a.order_by is not None and b.order_by is not None:
        return
    raise TypeError(
        "Binary elementwise ops on array Objects from different sources "
        "require an explicit row order. Wrap both sides with "
        ".view(order_by=...) before combining.\n"
        f"  Got: {a!r} + {b!r}"
    )


class Object:
    """
    Represents a data object stored in a ClickHouse table.

    Each Object instance corresponds to a ClickHouse table and supports
    operations through operator overloading that create new tables with results.

    Supports 14 operators: arithmetic (+, -, *, /, //, %, **), comparison
    (==, !=, <, <=, >, >=), and bitwise (&, |, ^).

    All operators work element-wise on both scalar and array data types.

    For detailed operator documentation, examples, and Python-to-ClickHouse
    operator mapping, see docs/user_guide/object.md.
    """

    def __init__(
        self,
        table: str | None = None,
        schema: Schema | None = None,
        order_by: list[str] | None = None,
    ):
        """
        Initialize an Object.

        Args:
            table: Optional table name. If not provided, generates unique table name
                  using Snowflake ID prefixed with 't' for ClickHouse compatibility
            schema: Optional Schema with column types (cached for internal use)
            order_by: Optional list of column names for the table ORDER BY clause.
                      Empty input yields ``tuple()`` (ClickHouse no-sort form).
                      Example: ``order_by=['date']`` → ``ORDER BY (date)``
        """
        table_name = table if table is not None else f"t_{get_snowflake_id()}"
        if schema is None:
            schema = Schema(fieldtype=FIELDTYPE_SCALAR, columns={})
        replace_kw: dict = {"table": table_name}
        if order_by is not None:
            replace_kw["order_by"] = build_order_by_clause(order_by)
        self._stale = False
        self._schema = schema.model_copy(update=replace_kw)
        self._registered = False
        self._owns_lifecycle_ref = False
        self._stats: QueryStats | None = None

    async def stats(self) -> QueryStats | None:
        """:class:`QueryStats` of the server-side query that produced this Object.

        Populated on objects born from a server-side query — ``.copy()`` results
        and materialized ``LazyOperator`` results; ``None`` on plain table-backed
        Objects and unexecuted Views. ``async`` because awaiting it on an
        unmaterialized ``LazyOperator`` first triggers the materialization
        (mirroring ``.data()``).
        """
        return self._stats

    @property
    def scope(self) -> ObjectScope:
        """Scope implied by the table-name prefix.

        - ``"temp"``       — ``t_<id>`` tables dropped at context/task exit
        - ``"temp_named"`` — ``t_<name>_<id>`` tables dropped at context/task exit
        - ``"job"``        — ``j_<job_id>_<name>`` tables dropped at job TTL
        - ``"global"``     — ``p_<name>`` tables persist until the user deletes them
        """
        return scope_of(self.table)

    @property
    def persistent(self) -> bool:
        """True when the underlying table survives context/task exit.

        Covers both job-scoped (``j_<job_id>_<name>``) and user-managed global
        (``p_<name>``) tables — equivalent to ``obj.scope != "temp"``.
        """
        return is_persistent_table(self.table)

    def _register(self) -> None:
        """Register this object with the active context for lifecycle tracking."""
        self._registered = True
        if not self.persistent:
            incref(self.table)
            self._owns_lifecycle_ref = True

    def __del__(self):
        """Decrement refcount on deletion.

        Skipped when the object is stale (already decreffed at context exit)
        or during interpreter shutdown.
        """
        if sys.is_finalizing():
            return
        if self._stale:
            return
        if not self._registered:
            return
        if self.persistent:
            return
        if not self._owns_lifecycle_ref:
            return
        decref(self.table)

    @property
    def table(self) -> str:
        """Get the table name for this object (read-only)."""
        assert self._schema.table is not None, "Object has no table (unrealized blueprint)"
        return self._schema.table

    @property
    def schema(self) -> Schema:
        """Get the cached schema for this object."""
        return self._schema

    @property
    def limit(self) -> int | None:
        """Get LIMIT (None for base Object)."""
        return None

    @property
    def offset(self) -> int | None:
        """Get OFFSET (None for base Object)."""
        return None

    @property
    def _explicit_order_by(self) -> str | None:
        """User-set ORDER BY (no aai_id fallback). Used by ``has_constraints``
        so the auto-default doesn't force a subquery wrap on reads where
        ordering doesn't matter (e.g. aggregations).
        """
        return None

    @property
    def order_by(self) -> str | None:
        """ORDER BY clause for this Object.

        Falls back to ``AAI_ID_COLUMN`` when the schema carries that column
        and no explicit ordering is set — so tables created with
        ``aai_id=True`` get deterministic insertion order on reads and
        pair-stable cross-table arithmetic without requiring callers to wrap
        them in ``.view(order_by=AAI_ID_COLUMN)``.
        """
        explicit = self._explicit_order_by
        if explicit is not None:
            return explicit
        if AAI_ID_COLUMN in self._schema.columns:
            return AAI_ID_COLUMN
        return None

    @property
    def selected_fields(self) -> list[str] | None:
        """Get selected field names (None for base Object)."""
        return None

    @property
    def where_clauses(self) -> list[tuple[str, str]]:
        """Get WHERE clauses (empty for base Object)."""
        return []

    @property
    def computed_columns(self) -> dict[str, Computed] | None:
        """Get computed columns (None for base Object)."""
        return None

    @property
    def renamed_columns(self) -> dict[str, str] | None:
        """Get renamed columns mapping old->new (None for base Object)."""
        return None

    @property
    def exploded_columns(self) -> list[str]:
        """Get exploded column names (empty for base Object)."""
        return []

    @property
    def _effective_columns(self) -> dict[str, ColumnInfo]:
        """Effective column schema visible to operations on this object.

        Base implementation returns the raw schema columns. Overridden in
        View to include computed columns, renames, field selection, and
        exploded-column type adjustments.
        """
        return self._schema.columns

    def _serialize_ref(self) -> dict:
        """Serialize this Object to a reference dict for task kwargs/results."""
        return ObjectRef(
            table=self.table,
            persistent=True if self.persistent else None,
        ).to_dict()

    @property
    def is_single_field(self) -> bool:
        """Check if this is a single-field selection."""
        return self.selected_fields is not None and len(self.selected_fields) == 1

    @property
    def ch_client(self):
        """Get the ClickHouse client from the context."""
        self.checkstale()
        return get_ch_client()

    @property
    def stale(self) -> bool:
        """Check if this object has been deleted."""
        return self._stale

    @property
    def has_constraints(self) -> bool:
        """Check if this object has any view constraints.

        Uses ``_explicit_order_by`` so the auto ``aai_id`` fallback in
        ``order_by`` doesn't wrap unconstrained sources in a redundant
        subquery — aggregations, GROUP BY, and other order-insensitive
        consumers select directly from the table.
        """
        return bool(
            self.where_clauses
            or self.limit is not None
            or self.offset is not None
            or self._explicit_order_by
            or self.selected_fields
            or self.computed_columns
            or self.renamed_columns
            or self.exploded_columns
        )

    def checkstale(self):
        """
        Check if object is stale and raise error if so.

        Raises:
            RuntimeError: If object has been deleted (stale)
        """
        if self._stale:
            raise RuntimeError(f"Cannot use stale Object. Table '{self.table}' has been deleted.")

    def _build_where(self) -> str | None:
        """Build the combined WHERE clause from stored conditions."""
        if not self.where_clauses:
            return None
        parts = []
        for i, (condition, connector) in enumerate(self.where_clauses):
            if i == 0:
                parts.append(f"({condition})")
            else:
                parts.append(f"{connector} ({condition})")
        return " ".join(parts)

    def _build_select(
        self,
        columns: str = "*",
        default_order_by: str | None = None,
        skip_order_by: bool = False,
        *,
        order_by: Any = _UNSET,
        limit: Any = _UNSET,
        offset: Any = _UNSET,
    ) -> str:
        """
        Build a SELECT query with view constraints applied.

        Args:
            columns: Column specification (default "*")
            default_order_by: Default ORDER BY clause if view doesn't have custom order_by
            skip_order_by: If True, omit ORDER BY from the query.
            order_by/limit/offset: Per-call overrides — when not ``_UNSET``,
                used in place of the View's stored attributes. Lets
                ``data(order_by=..., limit=..., offset=...)`` override the
                View it's called on without mutating the View itself.

        Returns:
            str: SELECT query string with WHERE/LIMIT/OFFSET/ORDER BY applied
        """
        eff_order_by = order_by if order_by is not _UNSET else self.order_by
        eff_limit = limit if limit is not _UNSET else self.limit
        eff_offset = offset if offset is not _UNSET else self.offset

        query = f"SELECT {columns} FROM {self.table}"
        where = self._build_where()
        if where:
            query += f" WHERE {where}"
        if not skip_order_by:
            order_clause = eff_order_by or default_order_by
            if order_clause:
                query += f" ORDER BY {order_clause}"
        if eff_limit is not None:
            query += f" LIMIT {eff_limit}"
        if eff_offset is not None:
            query += f" OFFSET {eff_offset}"
        return query

    def _get_query_info(self) -> QueryInfo:
        """
        Get query information for operator operations.

        Encapsulates the data source, base table name, and schema metadata
        to avoid needing to query system tables in operators.
        Works for both Object (value column) and View (selected field).
        """
        source = f"({self._build_select()})" if self.has_constraints else self.table
        value_column = self.selected_fields[0] if self.is_single_field and self.selected_fields else "value"

        if self.is_single_field:
            fieldtype = FIELDTYPE_ARRAY
            col_def = self._schema.columns.get(value_column, ColumnInfo("Float64"))
        else:
            fieldtype = self._schema.fieldtype
            col_def = self._schema.columns.get("value", ColumnInfo("Float64"))

        # Build constraint suffix (WHERE/ORDER BY/LIMIT/OFFSET) for same-table
        # operator optimization. Two views from the same table with identical
        # constraint_sql reference the same rows and can share a single SELECT.
        constraint_sql = self._build_constraint_sql()

        return QueryInfo(
            source=source,
            base_table=self.table,
            value_column=value_column,
            fieldtype=fieldtype,
            value_type=col_def.type,
            nullable=col_def.nullable,
            constraint_sql=constraint_sql,
            order_by=self.order_by,
            aai_id_info=self._schema.columns.get(AAI_ID_COLUMN),
        )

    def _build_constraint_sql(self) -> str:
        """Build the non-column constraints (WHERE/ORDER BY/LIMIT/OFFSET) as SQL suffix.

        Used by _apply_operator_db to detect when two views from the same table
        share identical constraints and can be combined into a single SELECT.
        Returns empty string for unconstrained Objects (no WHERE/LIMIT/etc).
        """
        parts = []
        where = self._build_where()
        if where:
            parts.append(f"WHERE {where}")
        if self.order_by:
            parts.append(f"ORDER BY {self.order_by}")
        if self.limit is not None:
            parts.append(f"LIMIT {self.limit}")
        if self.offset is not None:
            parts.append(f"OFFSET {self.offset}")
        return " ".join(parts)

    def _get_ingest_query_info(self) -> IngestQueryInfo:
        """
        Get query information for concat/insert operations.

        Extends QueryInfo with full column schema so ingest functions
        can validate without querying system.columns.
        """
        info = self._get_query_info()
        columns = self._schema.columns
        return IngestQueryInfo(**vars(info), columns=columns)

    def _get_copy_info(self) -> CopyInfo:
        """
        Get copy info for database-level copy operations.

        Returns:
            CopyInfo with source query and schema metadata from cached _schema
        """
        source_query = f"({self._build_select()})" if self.has_constraints else self.table
        return CopyInfo(
            source_query=source_query,
            fieldtype=self._schema.fieldtype,
            columns=self._schema.columns,
        )

    async def result(self):
        """Query and return the raw ClickHouse query result for this object.

        Returns the low-level result object from the ClickHouse client.
        Prefer `.data()` for normal use — it returns
        typed Python values and supports orient modes. The concrete type depends
        on the backend (clickhouse_connect `QueryResult` or the chdb equivalent).

        Raises:
            RuntimeError: If the object is stale (already deleted).
        """
        self.checkstale()
        return await self.ch_client.query(f"SELECT * FROM {self.table}")

    async def data(
        self,
        orient: str = ORIENT_DICT,
        *,
        order_by: Any = _UNSET,
        offset: Any = _UNSET,
        limit: Any = _UNSET,
    ) -> Any:
        """
        Get the data from the object's table.

        Returns a scalar value, list, or dict depending on the object's field type.
        Scalar objects return the value directly; array objects return a list;
        dict objects return a dict or list-of-dicts controlled by ``orient``.

        Args:
            orient: Output format for dict data — ``"dict"`` (default) or
                ``"records"``.
            order_by: Optional ``ORDER BY`` clause. When omitted on a View,
                inherits the View's stored ``order_by``. Scalar reads ignore.
            offset: Optional row offset. Same View-inheritance rule as above.
            limit: Row cap. Defaults to 1000 as a safety rail when called on a
                base Object; pass ``None`` to fetch all rows. On a View, defers
                to the View's ``limit`` when omitted (which may itself be 1000
                or None — the View's choice wins). Scalar / single-row reads
                ignore.
        """
        self.checkstale()

        # Object caches its schema on construction (from create_object or from
        # the registry during deserialize) — no need to round-trip SQL here.
        fieldtype = self._schema.fieldtype
        columns = self._schema.columns
        column_names = list(columns)

        if fieldtype == FIELDTYPE_SCALAR:
            return await data_extraction.extract_scalar_data(self)

        # Resolve override kwargs against View state (or apply 1000 safety cap
        # for base Objects). _UNSET means "caller didn't pass"; explicit None
        # means "no value" (e.g. limit=None to disable the cap).
        eff_order_by = order_by if order_by is not _UNSET else self.order_by
        eff_offset = offset if offset is not _UNSET else self.offset
        if limit is not _UNSET:
            eff_limit = limit
        elif self.limit is not None:
            eff_limit = self.limit
        else:
            eff_limit = 1000

        if fieldtype == FIELDTYPE_DICT:
            return await data_extraction.extract_dict_data(
                self,
                column_names,
                columns,
                orient,
                order_by=eff_order_by,
                offset=eff_offset,
                limit=eff_limit,
            )
        return await data_extraction.extract_array_data(
            self,
            order_by=eff_order_by,
            offset=eff_offset,
            limit=eff_limit,
        )

    async def markdown(self, truncate: dict[str, int] | None = None) -> str:
        """Return the object's data formatted as a markdown table.

        Fetches data via ``.data()`` and renders it as a plain-text markdown
        table with auto-sized column widths.

        Args:
            truncate: Optional mapping of column name to maximum character
                width.  Values longer than the limit are truncated with an
                ellipsis (``…``).  Columns not present in the mapping are
                never truncated.

        Returns:
            Multi-line string containing the markdown table.
        """
        raw = await self.data()
        trunc = truncate or {}

        # For scalar / array data wrap into a single-column dict
        if not isinstance(raw, dict):
            raw = {"value": raw if isinstance(raw, list) else [raw]}

        columns = list(raw)
        if not columns:
            return ""
        n_rows = len(raw[columns[0]]) if isinstance(raw[columns[0]], list) else 1

        def _cell(val: object, col: str) -> str:
            if val is None:
                return "N/A"
            if isinstance(val, float):
                return f"{val:.2f}"
            s = str(val)
            # Sanitize: collapse newlines/tabs and escape pipes
            s = s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
            s = s.replace("\t", " ").replace("|", "\\|")
            limit = trunc.get(col)
            if limit is not None and len(s) > limit:
                return s[: limit - 1] + "…"
            return s

        # Ensure we can iterate rows uniformly
        def _get(col: str, i: int) -> object:
            v = raw[col]
            return v[i] if isinstance(v, list) else v

        widths: dict[str, int] = {}
        for col in columns:
            max_val = max((len(_cell(_get(col, i), col)) for i in range(n_rows)), default=0)
            cap = trunc.get(col)
            w = max(len(col), max_val)
            widths[col] = min(w, cap) if cap is not None else w

        lines: list[str] = []
        header = "| " + " | ".join(f"{col:<{widths[col]}s}" for col in columns) + " |"
        sep = "|" + "|".join("-" * (w + 2) for w in (widths[col] for col in columns)) + "|"
        lines.append(header)
        lines.append(sep)
        for i in range(n_rows):
            row = "| " + " | ".join(f"{_cell(_get(col, i), col):<{widths[col]}s}" for col in columns) + " |"
            lines.append(row)
        return "\n".join(lines)

    async def export(self, path: str) -> str:
        """Export the object's data to a local file.

        The format is picked from the file extension. Supported text formats:
        ``.csv``, ``.tsv``, ``.json`` / ``.jsonl`` / ``.ndjson``, ``.md``,
        ``.xml``, ``.sql``. Supported binary/columnar formats: ``.parquet``,
        ``.arrow``, ``.orc``, ``.avro``. ClickHouse picks the compression
        scheme from a trailing ``.gz`` / ``.zst`` / ``.br`` / ``.xz`` suffix
        — e.g. ``data.csv.gz`` writes a gzipped CSV.

        View constraints (``where``, ``limit``, ``order_by``) are honored.
        Backends stream directly: chdb writes via ``INSERT INTO FUNCTION
        file()`` from the embedded engine; remote ClickHouse streams the
        formatted bytes over HTTP.

        Returns the absolute path written.
        """
        self.checkstale()
        fmt = format_for_extension(path)
        select_sql = self._build_select(columns="*")
        return await export_query_to_file(select_sql, path, fmt)

    async def execute(
        self,
        *,
        order_by: Any = _UNSET,
        limit: Any = _UNSET,
        offset: Any = _UNSET,
    ) -> QueryStats:
        """Run the query this Object/View describes, discard every row, return its stats.

        Rebuilds the exact SELECT that :meth:`data` would issue (honoring
        ``where``, ``order_by``, ``limit``, ``offset``, computed columns,
        renames, and field selection), appends ``FORMAT Null``, and runs it
        server-side. ClickHouse runs the full pipeline and emits zero rows —
        nothing is materialized or transported back. Returns the run's
        :class:`QueryStats`.

        Because ``View`` overrides ``_build_select``, a View's ``execute()``
        measures the View's projection automatically. The per-call
        ``order_by`` / ``limit`` / ``offset`` overrides mirror :meth:`data`.

        Unlike :meth:`data`, no safety row cap is applied — the whole pipeline
        runs unless ``limit`` is passed explicitly.
        """
        self.checkstale()
        select_sql = self._build_select(order_by=order_by, limit=limit, offset=offset)
        return await execute_for_stats(f"{select_sql} FORMAT Null")

    async def _get_fieldtype(self) -> str | None:
        """Get the fieldtype of the value column from the cached schema."""
        self.checkstale()
        col = self._schema.columns.get("value")
        return col.fieldtype if col is not None else None

    @staticmethod
    async def _ensure_object(value: Object | ValueScalarType) -> Object:
        """
        Ensure value is an Object, converting Python scalars if needed.

        Python scalars are converted to Objects via create_object_from_value,
        so all data stays in ClickHouse with a unified code path.

        Args:
            value: An Object or a Python scalar (int, float, bool, str)

        Returns:
            Object instance (existing or newly created from scalar)
        """
        if not isinstance(value, Object):
            return await create_object_from_value(value)
        return value

    def _peek_op_metadata(self) -> tuple[str, str, bool, ColumnInfo | None]:
        """Return (fieldtype, value_type, nullable, aai_id_info) the operator will see.

        Uses ``_get_query_info`` so View field-selection and other effective-fieldtype
        cases agree with what ``_apply_operator_db`` consumes at materialize time.
        Overridden in ``LazyOperator`` to read the precomputed result schema instead
        (LazyOperator has no table pre-materialize).
        """
        info = self._get_query_info()
        return info.fieldtype, info.value_type, info.nullable, info.aai_id_info

    def _plan_operator(self, other: Object | ValueScalarType, operator: str) -> LazyOperator:
        """Synchronously plan ``self op other`` — returns a LazyOperator that
        materializes on await. Schema is precomputed; no DB call.
        """
        self.checkstale()
        if isinstance(other, Object) and not _is_unmaterialized_lazy(self) and not _is_unmaterialized_lazy(other):
            _require_explicit_order_for_cross_table(self, other)
        a_ft, a_t, a_n, a_aai = self._peek_op_metadata()
        b_ft, b_t, b_n, b_aai = _peek_other_metadata(other)
        schema_preview, _ = _compute_operator_schema(
            fieldtype_a=a_ft,
            fieldtype_b=b_ft,
            type_a=a_t,
            type_b=b_t,
            nullable_a=a_n,
            nullable_b=b_n,
            aai_id_a=a_aai,
            aai_id_b=b_aai,
            operator=operator,
        )
        return LazyOperator(lhs=self, rhs=other, operator=operator, schema_preview=schema_preview)

    def _plan_operator_reverse(self, other: Object | ValueScalarType, operator: str) -> LazyOperator:
        """Synchronously plan ``other op self`` — used by __radd__ etc."""
        self.checkstale()
        if isinstance(other, Object) and not _is_unmaterialized_lazy(self) and not _is_unmaterialized_lazy(other):
            _require_explicit_order_for_cross_table(other, self)
        b_ft, b_t, b_n, b_aai = self._peek_op_metadata()
        a_ft, a_t, a_n, a_aai = _peek_other_metadata(other)
        schema_preview, _ = _compute_operator_schema(
            fieldtype_a=a_ft,
            fieldtype_b=b_ft,
            type_a=a_t,
            type_b=b_t,
            nullable_a=a_n,
            nullable_b=b_n,
            aai_id_a=a_aai,
            aai_id_b=b_aai,
            operator=operator,
        )
        return LazyOperator(lhs=other, rhs=self, operator=operator, schema_preview=schema_preview)

    # __eq__ override on a class zeroes the default __hash__. Restore it
    # explicitly so Objects remain hashable (set/dict keys, etc.).
    __hash__ = object.__hash__

    def __add__(self, other: Object | ValueScalarType) -> LazyOperator:
        """Add: self + other. Returns a LazyOperator that materializes on ``await``.
        Supports scalar broadcast."""
        return self._plan_operator(other, "+")

    def __radd__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "+")

    def __sub__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "-")

    def __rsub__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "-")

    def __mul__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "*")

    def __rmul__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "*")

    def __truediv__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "/")

    def __rtruediv__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "/")

    def __floordiv__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "//")

    def __rfloordiv__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "//")

    def __mod__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "%")

    def __rmod__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "%")

    def __pow__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "**")

    def __rpow__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "**")

    def __eq__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "==")

    def __ne__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "!=")

    def __lt__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "<")

    def __le__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "<=")

    def __gt__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, ">")

    def __ge__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, ">=")

    def __and__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "&")

    def __rand__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "&")

    def __or__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "|")

    def __ror__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "|")

    def __xor__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator(other, "^")

    def __rxor__(self, other: Object | ValueScalarType) -> LazyOperator:
        return self._plan_operator_reverse(other, "^")

    async def copy(
        self,
        *,
        name: str | None = None,
        scope: NamedScope | None = None,
    ) -> Object:
        """
        Copy this object to a new object and table.

        Creates a new Object with a copy of all data from this object.
        Preserves all column metadata including fieldtype.
        Also works for Views, handling field selection and constraints.

        Args:
            name: Optional result table name. When set, the copy is registered
                  in the table registry under this name. See ``create_object``
                  for the naming/scoping rules.
            scope: Lifetime tier for the named result —
                  ``"temp_named"`` (default when ``name`` is set), ``"job"``,
                  or ``"global"``. Must be ``None`` when ``name`` is ``None``.

        Returns:
            Object: New Object instance with copied data

        Examples:
            >>> obj_a = await ctx.create_object_from_value([1, 2, 3])
            >>> obj_copy = await obj_a.copy()
            >>> await obj_copy.data()  # Returns [1, 2, 3]
            >>>
            >>> # Snapshot under a stable global name
            >>> await obj_a.copy(name="snapshot_2024", scope="global")
            >>>
            >>> # Also works for views with field selection
            >>> obj = await create_object_from_value({'x': [1, 2], 'y': [3, 4]})
            >>> arr = await obj['x'].copy()  # Creates new array Object
            >>> await arr.data()  # Returns [1, 2]
        """
        self.checkstale()
        source_table = self.table
        copy_info = self._get_copy_info()
        if copy_info.selected_fields:
            result = await ingest.copy_db_selected_fields(copy_info, self.ch_client, name=name, scope=scope)
        else:
            result = await ingest.copy_db(copy_info, self.ch_client, name=name, scope=scope)
        oplog_record_sample(result.table, "copy", kwargs={"source": source_table})
        return result

    async def concat(
        self,
        *args: Object | ValueType,
        name: str | None = None,
        scope: NamedScope | None = None,
    ) -> Object:
        """
        Concatenate multiple objects or values to this object.

        Creates a new Object with rows from self followed by all args.
        Self must have array fieldtype. Each arg can be:
        - An Object (array or scalar)
        - A ValueType (scalar or list)

        Args:
            *args: Variable number of Objects or ValueTypes to concatenate
            name: Optional result table name. When set, the result is registered
                  in the table registry under this name. See ``create_object``
                  for the naming/scoping rules.
            scope: Lifetime tier for the named result —
                  ``"temp_named"`` (default when ``name`` is set), ``"job"``,
                  or ``"global"``. Must be ``None`` when ``name`` is ``None``.

        Returns:
            Object: New Object instance with concatenated data

        Raises:
            ValueError: If no arguments provided
            ValueError: If self does not have array fieldtype

        Examples:
            >>> # Concatenate with another Object
            >>> obj_a = await ctx.create_object_from_value([1, 2, 3])
            >>> obj_b = await ctx.create_object_from_value([4, 5, 6])
            >>> result = await obj_a.concat(obj_b)
            >>> await result.data()  # Returns [1, 2, 3, 4, 5, 6]
            >>>
            >>> # Concatenate with multiple objects
            >>> result = await obj_a.concat(obj_b, obj_c, obj_d)
            >>> await result.data()  # Returns concatenated data
            >>>
            >>> # Concatenate with mixed types
            >>> result = await obj_a.concat(42, [7, 8], obj_b)
            >>> await result.data()  # Returns [1, 2, 3, 42, 7, 8, ...]
            >>>
            >>> # Persist the merged result under a stable job-scoped name
            >>> await obj_a.concat(obj_b, name="merged", scope="job")
            >>>
            >>> # Nullable promotion: if any source has nullable columns,
            >>> # result is promoted to nullable
            >>> obj_nullable = await create_object(Schema(
            ...     fieldtype=FIELDTYPE_ARRAY,
            ...              "value": ColumnInfo("Int64", nullable=True)},
            ... ))
            >>> obj_non_null = await create_object_from_value([3, 4])
            >>> result = await obj_nullable.concat(obj_non_null)
            >>> result.schema.columns["value"].nullable  # True
        """
        if not args:
            raise ValueError("concat requires at least one argument")

        self.checkstale()

        # Convert all arguments to IngestQueryInfo
        query_infos = [self._get_ingest_query_info()]
        temp_objects = []

        for arg in args:
            if isinstance(arg, Object):
                arg.checkstale()
                query_infos.append(arg._get_ingest_query_info())
            else:
                # Skip empty lists to avoid type conflicts
                if isinstance(arg, list) and len(arg) == 0:
                    continue
                # Convert ValueType to temporary Object
                temp = await create_object_from_value(arg)
                temp_objects.append(temp)
                query_infos.append(temp._get_ingest_query_info())

        # If all args were empty lists, just copy self
        if len(query_infos) == 1:
            result = await self.copy(name=name, scope=scope)
        else:
            # Single database operation for all sources
            result = await ingest.concat_objects_db(query_infos, self.ch_client, name=name, scope=scope)

        # Cleanup temporary objects
        for temp in temp_objects:
            await self.ch_client.command(f"DROP TABLE IF EXISTS {temp.table}")

        return result

    async def join(
        self,
        other: Object,
        *,
        on: str | list[str] | None = None,
        left_on: str | list[str] | None = None,
        right_on: str | list[str] | None = None,
        how: join_module.JoinHow = "inner",
        suffixes: join_module.SuffixesArg = None,
        name: str | None = None,
        scope: NamedScope | None = None,
    ) -> Object:
        """Join two Objects on one or more key columns.

        Matches the familiar pandas / Spark shape.  The result is always a
        new dict-fieldtype Object materialized in ClickHouse.

        Args:
            other: Right-hand Object.
            on: Key column name(s) present under the same name in both
                Objects. Mutually exclusive with ``left_on`` / ``right_on``.
            left_on: Left-side key(s) when names differ. Requires
                ``right_on`` of equal length.
            right_on: Right-side key(s) when names differ.
            how: ``"inner" | "left" | "right" | "full" | "cross"``.
            suffixes: Applied to non-key columns that collide between left
                and right. ``True`` uses the default ``("_l", "_r")`` pair;
                a tuple lets you pick custom suffixes; ``None`` (the
                default) / ``False`` make a collision raise ``ValueError``.
            name: Optional result table name. When set, the join result is
                  registered in the table registry under this name. See
                  ``create_object`` for the naming/scoping rules.
            scope: Lifetime tier for the named result —
                  ``"temp_named"`` (default when ``name`` is set), ``"job"``,
                  or ``"global"``. Must be ``None`` when ``name`` is ``None``.

        Returns:
            Object: new dict Object with the joined rows.

        Raises:
            ValueError: Conflicting key arguments, missing keys, incompatible
                key types, non-key column collision without ``suffixes``, or
                unknown ``how``.
        """
        self.checkstale()
        other.checkstale()

        keys = join_module.resolve_join_keys(on=on, left_on=left_on, right_on=right_on, how=how)
        return await join_module.join_objects_db(
            self._get_ingest_query_info(),
            other._get_ingest_query_info(),
            keys=keys,
            how=how,
            suffixes=suffixes,
            ch_client=self.ch_client,
            name=name,
            scope=scope,
        )

    async def insert(self, *args: Self | ValueType) -> None:
        """
        Insert multiple objects or values into this object in place.

        Modifies self's table directly by appending data from all args.
        Self must have array fieldtype. Each arg can be:
        - An Object (array or scalar)
        - A ValueType (scalar or list)

        Unlike concat, this method modifies the table in place without creating a new object.

        Args:
            *args: Variable number of Objects or ValueTypes to insert

        Raises:
            ValueError: If no arguments provided
            ValueError: If self does not have array fieldtype
            ValueError: If any arg type is incompatible

        Examples:
            >>> # Insert another Object
            >>> obj_a = await ctx.create_object_from_value([1, 2, 3])
            >>> obj_b = await ctx.create_object_from_value([4, 5, 6])
            >>> await obj_a.insert(obj_b)
            >>> await obj_a.data()  # Returns [1, 2, 3, 4, 5, 6]
            >>>
            >>> # Insert multiple objects and values
            >>> await obj_a.insert(obj_b, 42, [7, 8])
            >>> await obj_a.data()  # Returns [1, 2, 3, 4, 5, 6, 42, 7, 8]
        """
        if not args:
            raise ValueError("insert requires at least one argument")

        self.checkstale()

        # Convert all arguments to IngestQueryInfo
        query_infos = []
        temp_objects = []

        for arg in args:
            if isinstance(arg, Object):
                arg.checkstale()
                query_infos.append(arg._get_ingest_query_info())
            else:
                # Skip empty lists
                if isinstance(arg, list) and len(arg) == 0:
                    continue
                # Convert ValueType to temporary Object
                temp = await create_object_from_value(arg)
                temp_objects.append(temp)
                query_infos.append(temp._get_ingest_query_info())

        # Single database operation for all sources
        if query_infos:
            await ingest.insert_objects_db(self._get_ingest_query_info(), query_infos, self.ch_client)

        # Cleanup temporary objects
        for temp in temp_objects:
            await self.ch_client.command(f"DROP TABLE IF EXISTS {temp.table}")

    async def insert_from_url(
        self,
        url: str,
        columns: list[str] | None = None,
        format: str = "Parquet",
        where: str | None = None,
        limit: int | None = None,
        retries: int = DEFAULT_RETRIES,
        backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
    ) -> None:
        """
        Insert data from an external URL into this object in place.

        Uses ClickHouse's native url() table function - zero Python memory footprint.
        Data flows directly from URL into this object's table.

        Args:
            url: HTTP(S) URL to load data from (e.g., Parquet file on S3)
            columns: Column names to select. If None, uses this object's columns.
                Column names must match the URL source.
            format: ClickHouse format name. Default "Parquet".
                Supported: Parquet, CSV, CSVWithNames, TSV, JSON, JSONEachRow, etc.
            where: Optional SQL WHERE clause for filtering rows at load time
            limit: Optional row limit applied at load time
            retries: Total attempts on transient upstream failures (5xx, connection
                resets, timeouts, DNS hiccups). Defaults to 4. Pass ``1`` to disable.
            backoff_factor: Base for the exponential backoff between retries.
                Sleep before retry ``n`` is ``backoff_factor ** n`` seconds.

        Raises:
            ValueError: If URL, columns, format, or limit are invalid
            RuntimeError: If object has incompatible schema

        Examples:
            >>> # Create schema from first month, then insert more months
            >>> trips = await create_object_from_url(
            ...     "https://example.com/jan.parquet",
            ...     columns=["fare", "tip", "distance"],
            ... )
            >>> await trips.insert_from_url("https://example.com/feb.parquet")
            >>> await trips.insert_from_url("https://example.com/mar.parquet")
        """
        from .url import (
            _validate_url,
            _validate_url_columns,
            _validate_url_format,
        )

        self.checkstale()

        _validate_url(url)
        _validate_url_format(format)

        if columns is None:
            columns = list(self.schema.columns)

        _validate_url_columns(columns)

        if limit is not None and (not isinstance(limit, int) or limit <= 0):
            raise ValueError(f"limit must be a positive integer, got {limit}")
        if where is not None and ";" in where:
            raise ValueError("WHERE clause must not contain ';'")

        safe_url = escape_sql_string(url)

        # Build column selection
        quoted_columns = [quote_identifier(c) for c in columns]
        columns_str = ", ".join(quoted_columns)

        # Handle single-column case (mapped to "value")
        if len(columns) == 1 and "value" in self.schema.columns:
            select_cols = f"{quoted_columns[0]} AS value"
        else:
            select_cols = columns_str

        insert_cols_str = ", ".join(self.schema.columns)
        where_clause = f" WHERE {where}" if where else ""
        limit_clause = f" LIMIT {limit}" if limit is not None else ""

        insert_query = (
            f"INSERT INTO {self.table} ({insert_cols_str}) "
            f"SELECT {select_cols} "
            f"FROM url('{safe_url}', '{format}')"
            f"{where_clause}"
            f"{limit_clause}"
        )
        await with_url_retry(
            lambda: self.ch_client.command(insert_query),
            retries=retries,
            backoff_factor=backoff_factor,
        )

    def _plan_aggregation(self, agg_func: str) -> LazyOperator:
        """Synchronously plan a simple aggregation (min/max/sum/mean/std/var/count).

        Returns a LazyOperator with the precomputed scalar result schema; the
        underlying ``operators._apply_aggregation`` call runs on await.
        """
        self.checkstale()
        _, value_type, _, _ = self._peek_op_metadata()
        schema_preview = _preview_agg_schema(value_type, agg_func)
        return LazyOperator(lhs=self, rhs=None, operator=agg_func, schema_preview=schema_preview)

    def min(self) -> LazyOperator:
        """Calculate the minimum value from the object's table.

        Returns a ``LazyOperator`` that materializes on ``await`` into a scalar
        Object. Use ``.as_(name, scope=...)`` to control the result table name.

        Examples:
            >>> obj = await create_object_from_value([5, 2, 8, 1, 9])
            >>> await obj.min().data()  # Returns 1
            >>> await obj.min().as_("daily_min", scope="job")  # named, job-scoped
        """
        return self._plan_aggregation("min")

    def max(self) -> LazyOperator:
        """Calculate the maximum value. Returns a LazyOperator (scalar)."""
        return self._plan_aggregation("max")

    def sum(self) -> LazyOperator:
        """Calculate the sum. Returns a LazyOperator (scalar)."""
        return self._plan_aggregation("sum")

    def mean(self) -> LazyOperator:
        """Calculate the mean (average). Returns a LazyOperator (scalar)."""
        return self._plan_aggregation("mean")

    def std(self) -> LazyOperator:
        """Calculate the standard deviation (population). Returns a LazyOperator (scalar)."""
        return self._plan_aggregation("std")

    def var(self) -> LazyOperator:
        """Calculate the variance (population). Returns a LazyOperator (scalar)."""
        return self._plan_aggregation("var")

    def count(self) -> LazyOperator:
        """Count the number of values. Returns a LazyOperator (scalar UInt64)."""
        return self._plan_aggregation("count")

    def count_if(self, condition: str | dict[str, str]) -> LazyOperator:
        """Count rows matching condition(s) using countIf().

        When condition is a str, materializes to a scalar Object.
        When condition is a dict ``{name: condition_str}``, materializes to a
        dict Object with one UInt64 column per entry, computed in one scan.

        Reference: https://clickhouse.com/docs/sql-reference/aggregate-functions/combinators#-if
        """
        self.checkstale()
        schema_preview = _preview_count_if_schema(condition)
        return LazyOperator(
            lhs=self,
            rhs=None,
            operator="count_if",
            schema_preview=schema_preview,
            params={"condition": condition},
        )

    def quantile(self, q: float) -> LazyOperator:
        """Calculate the quantile. Returns a LazyOperator (scalar Float64).

        Args:
            q: Quantile level between 0 and 1 (e.g., 0.5 for median).
        """
        self.checkstale()
        schema_preview = _preview_quantile_schema()
        return LazyOperator(
            lhs=self,
            rhs=None,
            operator="quantile",
            schema_preview=schema_preview,
            params={"q": q},
        )

    def unique(self) -> LazyOperator:
        """Get unique values. Returns a LazyOperator (array of source value type).

        Uses ``GROUP BY`` internally (faster than ``DISTINCT`` on large data).
        Order is not guaranteed.
        """
        self.checkstale()
        _, value_type, nullable, _ = self._peek_op_metadata()
        schema_preview = _preview_unique_schema(value_type, nullable)
        return LazyOperator(lhs=self, rhs=None, operator="unique", schema_preview=schema_preview)

    def nunique(self) -> LazyOperator:
        """Count distinct values. Returns a LazyOperator (scalar UInt64).

        Fused equivalent of ``obj.unique().count()`` — single query:
        ``SELECT count() FROM (... GROUP BY value)``.
        """
        self.checkstale()
        schema_preview = _preview_nunique_schema()
        return LazyOperator(lhs=self, rhs=None, operator="nunique", schema_preview=schema_preview)

    # Unary Transform Operators

    def _plan_unary_transform(self, transform: str) -> LazyOperator:
        """Synchronously plan a unary transform from ``UNARY_TRANSFORMS``."""
        self.checkstale()
        fieldtype, _, _, _ = self._peek_op_metadata()
        schema_preview = _preview_unary_schema(fieldtype, transform)
        return LazyOperator(lhs=self, rhs=None, operator=transform, schema_preview=schema_preview)

    def year(self) -> LazyOperator:
        """Extract year from Date/DateTime values (UInt16)."""
        return self._plan_unary_transform("year")

    def month(self) -> LazyOperator:
        """Extract month (1-12) from Date/DateTime values (UInt8)."""
        return self._plan_unary_transform("month")

    def day_of_week(self) -> LazyOperator:
        """Extract day of week (1=Mon, 7=Sun) from Date/DateTime values (UInt8)."""
        return self._plan_unary_transform("day_of_week")

    def lower(self) -> LazyOperator:
        """Lowercase string values."""
        return self._plan_unary_transform("lower")

    def upper(self) -> LazyOperator:
        """Uppercase string values."""
        return self._plan_unary_transform("upper")

    def length(self) -> LazyOperator:
        """String length of values (UInt64)."""
        return self._plan_unary_transform("length")

    def trim(self) -> LazyOperator:
        """Trim whitespace from string values."""
        return self._plan_unary_transform("trim")

    def abs(self) -> LazyOperator:
        """Absolute value of numeric values (Float64)."""
        return self._plan_unary_transform("abs")

    def log2(self) -> LazyOperator:
        """Log base 2 of numeric values (Float64)."""
        return self._plan_unary_transform("log2")

    def sqrt(self) -> LazyOperator:
        """Square root of numeric values (Float64)."""
        return self._plan_unary_transform("sqrt")

    # String/Regex Operators

    async def match(self, pattern: str) -> Object:
        """
        Test if string values match a RE2 regex pattern.

        Args:
            pattern: RE2 regex pattern string

        Returns:
            Self: New Object with UInt8 values (1 for match, 0 for no match)

        Examples:
            >>> obj = await create_object_from_value(["apple", "banana", "avocado"])
            >>> result = await obj.match("^a")
            >>> await result.data()  # [1, 0, 1]
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.match_op(info, pattern, self.ch_client)

    async def like(self, pattern: str) -> Object:
        """
        Test if string values match a SQL LIKE pattern.

        Uses SQL LIKE syntax: % for any sequence, _ for single character.

        Args:
            pattern: SQL LIKE pattern string

        Returns:
            Self: New Object with UInt8 values (1 for match, 0 for no match)

        Examples:
            >>> obj = await create_object_from_value(["apple", "banana", "avocado"])
            >>> result = await obj.like("a%")
            >>> await result.data()  # [1, 0, 1]
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.like_op(info, pattern, self.ch_client)

    async def ilike(self, pattern: str) -> Object:
        """
        Test if string values match a SQL LIKE pattern (case-insensitive).

        Args:
            pattern: SQL LIKE pattern string (case-insensitive)

        Returns:
            Self: New Object with UInt8 values (1 for match, 0 for no match)

        Examples:
            >>> obj = await create_object_from_value(["Apple", "BANANA", "avocado"])
            >>> result = await obj.ilike("a%")
            >>> await result.data()  # [1, 0, 1]
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.ilike_op(info, pattern, self.ch_client)

    async def isin(self, other: Object | list) -> Object:
        """
        Test if values are in another Object's value set.

        Generates an IN subquery: ``value IN (SELECT value FROM other_table)``.
        Accepts an Object or a Python list (converted to Object automatically).

        Args:
            other: Object whose values define the allowed set, or a Python list.

        Returns:
            Self: New Object with UInt8 values (1 if in set, 0 otherwise)

        Examples:
            >>> obj = await create_object_from_value(["a", "b", "c", "d"])
            >>> allowed = await create_object_from_value(["a", "c"])
            >>> result = await obj.isin(allowed)
            >>> await result.data()  # [1, 0, 1, 0]
        """
        self.checkstale()
        if isinstance(other, list):
            other = await create_object_from_value(other)
        other.checkstale()
        info = self._get_query_info()
        other_info = other._get_query_info()
        return await operators.isin_op(info, other_info, self.ch_client)

    async def extract(self, pattern: str) -> Object:
        """
        Extract the first regex capture group match from string values.

        Args:
            pattern: RE2 regex pattern with a capture group

        Returns:
            Self: New Object with String values (extracted matches, empty string if no match)

        Examples:
            >>> obj = await create_object_from_value(["user_123", "user_456", "admin_789"])
            >>> result = await obj.extract("_(\\\\d+)")
            >>> await result.data()  # ["123", "456", "789"]
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.extract_op(info, pattern, self.ch_client)

    async def replace(self, pattern: str, replacement: str) -> Object:
        """
        Replace all regex matches in string values.

        Uses replaceRegexpAll for replacing all occurrences (like Python re.sub).

        Args:
            pattern: RE2 regex pattern to match
            replacement: Replacement string (supports \\\\1, \\\\2 backreferences)

        Returns:
            Self: New Object with String values (after replacement)

        Examples:
            >>> obj = await create_object_from_value(["hello world", "foo bar"])
            >>> result = await obj.replace(" ", "_")
            >>> await result.data()  # ["hello_world", "foo_bar"]
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.replace_op(info, pattern, replacement, self.ch_client)

    async def is_null(self) -> Object:
        """Check which values are NULL.

        Returns:
            Self: New Object with UInt8 values (1 where NULL, 0 otherwise)
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.is_null_op(info, self.ch_client)

    async def is_not_null(self) -> Object:
        """Check which values are not NULL.

        Returns:
            Self: New Object with UInt8 values (1 where not NULL, 0 otherwise)
        """
        self.checkstale()
        info = self._get_query_info()
        return await operators.is_not_null_op(info, self.ch_client)

    async def coalesce(self, other: Object | View) -> Object:
        """Return first non-NULL value from self or other.

        In ClickHouse, NULL = NULL returns NULL (standard SQL semantics).
        Use coalesce to replace NULLs with a fallback value.

        Args:
            other: Fallback value — Object, View, or Python scalar

        Returns:
            Self: New Object with coalesced values
        """
        self.checkstale()
        other = await self._ensure_object(other)
        other.checkstale()
        _require_explicit_order_for_cross_table(self, other)
        info_a = self._get_query_info()
        info_b = other._get_query_info()
        return await operators.coalesce_op(info_a, info_b, self.ch_client)

    # arrayMap Operator

    async def array_map(self, other: Object | ValueScalarType, operator: str) -> Object:
        """
        Apply an element-wise operation using ClickHouse's arrayMap function.

        Uses ClickHouse's arrayMap function for element-wise operations.
        Raises an error when array sizes don't match.

        Args:
            other: Another Object or Python scalar to operate with
            operator: Operator symbol (e.g., '+', '-', '**', '==', '&')

        Returns:
            Self: New array Object with element-wise results

        Raises:
            DB::Exception: If both operands are arrays with different sizes

        Examples:
            >>> a = await create_object_from_value([1, 2, 3])
            >>> b = await create_object_from_value([10, 20, 30])
            >>> result = await a.array_map(b, '+')
            >>> await result.data()  # [11, 22, 33]
            >>>
            >>> # With scalar
            >>> result = await a.array_map(5, '*')
            >>> await result.data()  # [5, 10, 15]
            >>>
            >>> # Size mismatch raises error
            >>> c = await create_object_from_value([10, 20])
            >>> await a.array_map(c, '+')  # Raises DB::Exception
        """
        self.checkstale()
        other = await self._ensure_object(other)
        other.checkstale()
        info_a = self._get_query_info()
        info_b = other._get_query_info()
        return await operators.array_map_db(info_a, info_b, operator, self.ch_client)

    @staticmethod
    def _validate_expression(expression: str) -> None:
        """Validate a SQL expression for safety."""
        if ";" in expression:
            raise ValueError("Expression must not contain ';'")
        upper = expression.upper()
        if "SELECT" in upper.split():
            raise ValueError("Expression must not contain subqueries")

    def with_columns(self, columns: dict[str, Computed]) -> View:
        """Add computed columns to this Object, returning a View.

        Synchronous — no database call. The computed columns exist only
        in the View's SELECT list as ``expr AS name`` aliases.

        Args:
            columns: Mapping of column name to Computed(type, expression).

        Returns:
            View with original columns + computed expression aliases.

        Raises:
            ValueError: If columns is empty, name collides with existing,
                or Object is scalar.
        """
        self.checkstale()
        if not columns:
            raise ValueError("columns must be a non-empty dict")
        if self._schema.fieldtype == FIELDTYPE_SCALAR:
            raise ValueError("with_columns() cannot be used on scalar Objects")
        existing = set(self._schema.columns.keys())
        for name, computed in columns.items():
            if name in existing:
                raise ValueError(f"Computed column '{name}' collides with existing column")
            self._validate_expression(computed.expression)
        return View(self, computed_columns=columns)

    def _with_columns_trusted(self, columns: dict[str, Computed]) -> View:
        """Add computed columns without expression validation.

        For internal use only — expressions are built programmatically
        from trusted sources (e.g., table names) and may contain subqueries.
        """
        self.checkstale()
        if self._schema.fieldtype == FIELDTYPE_SCALAR:
            raise ValueError("with_columns() cannot be used on scalar Objects")
        existing = set(self._schema.columns.keys())
        for name in columns:
            if name in existing:
                raise ValueError(f"Computed column '{name}' collides with existing column")
        return View(self, computed_columns=columns)

    def rename(self, columns: dict[str, str]) -> View:
        """Rename columns, returning a View with aliased column names.

        Synchronous — no database call. The renamed columns exist only
        in the View's SELECT list as ``old_name AS new_name`` aliases.
        The original Object column names are mapped to new names in the
        View's schema, so insert() and other operations see the new names.

        Args:
            columns: Mapping of old_name -> new_name.

        Returns:
            View with renamed columns.

        Raises:
            ValueError: If columns is empty, old_name doesn't exist,
                or new_name collides with an existing column.
        """
        self.checkstale()
        if not columns:
            raise ValueError("columns must be a non-empty dict")
        if self._schema.fieldtype == FIELDTYPE_SCALAR:
            raise ValueError("rename() cannot be used on scalar Objects")
        existing = set(self._schema.columns.keys())
        renamed_away = set(columns.keys())
        for old_name in columns:
            if old_name not in existing:
                raise ValueError(f"Column '{old_name}' does not exist in schema")
        # Check for duplicate new names
        new_names = list(columns.values())
        if len(new_names) != len(set(new_names)):
            raise ValueError("Duplicate new column names in rename mapping")
        # Check collision: new names must not collide with non-renamed columns
        kept = existing - renamed_away
        for new_name in new_names:
            if new_name in kept:
                raise ValueError(f"Renamed column '{new_name}' collides with existing column")
        # Reject swap-style renames (a new name reuses another column's old
        # name). Allowing them would force the SELECT list to disambiguate
        # column refs from aliases — ClickHouse otherwise resolves the alias
        # before the column ref. Users who genuinely want to swap names
        # should rename in two steps with an intermediate column.
        for old_name, new_name in columns.items():
            if new_name != old_name and new_name in renamed_away:
                raise ValueError(
                    f"Renamed column '{new_name}' reuses a name being renamed away "
                    f"(from '{old_name}'). Rename in two steps via an intermediate name."
                )
        return View(self, renamed_columns=columns)

    def explode(self, *columns: str, left: bool = False) -> View:
        """Explode Array column(s) into individual rows.

        Returns a View (lazy, no materialization). Each element in the
        specified Array column(s) becomes its own row. Scalar columns
        are duplicated per element. Multiple columns are zipped (not
        a Cartesian product) — shorter arrays pad with type defaults.

        The ``left`` flag controls empty-array behavior:
        - Default (``False``): rows with empty arrays are dropped (``ARRAY JOIN``).
        - ``left=True``: rows with empty arrays are kept with NULL in the
          exploded column (``LEFT ARRAY JOIN``).

        Args:
            *columns: Array column name(s) to explode. At least one required.
            left: If True, use LEFT ARRAY JOIN (preserve rows with empty arrays).

        Returns:
            View with exploded rows and updated column types (Array(T) → T).

        Raises:
            ValueError: If no columns are specified.
            ValueError: If Object is not a dict type.
            ValueError: If a column does not exist.
            ValueError: If a column is not an Array type.
        """
        self.checkstale()
        if not columns:
            raise ValueError("explode() requires at least one column")
        if self._schema.fieldtype != FIELDTYPE_DICT:
            raise ValueError("explode() can only be used on dict Objects")
        for col in columns:
            if col not in self._effective_columns:
                raise ValueError(f"Column '{col}' does not exist in schema")
            col_info = self._effective_columns[col]
            if not col_info.array:
                raise ValueError(f"Column '{col}' is not an Array type. explode() requires Array columns.")
        return View(self, exploded_columns=list(columns), left_explode=left)

    # -----------------------------------------------------------------
    # Domain helpers — each delegates to with_columns()
    # -----------------------------------------------------------------

    def with_year(self, column: str, *, alias: str | None = None) -> View:
        """Extract year from a Date/DateTime column."""
        name = alias or f"{column}_year"
        return self.with_columns({name: Computed("UInt16", f"toYear({column})")})

    def with_month(self, column: str, *, alias: str | None = None) -> View:
        """Extract month (1-12) from a Date/DateTime column."""
        name = alias or f"{column}_month"
        return self.with_columns({name: Computed("UInt8", f"toMonth({column})")})

    def with_day_of_week(self, column: str, *, alias: str | None = None) -> View:
        """Extract day of week (1=Mon, 7=Sun) from a Date/DateTime column."""
        name = alias or f"{column}_dow"
        return self.with_columns({name: Computed("UInt8", f"toDayOfWeek({column})")})

    def with_date_diff(
        self,
        unit: str,
        col_a: str,
        col_b: str,
        *,
        alias: str | None = None,
    ) -> View:
        """Compute date difference between two columns.

        Args:
            unit: Time unit ('day', 'hour', 'minute', 'second', 'month', 'year')
            col_a: Start date column
            col_b: End date column
            alias: Result column name (default: '{col_a}_{col_b}_diff')
        """
        name = alias or f"{col_a}_{col_b}_diff"
        return self.with_columns({name: Computed("Int64", f"dateDiff('{unit}', {col_a}, {col_b})")})

    def with_lower(self, column: str, *, alias: str | None = None) -> View:
        """Lowercase a String column."""
        name = alias or f"{column}_lower"
        return self.with_columns({name: Computed("String", f"lower({column})")})

    def with_upper(self, column: str, *, alias: str | None = None) -> View:
        """Uppercase a String column."""
        name = alias or f"{column}_upper"
        return self.with_columns({name: Computed("String", f"upper({column})")})

    def with_length(self, column: str, *, alias: str | None = None) -> View:
        """String length of a column."""
        name = alias or f"{column}_length"
        return self.with_columns({name: Computed("UInt64", f"length({column})")})

    def with_trim(self, column: str, *, alias: str | None = None) -> View:
        """Trim whitespace from a String column."""
        name = alias or f"{column}_trimmed"
        return self.with_columns({name: Computed("String", f"trim({column})")})

    def with_split_by_char(
        self,
        column: str,
        separator: str,
        *,
        element_type: str = "String",
        alias: str | None = None,
    ) -> View:
        """Split a String column into an Array using a character separator.

        Uses ClickHouse's splitByChar(sep, col) function.

        Args:
            column: Source string column name.
            separator: Single character to split on.
            element_type: ClickHouse type for array elements (default "String").
            alias: Result column name (default: '{column}_parts').
        """
        from .transforms import split_by_char

        name = alias or f"{column}_parts"
        return self.with_columns({name: split_by_char(column, separator, element_type=element_type)})

    def with_abs(self, column: str, *, alias: str | None = None) -> View:
        """Absolute value of a numeric column. Result type is Float64."""
        name = alias or f"{column}_abs"
        return self.with_columns({name: Computed("Float64", f"abs({column})")})

    def with_log2(self, column: str, *, alias: str | None = None) -> View:
        """Log base 2 of a numeric column."""
        name = alias or f"{column}_log2"
        return self.with_columns({name: Computed("Float64", f"log2({column})")})

    def with_sqrt(self, column: str, *, alias: str | None = None) -> View:
        """Square root of a numeric column."""
        name = alias or f"{column}_sqrt"
        return self.with_columns({name: Computed("Float64", f"sqrt({column})")})

    def with_bucket(self, column: str, size: int, *, alias: str | None = None) -> View:
        """Integer division bucketing: intDiv(column, size)."""
        name = alias or f"{column}_bucket"
        return self.with_columns({name: Computed("Int64", f"intDiv({column}, {size})")})

    def with_hash_bucket(self, column: str, n_buckets: int, *, alias: str | None = None) -> View:
        """Hash bucketing: cityHash64(column) % n_buckets."""
        name = alias or f"{column}_hash"
        return self.with_columns({name: Computed("UInt64", f"cityHash64({column}) % {n_buckets}")})

    def with_if(
        self,
        condition: str,
        then_value: str,
        else_value: str,
        *,
        alias: str,
        type: str = "String",
    ) -> View:
        """Conditional column: if(condition, then, else).

        Args:
            condition: SQL boolean expression
            then_value: Value when true (SQL literal or column)
            else_value: Value when false (SQL literal or column)
            alias: Required — result column name
            type: ClickHouse result type (default 'String')
        """
        return self.with_columns({alias: Computed(type, f"if({condition}, {then_value}, {else_value})")})

    def with_cast(
        self,
        column: str,
        to_type: str,
        *,
        nullable: bool = False,
        alias: str | None = None,
    ) -> View:
        """Cast a column to a different ClickHouse type.

        Args:
            column: Source column name.
            to_type: Target ClickHouse base type, e.g. "UInt32", "Float64", "String".
            nullable: Use to{Type}OrNull and wrap in Nullable (default False).
            alias: Result column name (default: '{column}_{to_type.lower()}').
        """
        from .transforms import cast

        name = alias or f"{column}_{to_type.lower()}"
        return self.with_columns({name: cast(column, to_type, nullable=nullable)})

    def with_isin(
        self,
        column: str,
        other: Object,
        *,
        alias: str | None = None,
    ) -> View:
        """Computed boolean column: 1 if column value is in other Object's values.

        Uses an IN subquery: ``column IN (SELECT value FROM other_table)``.

        Args:
            column: Source column name to test membership for.
            other: Object whose values define the allowed set.
            alias: Result column name (default: '{column}_isin').
        """
        other.checkstale()
        name = alias or f"{column}_isin"
        other_info = other._get_query_info()
        subquery = f"SELECT value FROM {other_info.source}"
        expr = f"{quote_identifier(column)} IN ({subquery})"
        return self._with_columns_trusted({name: Computed("UInt8", expr)})

    def view(
        self,
        where: str | None = None,
        limit: int | None = None,
        offset: int | None = None,
        order_by: str | None = None,
    ) -> View:
        """
        Create a read-only view of this object with query constraints.

        Args:
            where: Optional WHERE clause condition
            limit: Optional LIMIT for number of rows
            offset: Optional OFFSET for skipping rows
            order_by: Optional ORDER BY clause

        Returns:
            View: A new View instance with the specified constraints

        Examples:
            >>> obj = await ctx.create_object_from_value([1, 2, 3, 4, 5])
            >>> view = obj.view(where="value > 2", limit=2)
            >>> await view.data()  # Returns [3, 4]
        """
        return View(self, where=where, limit=limit, offset=offset, order_by=order_by)

    def where(self, condition: str) -> View:
        """
        Create a View with a WHERE condition.

        Shortcut for obj.view(where=condition). The returned View supports
        chaining with .where() (AND) and .or_where() (OR).

        Args:
            condition: Raw SQL WHERE expression (e.g., 'value > 100')

        Returns:
            View with the WHERE condition applied

        Examples:
            >>> obj = await create_object_from_value([1, 2, 3, 4, 5])
            >>> view = obj.where("value > 2").where("value < 5")
            >>> await view.data()  # Returns [3, 4]
        """
        return View(self, where=condition)

    def or_where(self, condition: str) -> View:
        """
        Create a View with an OR WHERE condition.

        Note: or_where() on Object requires a prior where() call.
        Use where() first to start a chain.

        Raises:
            ValueError: Always, since there's no prior WHERE clause on a plain Object.
                       Use where() first: obj.where('x > 5').or_where('y < 3')
        """
        raise ValueError(
            "or_where() requires a prior where condition. Use where() first: obj.where('x > 5').or_where('y < 3')"
        )

    def group_by(self, *keys: str) -> GroupByQuery:
        """
        Group data by one or more columns, returning a GroupByQuery.

        The GroupByQuery is an intermediate object that stores grouping info.
        Call aggregation methods (sum, mean, count, etc.) on it to produce
        a dict Object with the grouped results.

        Works on both dict Objects (group by named columns) and array Objects
        (group by 'value' for value_counts-style operations).

        Args:
            *keys: Column name(s) to group by

        Returns:
            GroupByQuery: Intermediate object for applying aggregations

        Raises:
            ValueError: If no keys provided, or key doesn't exist

        Examples:
            >>> obj = await create_object_from_value({
            ...     'category': ['A', 'A', 'B', 'B'],
            ...     'amount': [10, 20, 30, 40],
            ... })
            >>> result = await obj.group_by('category').sum('amount')
            >>> await result.data()  # {'category': ['A', 'B'], 'amount': [30, 70]}
        """
        self.checkstale()
        return GroupByQuery(self, list(keys))

    def __getitem__(self, key: str | list[str]) -> View:
        """
        Select field(s) from a dict Object, returning a View.

        Creates a View that selects only the specified column(s) from the dict Object.
        The View can be used in operations or materialized with copy().

        Args:
            key: Column name (str) or list of column names (list) to select

        Returns:
            View: A new View instance selecting the specified field(s)

        Examples:
            >>> obj = await create_object_from_value({'param1': [123, 234], 'param2': [456, 342]})
            >>>
            >>> # Single field selector - returns array-like view
            >>> view = obj['param1']
            >>> await view.data()  # Returns [123, 234]
            >>> arr = await view.copy()  # Returns new array Object
            >>>
            >>> # Multi-field selector - returns dict-like view
            >>> view = obj[['param1', 'param2']]
            >>> await view.data()  # Returns {'param1': [123, 234], 'param2': [456, 342]}
            >>> dict_obj = await view.copy()  # Returns new dict Object
        """
        self.checkstale()
        # Always store as list - single field is just a list of one
        fields = key if isinstance(key, list) else [key]
        return View(self, selected_fields=fields)

    def __repr__(self) -> str:
        """String representation of the Object."""
        return f"Object(table='{self.table}')"


class GroupByQuery:
    """
    Intermediate object representing a GROUP BY operation on an Object.

    GroupByQuery stores the source Object and grouping keys. It does NOT
    inherit from Object because it has no ClickHouse table, no data(),
    and no lifecycle management.

    Call aggregation methods (sum, mean, count, etc.) to execute the
    GROUP BY query and produce a dict Object with the results.

    Examples:
        >>> obj = await create_object_from_value({
        ...     'category': ['A', 'A', 'B', 'B'],
        ...     'amount': [10, 20, 30, 40],
        ... })
        >>> result = await obj.group_by('category').sum('amount')
        >>> await result.data()  # {'category': ['A', 'B'], 'amount': [30, 70]}
    """

    def __init__(self, source: Object, keys: list[str]):
        """
        Initialize a GroupByQuery.

        Args:
            source: Source Object to group
            keys: List of column names to group by

        Raises:
            ValueError: If no keys, or key doesn't exist in schema
        """
        if not keys:
            raise ValueError("group_by requires at least one key")

        schema = source._schema
        if schema is None:
            raise ValueError("Source object has no cached schema")

        if isinstance(source, View) and source.is_single_field:
            # Single-field View projects to {value}
            available = {"value"}
        elif isinstance(source, View) and source.selected_fields:
            available = set(source.selected_fields)
        else:
            available = set(schema.columns)
        # Include computed columns
        if source.computed_columns:
            available |= set(source.computed_columns.keys())

        for key in keys:
            if key not in available:
                raise ValueError(f"Key '{key}' not found in source columns. Available: {sorted(available)}")

        self._source = source
        self._keys = keys
        self._having_clauses: list[tuple[str, str]] = []

    @property
    def ch_client(self):
        """Get the ClickHouse client from the source object."""
        return self._source.ch_client

    def _clone_with_having(self, condition: str, connector: str) -> GroupByQuery:
        """Create a new GroupByQuery with all current state plus an additional HAVING clause."""
        new_gbq = GroupByQuery.__new__(GroupByQuery)
        new_gbq._source = self._source
        new_gbq._keys = self._keys
        new_gbq._having_clauses = list(self._having_clauses)
        new_gbq._having_clauses.append((condition, connector))
        return new_gbq

    def having(self, condition: str) -> GroupByQuery:
        """
        Return a new GroupByQuery with an AND-chained HAVING condition.

        Multiple calls chain with AND:
            .having('sum(x) > 10').having('count() >= 2')
            → HAVING (sum(x) > 10) AND (count() >= 2)

        The original GroupByQuery is not modified.

        Args:
            condition: Raw SQL HAVING expression (e.g., 'sum(amount) > 100')

        Returns:
            New GroupByQuery with the condition added

        Raises:
            ValueError: If condition is empty
        """
        if not condition or not condition.strip():
            raise ValueError("HAVING condition must be a non-empty string")
        return self._clone_with_having(condition.strip(), "AND")

    def or_having(self, condition: str) -> GroupByQuery:
        """
        Return a new GroupByQuery with an OR-chained HAVING condition.

        Use after .having() to add an alternative condition:
            .having('sum(x) > 100').or_having('count() >= 5')
            → HAVING (sum(x) > 100) OR (count() >= 5)

        The original GroupByQuery is not modified.

        Args:
            condition: Raw SQL HAVING expression (e.g., 'max(amount) > 50')

        Returns:
            New GroupByQuery with the condition added

        Raises:
            ValueError: If condition is empty or no prior having clause exists
        """
        if not condition or not condition.strip():
            raise ValueError("HAVING condition must be a non-empty string")
        if not self._having_clauses:
            raise ValueError("or_having() requires a prior having() call")
        return self._clone_with_having(condition.strip(), "OR")

    def _build_having(self) -> str | None:
        """Build the combined HAVING clause from stored conditions."""
        if not self._having_clauses:
            return None
        parts = []
        for i, (condition, connector) in enumerate(self._having_clauses):
            if i == 0:
                parts.append(f"({condition})")
            else:
                parts.append(f"{connector} ({condition})")
        return " ".join(parts)

    def _get_group_by_info(self) -> GroupByInfo:
        """
        Build GroupByInfo from the source Object.

        Handles plain Objects, Views with WHERE/LIMIT constraints,
        multi-field Views, and single-field Views.

        Returns:
            GroupByInfo with source, group keys, and column metadata
        """
        source = self._source
        schema = source._schema

        # Determine source query and columns based on source type
        if isinstance(source, View):
            if source.is_single_field and source.selected_fields:
                # Single-field View projects to {value}
                field = source.selected_fields[0]
                col_def = schema.columns.get(field, ColumnInfo("Float64"))
                columns = {"value": col_def.type}
                source_query = f"({source._build_select()})"
            elif source.selected_fields:
                columns = {}
                for field in source.selected_fields:
                    col_def = schema.columns.get(field, ColumnInfo("Float64"))
                    columns[field] = col_def.type
                if source.computed_columns:
                    for col_name, comp in source.computed_columns.items():
                        columns[col_name] = comp.type
                source_query = f"({source._build_select()})"
            elif source.has_constraints:
                # WHERE/LIMIT View: full columns, wrapped in subquery
                columns = {k: cd.type for k, cd in schema.columns.items()}
                if source.computed_columns:
                    for col_name, comp in source.computed_columns.items():
                        columns[col_name] = comp.type
                source_query = f"({source._build_select()})"
            else:
                # Base View (no constraints): same as plain Object
                columns = {k: cd.type for k, cd in schema.columns.items()}
                source_query = source.table
        else:
            # Plain Object
            columns = {k: cd.type for k, cd in schema.columns.items()}
            source_query = (
                f"({source._build_select()})"
                if hasattr(source, "has_constraints") and source.has_constraints
                else source.table
            )

        return GroupByInfo(
            source=source_query,
            base_table=source.table,
            group_keys=self._keys,
            columns=columns,
            fieldtype=schema.fieldtype,
            having=self._build_having(),
        )

    async def agg(
        self,
        aggregations: dict[str, AggSpec],
        *,
        name: str | None = None,
        scope: NamedScope | None = None,
    ) -> Object:
        """
        Apply aggregations per group. Core method — all convenience methods delegate here.

        Each entry maps a source column to an aggregation spec:
            - ``str``:  plain operator, result alias = column name
            - ``Agg(op, alias)``:  single operator with explicit alias
            - ``[Agg(op, alias), ...]``:  multiple operators on the same column

        For 'count', the column key becomes the result column name
        and count() is called without arguments.

        Args:
            aggregations: Dict mapping source_column -> AggSpec
            name: Optional result table name. When set, the result is
                  registered in the table registry under this name. See
                  ``create_object`` for the naming/scoping rules.
            scope: Lifetime tier for the named result —
                  ``"temp_named"`` (default when ``name`` is set), ``"job"``,
                  or ``"global"``. Must be ``None`` when ``name`` is ``None``.

        Returns:
            Dict Object with group keys + all aggregated columns

        Examples:
            >>> result = await obj.group_by('category').agg({
            ...     'amount': "sum",
            ...     'price': "mean",
            ... })
            >>> result = await obj.group_by('category').agg({
            ...     'amount': [Agg("sum", 'amount_sum'), Agg("mean", 'amount_avg')],
            ... })
            >>> # Persist the grouped result under a stable job-scoped name
            >>> await obj.group_by('category').agg(
            ...     {'amount': "sum"}, name="daily_totals", scope="job"
            ... )
        """
        info = self._get_group_by_info()
        return await operators.group_by_agg(info, aggregations, self.ch_client, name=name, scope=scope)

    async def sum(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: sum per group. Delegates to agg()."""
        return await self.agg({column: GB_SUM}, name=name, scope=scope)

    async def mean(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: mean per group. Delegates to agg()."""
        return await self.agg({column: GB_MEAN}, name=name, scope=scope)

    async def min(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: min per group. Delegates to agg()."""
        return await self.agg({column: GB_MIN}, name=name, scope=scope)

    async def max(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: max per group. Delegates to agg()."""
        return await self.agg({column: GB_MAX}, name=name, scope=scope)

    async def count(self, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: count per group. Delegates to agg()."""
        return await self.agg({"_count": GB_COUNT}, name=name, scope=scope)

    async def std(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: std per group. Delegates to agg()."""
        return await self.agg({column: GB_STD}, name=name, scope=scope)

    async def var(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: var per group. Delegates to agg()."""
        return await self.agg({column: GB_VAR}, name=name, scope=scope)

    async def any(self, column: str, *, name: str | None = None, scope: NamedScope | None = None) -> Object:
        """Convenience: any (pick arbitrary non-NULL) per group. Delegates to agg()."""
        return await self.agg({column: GB_ANY}, name=name, scope=scope)

    async def group_array_distinct(
        self, column: str, *, name: str | None = None, scope: NamedScope | None = None
    ) -> Object:
        """Convenience: collect distinct values into an array per group. Delegates to agg()."""
        return await self.agg({column: GB_GROUP_ARRAY_DISTINCT}, name=name, scope=scope)

    def __repr__(self) -> str:
        """String representation of the GroupByQuery."""
        keys_str = ", ".join(f"'{k}'" for k in self._keys)
        return f"GroupByQuery(keys=[{keys_str}])"


class View(Object):
    """
    A view of an Object with query constraints (WHERE, LIMIT, OFFSET, ORDER BY).

    Views are read-only and reference the same underlying table as their source Object.
    They cannot be modified with operations like insert().

    Views can also select fields from a dict Object using selected_fields.
    Single-field selection (len=1) returns array-like data, multi-field returns dict-like.
    """

    def __init__(
        self,
        source: Object,
        where: str | None = None,
        limit: int | None = None,
        offset: int | None = None,
        order_by: str | None = None,
        selected_fields: list[str] | None = None,
        computed_columns: dict[str, Computed] | None = None,
        renamed_columns: dict[str, str] | None = None,
        where_connector: str = "AND",
        exploded_columns: list[str] | None = None,
        left_explode: bool = False,
    ):
        """
        Initialize a View.

        Args:
            source: Source Object to create view from
            where: Optional WHERE clause
            limit: Optional LIMIT
            offset: Optional OFFSET
            order_by: Optional ORDER BY clause
            selected_fields: Optional list of field names to select
                             Single field [name] returns array-like view
                             Multiple fields returns dict-like view
            computed_columns: Optional dict of computed column definitions
            renamed_columns: Optional dict mapping old_name -> new_name
            where_connector: Connector for the where clause ("AND" or "OR")
            exploded_columns: Optional list of Array column names to ARRAY JOIN
            left_explode: If True, use LEFT ARRAY JOIN (preserve rows with empty arrays)
        """
        super().__init__(table=source.table, schema=source._schema)

        # Inherit existing constraints when source is already a View
        is_view = isinstance(source, View)
        self._where_clauses: list[tuple[str, str]] = list(source._where_clauses) if is_view else []
        if where:
            self._where_clauses.append((where.strip(), where_connector))
        self._limit = limit if limit is not None else (source._limit if is_view else None)
        self._offset = offset if offset is not None else (source._offset if is_view else None)
        self._order_by = order_by if order_by is not None else (source._order_by if is_view else None)
        self._selected_fields = (
            selected_fields if selected_fields is not None else (source._selected_fields if is_view else None)
        )
        self._computed_columns: dict[str, Computed] | None = (
            computed_columns if computed_columns is not None else (source._computed_columns if is_view else None)
        )
        self._renamed_columns: dict[str, str] | None = (
            renamed_columns if renamed_columns is not None else (source._renamed_columns if is_view else None)
        )
        self._exploded_columns: list[str] = (
            list(exploded_columns)
            if exploded_columns is not None
            else (list(source._exploded_columns) if is_view else [])
        )
        self._left_explode: bool = (
            left_explode if exploded_columns is not None else (source._left_explode if is_view else False)
        )

        # Register with context for lifecycle tracking and stale marking.
        # Views do NOT call incref/decref: the source Object owns the lifecycle
        # ref. Storing _source_obj keeps source alive via Python ref-counting
        # so decref is deferred until the source Object itself is GC'd.
        if source._registered:
            self._registered = True
            self._source_obj = source
            register_object(self)

    @property
    def limit(self) -> int | None:
        """Get LIMIT."""
        return self._limit

    @property
    def offset(self) -> int | None:
        """Get OFFSET."""
        return self._offset

    @property
    def _explicit_order_by(self) -> str | None:
        return self._order_by

    @property
    def where_clauses(self) -> list[tuple[str, str]]:
        """Get WHERE clauses."""
        return self._where_clauses

    @property
    def selected_fields(self) -> list[str] | None:
        """Get selected field names."""
        return self._selected_fields

    @property
    def computed_columns(self) -> dict[str, Computed] | None:
        """Get computed columns."""
        return self._computed_columns

    @property
    def renamed_columns(self) -> dict[str, str] | None:
        """Get renamed columns mapping old->new."""
        return self._renamed_columns

    @property
    def exploded_columns(self) -> list[str]:
        """Get exploded column names."""
        return self._exploded_columns

    @property
    def left_explode(self) -> bool:
        """Get whether LEFT ARRAY JOIN is used."""
        return self._left_explode

    @functools.cached_property
    def _inv_renames(self) -> dict[str, str]:
        """Reverse map post-rename name -> original column name."""
        return {new: old for old, new in (self._renamed_columns or {}).items()}

    @functools.cached_property
    def _effective_columns(self) -> dict[str, ColumnInfo]:
        """Column schema with renames, field selection, and computed columns applied.

        Returns the effective column names and types as seen by consumers
        (data(), insert(), concat). Accounts for:
        - Field selection (narrows to selected fields)
        - Renamed columns (old_name -> new_name)
        - Computed columns (added as new columns)

        Cached because View instances are immutable — all fields are set at
        init and never mutated in place.
        """
        orig = self._schema.columns
        renames = self._renamed_columns or {}
        inv_renames = self._inv_renames

        # selected_fields hold post-rename names; map them back to source
        # column names via the inverse rename when looking up types in
        # ``orig``.
        if self._selected_fields and self.is_single_field:
            field = self._selected_fields[0]
            src_name = inv_renames.get(field, field)
            col_def = orig.get(src_name, ColumnInfo("Float64"))
            columns = {"value": col_def}
        elif self._selected_fields:
            columns = {}
            for f in self._selected_fields:
                src_name = inv_renames.get(f, f)
                columns[f] = orig[src_name]
        else:
            columns = {renames.get(name, name): info for name, info in orig.items()}

        if self._computed_columns:
            for name, comp in self._computed_columns.items():
                columns[name] = parse_ch_type(comp.type)

        if self._exploded_columns:
            renames = self._renamed_columns or {}
            for col_name in self._exploded_columns:
                if self._selected_fields and self.is_single_field:
                    effective_name = "value" if col_name == self._selected_fields[0] else col_name
                else:
                    effective_name = renames.get(col_name, col_name)
                if effective_name in columns:
                    old_info = columns[effective_name]
                    new_depth = max(0, int(old_info.array) - 1)
                    columns[effective_name] = ColumnInfo(
                        type=old_info.type,
                        nullable=old_info.nullable,
                        array=new_depth,
                        low_cardinality=old_info.low_cardinality,
                    )

        return columns

    def _serialize_ref(self) -> dict:
        """Serialize this View to a reference dict for task kwargs/results."""
        return ViewRef(
            table=self.table,
            where=self._build_where(),
            limit=self.limit,
            offset=self.offset,
            order_by=self.order_by,
            selected_fields=self.selected_fields,
            renamed_columns=self._renamed_columns,
            persistent=True if self.persistent else None,
        ).to_dict()

    def where(self, condition: str) -> View:
        """
        Return a new View with an AND-chained WHERE condition.

        Multiple calls chain with AND:
            .where('x > 10').where('y < 20')
            → WHERE (x > 10) AND (y < 20)

        The original View is not modified.

        Args:
            condition: Raw SQL WHERE expression (e.g., 'value > 100')

        Returns:
            New View with the condition added

        Raises:
            ValueError: If condition is empty
        """
        if not condition or not condition.strip():
            raise ValueError("WHERE condition must be a non-empty string")
        return View(self, where=condition.strip())

    def or_where(self, condition: str) -> View:
        """
        Return a new View with an OR-chained WHERE condition.

        Use after .view(where=...) or .where() to add an alternative condition:
            .where('x > 100').or_where('y < 5')
            → WHERE (x > 100) OR (y < 5)

        The original View is not modified.

        Args:
            condition: Raw SQL WHERE expression (e.g., 'value < 10')

        Returns:
            New View with the condition added

        Raises:
            ValueError: If condition is empty or no prior where clause exists
        """
        if not condition or not condition.strip():
            raise ValueError("WHERE condition must be a non-empty string")
        if not self.where_clauses:
            raise ValueError("or_where() requires a prior where condition")
        return View(self, where=condition.strip(), where_connector="OR")

    def with_columns(self, columns: dict[str, Computed]) -> View:
        """Add computed columns to this View, returning a new View.

        Additive — merges with any existing computed columns.
        Preserves all existing constraints (WHERE, LIMIT, etc.).
        """
        self.checkstale()
        if not columns:
            raise ValueError("columns must be a non-empty dict")

        # Check collision with effective column names (renames + computed applied)
        existing = set(self._effective_columns.keys())
        for name, computed in columns.items():
            if name in existing:
                raise ValueError(f"Computed column '{name}' collides with existing column")
            self._validate_expression(computed.expression)

        merged = dict(self.computed_columns) if self.computed_columns else {}
        merged.update(columns)
        return View(self, computed_columns=merged)

    def _with_columns_trusted(self, columns: dict[str, Computed]) -> View:
        """Add computed columns without expression validation (View override).

        For internal use only — see Object._with_columns_trusted.
        """
        self.checkstale()
        existing = set(self._effective_columns.keys())
        for name in columns:
            if name in existing:
                raise ValueError(f"Computed column '{name}' collides with existing column")
        merged = dict(self.computed_columns) if self.computed_columns else {}
        merged.update(columns)
        return View(self, computed_columns=merged)

    def _build_select(
        self,
        columns: str = "*",
        default_order_by: str | None = None,
        skip_order_by: bool = False,
        *,
        order_by: Any = _UNSET,
        limit: Any = _UNSET,
        offset: Any = _UNSET,
    ) -> str:
        """
        Build a SELECT query with view constraints applied.

        For single-field selection, renames the field as 'value' for array compatibility.
        For multi-field selection, selects all specified fields.
        If columns="value" is requested, only the value column is selected.
        Computed columns are appended as ``expr AS name`` aliases.
        Renamed columns are emitted as ``old_name AS new_name`` aliases.

        Args:
            columns: Column specification (default "*", respected for field selection views)
            default_order_by: Default ORDER BY clause if view doesn't have custom order_by
            skip_order_by: If True, omit ORDER BY from the query. Used by copy()
                to avoid a wasted sort when the order is preserved as a View.
            order_by/limit/offset: Per-call overrides — when not ``_UNSET``,
                used in place of the View's stored attributes.

        Returns:
            str: SELECT query string with WHERE/LIMIT/OFFSET/ORDER BY applied
        """
        if self.selected_fields:
            # Carry aai_id through field-selected subqueries so binary
            # operators can propagate it onto the result table — without
            # this, ``(obj["x"] + array_b)`` would build a source subquery
            # that omits aai_id, and the operator's outer SELECT can't find it.
            aai_id_proj = ", aai_id" if AAI_ID_COLUMN in self._schema.columns else ""
            inv_renames = self._inv_renames
            if self.is_single_field:
                src = inv_renames.get(self.selected_fields[0], self.selected_fields[0])
                select_cols = f"{quote_identifier(src)} AS value{aai_id_proj}"
            else:
                parts = []
                for f in self.selected_fields:
                    src = inv_renames.get(f, f)
                    if src != f:
                        parts.append(f"{quote_identifier(src)} AS {quote_identifier(f)}")
                    else:
                        parts.append(quote_identifier(src))
                select_cols = ", ".join(parts) + aai_id_proj
        elif self._renamed_columns and columns == "*":
            renames = self._renamed_columns
            col_parts = []
            for col_name in self._schema.columns:
                qname = quote_identifier(col_name)
                if col_name in renames:
                    col_parts.append(f"{qname} AS {quote_identifier(renames[col_name])}")
                else:
                    col_parts.append(qname)
            select_cols = ", ".join(col_parts)
        else:
            select_cols = columns

        # Computed columns that are being exploded via ARRAY JOIN must be placed
        # in the ARRAY JOIN clause (not SELECT), because ClickHouse processes
        # ARRAY JOIN before SELECT aliases — referencing a SELECT alias in
        # ARRAY JOIN would not resolve correctly.
        exploded_computed = (
            set(self._exploded_columns) & set(self._computed_columns)
            if self._exploded_columns and self._computed_columns
            else set()
        )

        # Append non-exploded computed column expressions to SELECT.
        # Exploded computed columns go into the ARRAY JOIN clause instead,
        # but their aliases still need to appear in the SELECT list so they
        # are included in the result (ClickHouse SELECT * does not automatically
        # include ARRAY JOIN aliases).
        if self._computed_columns:
            computed_parts = []
            for name, comp in self._computed_columns.items():
                if name not in exploded_computed:
                    computed_parts.append(f"{comp.expression} AS {quote_identifier(name)}")
            for name in exploded_computed:
                computed_parts.append(quote_identifier(name))
            if computed_parts:
                select_cols += ", " + ", ".join(computed_parts)

        query = f"SELECT {select_cols} FROM {self.table}"
        if self._exploded_columns:
            join_type = "LEFT ARRAY JOIN" if self._left_explode else "ARRAY JOIN"
            join_parts = []
            for col in self._exploded_columns:
                if col in exploded_computed:
                    # Put the computed expression directly in ARRAY JOIN
                    assert self._computed_columns is not None
                    expr = self._computed_columns[col].expression
                    join_parts.append(f"{expr} AS {quote_identifier(col)}")
                else:
                    join_parts.append(quote_identifier(col))
            query += f" {join_type} {', '.join(join_parts)}"
        eff_order_by = order_by if order_by is not _UNSET else self.order_by
        eff_limit = limit if limit is not _UNSET else self.limit
        eff_offset = offset if offset is not _UNSET else self.offset

        where_clause = self._build_where()
        if where_clause:
            query += f" WHERE {where_clause}"
        if not skip_order_by:
            order_clause = eff_order_by or default_order_by
            if order_clause:
                query += f" ORDER BY {order_clause}"
        if eff_limit is not None:
            query += f" LIMIT {eff_limit}"
        if eff_offset is not None:
            query += f" OFFSET {eff_offset}"
        return query

    def _get_copy_info(self) -> CopyInfo:
        """Get copy info for database-level copy operations.

        ORDER BY is stripped from the source query and passed separately
        via CopyInfo.order_by — copy_db() uses it to sort during INSERT.
        """
        has_non_order_constraints = bool(
            self.where_clauses
            or self.limit is not None
            or self.offset is not None
            or self.selected_fields
            or self.computed_columns
            or self.renamed_columns
            or self.exploded_columns
        )
        if has_non_order_constraints:
            source_query = f"({self._build_select(skip_order_by=True)})"
        else:
            source_query = self.table
        # Apply renames so the destination schema and the INSERT/SELECT
        # column list use the post-rename names — matching the aliases
        # emitted by the inner ``_build_select``. Without this, ``copy_db``
        # builds ``INSERT INTO new (orig_name) SELECT orig_name FROM
        # (... orig AS new_name ...)`` which fails with ClickHouse Code 47
        # because ``orig_name`` is no longer exposed by the inner SELECT.
        renames = self._renamed_columns or {}
        columns = {renames.get(name, name): info for name, info in self._schema.columns.items()}

        # Include computed columns in destination schema so copy() materializes them
        if self._computed_columns:
            for col_name, computed in self._computed_columns.items():
                columns[col_name] = parse_ch_type(computed.type)

        if self._exploded_columns:
            for col_name in self._exploded_columns:
                effective_name = renames.get(col_name, col_name)
                if effective_name in columns:
                    old_info = columns[effective_name]
                    columns[effective_name] = old_info.model_copy(update={"array": max(0, int(old_info.array) - 1)})
        return CopyInfo(
            source_query=source_query,
            fieldtype=self._schema.fieldtype,
            columns=columns,
            selected_fields=self.selected_fields,
            is_single_field=self.is_single_field,
            order_by=self.order_by,
        )

    async def data(
        self,
        orient: str = ORIENT_DICT,
        *,
        order_by: Any = _UNSET,
        offset: Any = _UNSET,
        limit: Any = _UNSET,
    ) -> Any:
        """
        Get the data from the view.

        For single-field selection, returns array data (the selected column).
        For multi-field selection, returns dict data (subset of columns).
        For computed columns, returns dict data with computed values included.

        Args:
            orient: Output format for dict data
            order_by: Per-call ``ORDER BY`` override — see ``Object.data``.
            offset: Per-call ``OFFSET`` override — see ``Object.data``.
            limit: Per-call ``LIMIT`` override — see ``Object.data``.
        """
        self.checkstale()

        # Resolve overrides against View state. Same rule as Object.data:
        # _UNSET means "caller didn't pass" → fall back to the View attr;
        # explicit None means "no value" (e.g. limit=None to disable any cap).
        eff_order_by = order_by if order_by is not _UNSET else self.order_by
        eff_offset = offset if offset is not _UNSET else self.offset
        eff_limit = limit if limit is not _UNSET else self.limit
        ext_kwargs = {"order_by": eff_order_by, "offset": eff_offset, "limit": eff_limit}

        if self.selected_fields:
            if self.is_single_field:
                return await data_extraction.extract_array_data(self, **ext_kwargs)
            columns: dict[str, ColumnInfo] = {
                field: ColumnInfo("String", fieldtype=FIELDTYPE_ARRAY) for field in self.selected_fields
            }
            column_names = list(self.selected_fields)
            return await data_extraction.extract_dict_data(self, column_names, columns, orient, **ext_kwargs)

        if self.computed_columns or self._renamed_columns or self._exploded_columns:
            eff = self._effective_columns
            columns: dict[str, ColumnInfo] = {name: ColumnInfo("String", fieldtype=FIELDTYPE_ARRAY) for name in eff}
            column_names = list(eff.keys())
            return await data_extraction.extract_dict_data(self, column_names, columns, orient, **ext_kwargs)

        # Delegate to parent for normal views
        return await super().data(orient=orient, order_by=order_by, offset=offset, limit=limit)

    @property
    def schema(self) -> ViewSchema:
        """Get schema for this view including view constraints."""
        return ViewSchema(
            fieldtype=self._schema.fieldtype,
            columns=self._schema.columns,
            table=self._schema.table,
            where=self._build_where(),
            limit=self._limit,
            offset=self._offset,
            order_by=self._order_by,
            selected_fields=self.selected_fields,
            computed_columns=self.computed_columns,
        )

    def _get_ingest_query_info(self) -> IngestQueryInfo:
        """Build effective column schema for insert/concat validation.

        Delegates to effective_columns property for column resolution.
        """
        info = self._get_query_info()
        return IngestQueryInfo(**vars(info), columns=self._effective_columns)

    async def insert(self, *args) -> None:
        """Views are read-only and cannot be modified."""
        raise RuntimeError("Cannot insert into a view")

    def __repr__(self) -> str:
        """String representation of the View."""
        constraints = []
        if self.selected_fields:
            constraints.append(f"selected_fields={self.selected_fields}")
        where_clause = self._build_where()
        if where_clause:
            constraints.append(f"where='{where_clause}'")
        if self.limit:
            constraints.append(f"limit={self.limit}")
        if self.offset:
            constraints.append(f"offset={self.offset}")
        if self.order_by:
            constraints.append(f"order_by='{self.order_by}'")
        constraint_str = ", ".join(constraints) if constraints else "no constraints"
        return f"View(table='{self.table}', {constraint_str})"


def _is_unmaterialized_lazy(value) -> bool:
    """True when ``value`` is a LazyOperator that hasn't materialized yet —
    sync checks that read ``.table`` must skip these (the table doesn't exist)."""
    return isinstance(value, LazyOperator) and value._materialized is None


def _peek_other_metadata(value) -> tuple[str, str, bool, ColumnInfo | None]:
    """Same shape as ``Object._peek_op_metadata`` but for an arbitrary RHS
    operand (Object / LazyOperator / Python scalar). Used by the planners to
    derive the operator's result schema without materializing.
    """
    if isinstance(value, Object):
        return value._peek_op_metadata()
    # Python scalar: fall back to scalar-schema inference.
    schema = _scalar_to_schema(value)
    col = schema.columns["value"]
    return schema.fieldtype, col.type, col.nullable, None


async def _resolve_operand(value: LazyOperator | Object | ValueScalarType | None) -> Object | None:
    """Resolve an operand to a materialized Object.

    - LazyOperator → materialize and unwrap.
    - Object → return as-is.
    - Python scalar → ``Object._ensure_object``.
    - None → return ``None`` (unary / aggregation operators have no RHS).
    """
    if value is None:
        return None
    if isinstance(value, LazyOperator):
        return await value._materialize()
    if isinstance(value, Object):
        return value
    return await Object._ensure_object(value)


# Operator dispatch tables for unary / aggregation LazyOperators (rhs=None).
# Each entry maps an operator name to a coroutine factory that produces the
# materialized result given the resolved lhs Object, the LazyOperator instance
# (for ``params``), and forwarded ``name``/``scope``.
_SIMPLE_AGGREGATIONS = frozenset({"min", "max", "sum", "mean", "std", "var", "count"})


class LazyOperator(Object):
    """A planned operator that materializes into an Object on ``await``.

    Created synchronously by ``Object`` operator methods (binary dunders
    ``__add__`` / ``__eq__`` / etc., aggregations ``.sum()`` / ``.nunique()``,
    unary transforms ``.year()`` / ``.lower()``, …). No ClickHouse round-trip
    happens until the LazyOperator is awaited. The result table name and
    lifetime can be controlled via ``.as_(name, scope=...)``.

    See ``docs/user_guide/object.md`` — "Lazy Operator Results".

    Fields:
        lhs: Left operand — Object, LazyOperator, or Python scalar.
        rhs: Right operand — Object, LazyOperator, Python scalar, or
            ``None`` (unary / aggregation operators have no RHS).
        operator: Operator symbol or name:
            - Binary: ``"+", "-", "*", "/", "//", "%", "**", "==", "!=",
              "<", "<=", ">", ">=", "&", "|", "^"``.
            - Unary transforms (rhs=None): keys of
              ``schema_compute.UNARY_TRANSFORMS`` (``"year"``, ``"lower"``, …).
            - Aggregations (rhs=None): ``"min" / "max" / "sum" / "mean" /
              "std" / "var" / "count"``, plus ``"count_if"``,
              ``"quantile"``, ``"unique"``, ``"nunique"``.
        params: Operator-specific extra arguments — e.g. ``{"q": 0.5}`` for
            ``quantile``, ``{"condition": ...}`` for ``count_if``.
    """

    def __init__(
        self,
        lhs: Object | ValueScalarType,
        rhs: Object | ValueScalarType | None,
        operator: str,
        schema_preview: Schema,
        params: dict | None = None,
    ):
        # NB: do NOT call Object.__init__ — we have no table yet. Set up the
        # Object-subclass invariants manually so inherited methods that touch
        # _stale / _registered / _owns_lifecycle_ref behave correctly.
        self.lhs = lhs
        self.rhs = rhs
        self.operator = operator
        self.params = params
        self._schema = schema_preview
        self._name: str | None = None
        self._scope: NamedScope | None = None
        self._materialized: Object | None = None
        self._stale = False
        self._registered = False
        self._owns_lifecycle_ref = False
        self._stats: QueryStats | None = None

    def as_(self, name: str, scope: NamedScope = "temp_named") -> LazyOperator:
        """Return a new LazyOperator that materializes with the given name and scope.

        Args:
            name: Result table name (forwarded to ``create_object``).
            scope: ``"temp_named"`` (default), ``"job"``, or ``"global"`` — see
                ``create_object`` for the lifetime semantics of each.

        Returns:
            New LazyOperator instance. The receiver is unchanged.
        """
        new = LazyOperator(
            lhs=self.lhs,
            rhs=self.rhs,
            operator=self.operator,
            schema_preview=self._schema,
            params=self.params,
        )
        new._name = name
        new._scope = scope
        return new

    @property
    def table(self) -> str:
        """Table name of the materialized result.

        Raises:
            RuntimeError: When the LazyOperator has not been awaited yet.
        """
        if self._materialized is None:
            raise RuntimeError(
                "LazyOperator has no table yet — `await` it before reading .table. "
                "Async methods like .data() auto-materialize; only sync table-access "
                "requires an explicit await."
            )
        return self._materialized.table

    def _peek_op_metadata(self) -> tuple[str, str, bool, ColumnInfo | None]:
        """Read the precomputed result schema — no _get_query_info() call,
        since LazyOperator has no table pre-materialize.
        """
        s = self._schema
        col = s.columns.get("value")
        return (
            s.fieldtype,
            col.type if col is not None else "Float64",
            col.nullable if col is not None else False,
            s.columns.get(AAI_ID_COLUMN),
        )

    async def _materialize(self) -> Object:
        """Walk lhs/rhs and produce the materialized Object. Cached on _materialized."""
        if self._materialized is not None:
            return self._materialized

        # Resolve both operands concurrently — when both are LazyOperators with
        # independent upstream chains, they can materialize in parallel. ``lhs``
        # is always non-None on a LazyOperator (the planners guarantee it);
        # ``rhs`` is None for unary / aggregation operators.
        lhs_obj, rhs_obj = await asyncio.gather(
            _resolve_operand(self.lhs),
            _resolve_operand(self.rhs),
        )
        assert lhs_obj is not None, "LazyOperator.lhs must always resolve to an Object"
        lhs_obj.checkstale()

        ch_client = get_ch_client()
        if rhs_obj is None:
            result = await self._materialize_unary(lhs_obj, ch_client)
        else:
            rhs_obj.checkstale()
            _require_explicit_order_for_cross_table(lhs_obj, rhs_obj)
            result = await operators._apply_operator_db(
                lhs_obj._get_query_info(),
                rhs_obj._get_query_info(),
                self.operator,
                ch_client,
                name=self._name,
                scope=self._scope,
            )
        self._materialized = result
        return result

    async def _materialize_unary(self, lhs_obj: Object, ch_client) -> Object:
        """Dispatch a unary / aggregation operator (rhs=None) to its operator fn."""
        info = lhs_obj._get_query_info()
        op = self.operator
        if op in UNARY_TRANSFORMS:
            return await operators.unary_transform(info, op, ch_client, name=self._name, scope=self._scope)
        if op in _SIMPLE_AGGREGATIONS:
            return await operators._apply_aggregation(info, op, ch_client, name=self._name, scope=self._scope)
        if op == "count_if":
            assert self.params is not None
            return await operators.count_if_agg(
                info, self.params["condition"], ch_client, name=self._name, scope=self._scope
            )
        if op == "quantile":
            assert self.params is not None
            return await operators.quantile_agg(info, self.params["q"], ch_client, name=self._name, scope=self._scope)
        if op == "unique":
            return await operators.unique_group(info, ch_client, name=self._name, scope=self._scope)
        if op == "nunique":
            return await operators.nunique_agg(info, ch_client, name=self._name, scope=self._scope)
        raise RuntimeError(f"LazyOperator: unknown unary operator {op!r}")

    def __await__(self):
        return self._materialize().__await__()

    # Async-method overrides: each first materializes (cached), then delegates.
    # Sync properties (.table, .scope) still raise pre-materialize — only async
    # methods auto-materialize, because only they can `await`.

    async def data(self, *args, **kwargs):
        return await (await self._materialize()).data(*args, **kwargs)

    async def result(self, *args, **kwargs):
        return await (await self._materialize()).result(*args, **kwargs)

    async def markdown(self, *args, **kwargs):
        return await (await self._materialize()).markdown(*args, **kwargs)

    async def export(self, *args, **kwargs):
        return await (await self._materialize()).export(*args, **kwargs)

    async def execute(self, *args, **kwargs):
        return await (await self._materialize()).execute(*args, **kwargs)

    async def stats(self) -> QueryStats | None:
        return await (await self._materialize()).stats()

    # Copy / concat / join / insert — return new Objects or mutate, materialize first.
    # (Phase 2 covers aggregations + unary transforms; the table-shape ops below
    # are scheduled for a follow-up phase; until then, materialize-and-delegate.)

    async def copy(self, *, name=None, scope=None):
        return await (await self._materialize()).copy(name=name, scope=scope)

    async def concat(self, *args, name=None, scope=None):
        return await (await self._materialize()).concat(*args, name=name, scope=scope)

    async def join(
        self,
        other: Object,
        *,
        on: str | list[str] | None = None,
        left_on: str | list[str] | None = None,
        right_on: str | list[str] | None = None,
        how: join_module.JoinHow = "inner",
        suffixes: join_module.SuffixesArg = None,
        name: str | None = None,
        scope: NamedScope | None = None,
    ):
        return await (await self._materialize()).join(
            other,
            on=on,
            left_on=left_on,
            right_on=right_on,
            how=how,
            suffixes=suffixes,
            name=name,
            scope=scope,
        )

    async def insert(self, *args):
        return await (await self._materialize()).insert(*args)

    def __repr__(self) -> str:
        if self._materialized is not None:
            return f"LazyOperator(materialized={self._materialized.table!r})"
        return f"LazyOperator(op={self.operator!r}, materialized=False)"
