///|
/// Evaluate each expression against `self` and add the results as
/// columns. Derived columns are *declared* (`col("a") + col("b")`) rather
/// than pre-materialised (a `lit_series(s)` carries a ready-made `Series`).
/// Every `Expr` the evaluator supports is accepted here — aggregations
/// included, evaluating to length-1 and broadcasting back over the frame —
/// under the dtype / null / NaN rules documented in `expr_eval.mbt`, which is
/// the one place the node set is enumerated.
///
/// Naming and placement:
/// - each result takes its expression's output name — an alias if the tree
/// carries one, else the leftmost column reference, else `"literal"` for a
/// pure-literal tree (`expr_output_name`). The alias clause is what decides
/// replace-vs-append below: `col("a").with_alias("b")` appends `b`, it does
/// not replace `a`;
/// - an output name already present in `self` **replaces** that column in
/// place (original position kept);
/// - a new output name **appends** rightmost, in expression order;
/// - two expressions producing the same output name raise
/// `DuplicateColumn` at the second — nothing silently wins.
///
/// Every result rides the length contract in `expr_eval.mbt`: a frame-tall one
/// becomes the column, a length-1 one (a literal) broadcasts to `nrows` — to
/// zero rows on an empty frame — and any other length raises `LengthMismatch`,
/// which only a `lit_series` or a `map_batches` closure can produce. Errors
/// (unknown columns, dtype mismatches, the unrepresentable `Null` literal, an
/// off-frame length) surface here at evaluation time;
/// building the expressions was total. `with_columns([])` adds nothing and
/// returns the frame itself — a literal identity, declared schema included.
/// A call that adds or replaces a column leaves the schema of every *other*
/// column alone: each keeps the field it arrived with, declared `nullable`
/// included. The column an expression writes takes a carried field when the
/// expression only renames one (a bare `col("x")`, or an aliased one — so a
/// replacement inherits from whichever column now supplies the cells) and a
/// freshly derived one, `nullable = true`, when it computes.
pub fn DataFrame::with_columns(
self : DataFrame,
exprs : Array[@expr.Expr],
) -> DataFrame raise @types.DataError {
// Nothing to evaluate and nothing to add: return `self` rather than
// rebuilding an equal frame.
if exprs.is_empty() {
return self
}
let n = self.nrows()
let scope = Array::makei(n, i => i)
// `column_series` returns a fresh, owned array, so in-place replacement
// can't perturb `self`.
let out = self.column_series()
// The field vector travels beside the column vector: an untouched column
// keeps the field it arrived with, and a replaced or added one takes the
// field its expression carries. `fields` is mutated in step with `out`, so
// the source lookup reads its own copy.
let fields = self.schema.fields()
let source_fields = self.schema.fields()
let seen : Map[String, Unit] = Map([])
for expr in exprs {
let column = @kernel.broadcast_series(eval_expr(expr, self, scope), n)
let name = expr_output_name(expr)
if seen.contains(name) {
raise @types.DataError::DuplicateColumn(name)
}
seen[name] = ()
let field = self.carried_field(source_fields, expr, column, name)
let renamed = column.rename(name)
match self.name_to_index.get(name) {
Some(i) => {
out[i] = renamed
fields[i] = field
}
None => {
out.push(renamed)
fields.push(field)
}
}
}
DataFrame::from_parts_with_fields(out, fields, n)
}