Repository: narwhals-dev/narwhals
Pull request #2483 (2025-05-04 15:00:03)

chore: Refactor expression parsing again (yes, again)
closes https://github.com/narwhals-dev/narwhals/issues/2478

<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below


dangotbanned: Thanks for the ping @MarcoGorelli (https://github.com/narwhals-dev/narwhals/pull/2483#event-17573776486), will try to get to this today 🙏 
dangotbanned: > Thanks for the ping @MarcoGorelli ([#2483 (comment)](https://github.com/narwhals-dev/narwhals/pull/2483#event-17573776486)), will try to get to this today 🙏

Okay so I didn't make it 😞 

But I do have *one* nit to tide you over till tomorrow 😉 

### Current
```py
last_node_is_orderable_window: bool = False,
last_node_is_unorderable_window: bool = False,
```


### Suggested
```py
is_last_node_orderable_window: bool = False,
is_last_node_unorderable_window: bool = False,
```
MarcoGorelli: sure - tbh i might do another pass, it might be cleaner to have a `last_node` attribute, and then to do `if expr.last_node.is_orderable_window()` instead of `if expr._last_node_is_orderable_window`
dangotbanned: > sure - tbh i might do another pass, it might be cleaner to have a `last_node` attribute, and then to do `if expr.last_node.is_orderable_window()` instead of `if expr._last_node_is_orderable_window`

I like it!
dangotbanned: @MarcoGorelli I'm finding this a bit difficult to review, as there's a goblin in my head trying to ask what seems like a derailing question 😭 

**What do you think about the ways other packages represent operations/expressions?**

Using [`nw.Expr.median`](https://narwhals-dev.github.io/narwhals/api-reference/expr/#narwhals.Expr.median) as an *arbitrary* example - this (I think 😅) is how `polars`, `ibis`, `sqlglot`, `duckdb` store it in their IR:

<details><summary>How do others?</summary>
<p>

- https://github.com/pola-rs/polars/blob/da27decd9a1adabe0498b786585287eb730d1d91/crates/polars-plan/src/dsl/expr.rs
  - https://github.com/pola-rs/polars/blob/da27decd9a1adabe0498b786585287eb730d1d91/crates/polars-plan/src/dsl/expr.rs#L26
- https://github.com/ibis-project/ibis/tree/dcc79a8e2b6f6b0c20a17066aff8bf299e729bb6/ibis/expr/operations
  - https://github.com/ibis-project/ibis/blob/dcc79a8e2b6f6b0c20a17066aff8bf299e729bb6/ibis/expr/operations/reductions.py#L209
- https://github.com/tobymao/sqlglot/blob/411e1304905686388d0e7c466937076f100efe2d/sqlglot/expressions.py
  - https://github.com/tobymao/sqlglot/blob/411e1304905686388d0e7c466937076f100efe2d/sqlglot/expressions.py#L6504
- https://github.com/duckdb/duckdb/blob/30e907b52fd647f811e2b320c29a88190d3aa85b/extension/core_functions/aggregate/holistic/quantile.cpp
  - https://github.com/duckdb/duckdb/blob/30e907b52fd647f811e2b320c29a88190d3aa85b/extension/core_functions/aggregate/holistic/quantile.cpp#L678

</p>
</details> 

The `duckdb` one is a bit a of a stretch for *my* understanding - but I feel like there are some lessons we could learn from how others model solve *similar* problems 🙂

For `sqlglot` and `ibis`, I get the feeling that the way they approach this means that each operation carries *less* metadata than we would with `ExprMetadata`.
The compositional rules would be able to work on classes and how they interact - rather than how a combination of flags set in every operation works.

I don't know if any of that made sense 😅 

I think you've done a really good job with all the [information hiding](https://en.wikipedia.org/wiki/Information_hiding) *outside* of `ExprMetadata` 👏 👏 👏  
MarcoGorelli: thanks for asking - indeed, as you've found, many of these flags come from Polars

it's been on my mind whether we need a separate class for each operation. I don't think it's necessary for what I'm trying to do here, but we may want to get to it in the future, especially if we want to allow for expression serialisation / deserialisation

Patch:
diff --git a/docs/api-completeness/index.md b/docs/api-completeness/index.md
index 33181792..5d1c35ab 100644
--- a/docs/api-completeness/index.md
+++ b/docs/api-completeness/index.md
@@ -1,3 +1,8 @@
+---
+search:
+  exclude: true
+---
+
 # API Completeness
 
 In the following section it is possible to check which method is implemented for which
diff --git a/docs/extending.md b/docs/extending.md
index c0076c58..2e08ccfc 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -1,104 +1,8 @@
-# Supported libraries and extending Narwhals
+# Extending Narwhals
 
-## List of supported libraries (and how to add yours!)
+!!! warning
 
-Currently, Narwhals has **full API** support for the following libraries:
-
-| Library  | 🔗 Link 🔗 |
-| ------------- | ------------- |
-| ️Polars 🐻‍❄️ | [github.com/pola-rs/polars](https://github.com/pola-rs/polars) |
-| pandas 🐼 |  [github.com/pandas-dev/pandas](https://github.com/pandas-dev/pandas) |
-| cuDF | [github.com/rapidsai/cudf](https://github.com/rapidsai/cudf) |
-| Modin | [github.com/modin-project/modin](https://github.com/modin-project/modin) |
-| PyArrow ⇶ | [arrow.apache.org/docs/python](https://arrow.apache.org/docs/python/index.html) |
-
-It also has **lazy-only** support for [Dask](https://github.com/dask/dask), and **interchange** support
-for [DuckDB](https://github.com/duckdb/duckdb) and [Ibis](https://github.com/ibis-project/ibis).
-
-We are working towards full "lazy-only" support for DuckDB, Ibis, and PySpark.
-
-### Levels of support
-
-Narwhals comes with three levels of support:
-
-- **Full API support**: cuDF, Modin, pandas, Polars, PyArrow
-- **Lazy-only support**: Dask. Work in progress: DuckDB, Ibis, PySpark.
-- **Interchange-level support**: DuckDB, Ibis, Vaex, anything which implements the DataFrame Interchange Protocol
-
-Libraries for which we have full support can benefit from the whole
-[Narwhals API](./api-reference/index.md).
-
-For example:
-
-=== "from/to_native"
-    ```python exec="1" source="above"
-    import narwhals as nw
-    from narwhals.typing import IntoFrameT
-
-
-    def func(df: IntoFrameT) -> IntoFrameT:
-        return (
-            nw.from_native(df)
-            .group_by("a")
-            .agg(
-                b_mean=nw.col("b").mean(),
-                b_std=nw.col("b").std(),
-            )
-            .to_native()
-        )
-    ```
-
-=== "@narwhalify"
-    ```python exec="1" source="above"
-    import narwhals as nw
-    from narwhals.typing import FrameT
-
-
-    @nw.narwhalify
-    def func(df: FrameT) -> FrameT:
-        return df.group_by("a").agg(
-            b_mean=nw.col("b").mean(),
-            b_std=nw.col("b").std(),
-        )
-    ```
-
-will work for any of pandas, Polars, cuDF, Modin, and PyArrow.
-
-However, sometimes you don't need to do complex operations on dataframes - all you need
-is to inspect the schema a bit before making other decisions, such as which columns to
-select or whether to convert to another library. For that purpose, we also provide "interchange"
-level of support. If a library implements the
-[Dataframe Interchange Protocol](https://data-apis.org/dataframe-protocol/latest/), then
-a call such as
-
-```python exec="1" source="above"
-from typing import Any
-
-import narwhals as nw
-from narwhals.schema import Schema
-
-
-def func(df: Any) -> Schema:
-    df = nw.from_native(df, eager_or_interchange_only=True)
-    return df.schema
-```
-
-is also supported, meaning that, in addition to the libraries mentioned above, you can
-also pass Ibis, DuckDB, Vaex, and any library which implements the protocol.
-
-#### Interchange-only support
-
-While libraries for which we have full support can benefit from the whole Narwhals API,
-libraries which have interchange only support can access the following methods after 
-converting to Narwhals DataFrame:
-
-- `.schema`, hence column names via `.schema.names()` and column types via `.schema.dtypes()`
-- `.columns`
-- `.to_pandas()` and `.to_arrow()`, for converting to Pandas and Arrow, respectively.
-- `.select(names)` (Ibis and DuckDB), where `names` is a list of (string) column names. This is useful for
-  selecting columns before converting to another library.
-
-## Extending Narwhals
+  The extension mechanism in Narwhals is experimental and under development.
 
 If you want your own library to be recognised too, you're welcome open a PR (with tests)!.
 Alternatively, if you can't do that (for example, if you library is closed-source), see
diff --git a/docs/how_it_works.md b/docs/how_it_works.md
index 2030a2db..fa21a88f 100644
--- a/docs/how_it_works.md
+++ b/docs/how_it_works.md
@@ -287,45 +287,41 @@ print(nw.col("a").mean().over("b"))
 Note how they tell us something about their metadata. This section is all about
 making sense of what that all means, what the rules are, and what it enables.
 
-### Expression kinds
-
-Each Narwhals expression can be of one of the following kinds:
-
-- `LITERAL`: expressions which correspond to literal values, such as the `3` in `nw.col('a')+3`.
-- `AGGREGATION`: expressions which reduce a column to a single value (e.g. `nw.col('a').mean()`).
-- `TRANSFORM`: expressions which don't change length (e.g. `nw.col('a').abs()`).
-- `WINDOW`: like `TRANSFORM`, but the last operation is a (row-order-dependent) 
-   window function (`rolling_*`, `cum_*`, `diff`, `shift`, `is_*_distinct`).
-- `FILTRATION`: expressions which change length but don't
-   aggregate (e.g. `nw.col('a').drop_nulls()`).
-
-For example:
-
-  - `nw.col('a')` is not order-dependent, so it's `TRANSFORM`.
-  - `nw.col('a').abs()` is not order-dependent, so it's a `TRANSFORM`.
-  - `nw.col('a').cum_sum()`'s last operation is `cum_sum`, so it's `WINDOW`.
-  - `nw.col('a').cum_sum() + 1`'s last operation is `__add__`, and it preserves
-     the input dataframe's length, so it's a `TRANSFORM`.
-
-How these change depends on the operation.
+Here's a brief description of each piece of metadata:
+
+- `expansion_kind`: How and whether the expression expands to multiple outputs.
+  This can be one of:
+
+    - `ExpansionKind.SINGLE`: Only produces a single output. For example, `nw.col('a')`.
+    - `ExpansionKind.MULTI_NAMED`: Produces multiple outputs whose names can be
+      determined statically, for example `nw.col('a', 'b')`.
+    - `ExpansionKind.MULTI_UNNAMED`: Produces multiple outputs whose names depend
+      on the input dataframe. For example, `nw.nth(0, 1)` or `nw.selectors.numeric()`.
+
+- `last_node`: Kind of the last operation in the expression. See
+  `narwhals._expression_parsing.ExprKind` for the various options.
+- `has_windows`: Whether the expression already contains an `over(...)` statement.
+- `n_orderable_ops`: How many order-dependent operations the expression contains.
+  
+    Examples:
+
+    - `nw.col('a')` contains 0 orderable operations.
+    - `nw.col('a').diff()` contains 1 orderable operation.
+    - `nw.col('a').diff().shift()` contains 2 orderable operation.
+
+- `is_elementwise`: Whether it preserves length and operates on each row independently
+  of the rows around it (e.g. `abs`, `is_null`, `round`, ...).
+- `preserves_length`: Whether the output of the expression is the same length as
+  the dataframe it gets evaluated on.
+- `is_scalar_like`: Whether the output of the expression is always length-1.
+- `is_literal`: Whether the expression doesn't depend on any column but instead
+  only on literal values, like `nw.lit(1)`.
 
 #### Chaining
 
 Say we have `expr.expr_method()`. How does `expr`'s `ExprMetadata` change?
-This depends on `expr_method`.
-
-- Element-wise expressions such `abs`, `alias`, `cast`, `__invert__`, and
-  many more, preserve the input kind (unless `expr` is a `WINDOW`, in
-  which case it becomes a `TRANSFORM`. This is because for an expression
-  to be `WINDOW`, the last expression needs to be the order-dependent one).
-- `rolling_*`, `cum_*`, `diff`, `shift`, `ewm_mean`, and `is_*_distinct`
-  are window functions and result in `WINDOW`.
-- `mean`, `std`, `median`, and other aggregations result in `AGGREGATION`,
-  and can only be applied to `TRANSFORM` and `WINDOW`.
-- `drop_nulls` and `filter` result in `FILTRATION`, and can only be applied
-  to `TRANSFORM` and `WINDOW`.
-- `over` always results in `TRANSFORM`. This is a bit more complicated,
-  so we elaborate on it in the ["You open a window ..."](#you-open-a-window-to-another-window-to-another-window-to-another-window).
+This depends on `expr_method`. Details can be found in `narwhals/_expression_parsing`,
+in the `ExprMetadata.with_*` methods.
 
 #### Binary operations (e.g. `nw.col('a') + nw.col('b')`)
 
@@ -333,16 +329,15 @@ How do expression kinds change under binary operations? For example,
 if we do `expr1 + expr2`, then what can we say about the output kind?
 The rules are:
 
-- If both are `LITERAL`, then the output is `LITERAL`.
-- If one is a `FILTRATION`, then:
+- If one changes the input length (e.g. `Expr.drop_nulls`), then:
 
-    - if the other is `LITERAL` or `AGGREGATION`, then the output is `FILTRATION`.
+    - if the other is scalar-like, then the output also changes length.
     - else, we raise an error.
 
-- If one is `TRANSFORM` or `WINDOW` and the other is not `FILTRATION`,
-  then the output is `TRANSFORM`.
-- If one is `AGGREGATION` and the other is `LITERAL` or `AGGREGATION`,
-  the output is `AGGREGATION`.
+- If one preserves length and the other is scalar-like, then the output
+  preserves length (because of broadcasting).
+- If one is scalar-like but not literal and the other is scalar-like,
+  the output is scalar-like but not literal.
 
 For n-ary operations such as `nw.sum_horizontal`, the above logic is
 extended across inputs. For example, `nw.sum_horizontal(expr1, expr2, expr3)`
@@ -350,24 +345,21 @@ is `LITERAL` if all of `expr1`, `expr2`, and `expr3` are.
 
 ### "You open a window to another window to another window to another window"
 
-When we print out an expression, in addition to the expression kind,
-we also see `window_kind`. There are four window kinds:
-
-- `NONE`: non-order-dependent operations, like `.abs()` or `.mean()`.
-- `CLOSEABLE`: expression where the last operation is order-dependent. For
-  example, `nw.col('a').diff()`.
-- `UNCLOSEABLE`: expression where some operation is order-dependent but
-  the order-dependent operation wasn't the last one. For example,
-  `nw.col('a').diff().abs()`.
-- `CLOSED`: expression contains `over` at some point, and any order-dependent
-  operation was immediately followed by `over(order_by=...)`.
-
 When working with `DataFrame`s, row order is well-defined, as the dataframes
-are assumed to be eager and in-memory. Therefore, it's allowed to work
-with all window kinds.
+are assumed to be eager and in-memory. Therefore, `n_orderable_ops` is
+disregarded.
 
 When working with `LazyFrame`s, on the other hand, row order is undefined.
-Therefore, window kinds must either be `NONE` or `CLOSED`.
+Therefore, when evaluating an expression, `n_orderable_ops` must be exactly
+zero - if it's not, it means that the expression depends on physical row order,
+which is not allowed for `LazyFrame`s. The way that `n_orderable_ops` can change
+is:
+
+- Orderable window functions like `diff` and `rolling_mean` increase `n_orderable_ops`
+  by 1.
+- If an orderable window function is immediately followed by `over(order_by=...)`,
+  then `n_orderable_ops` is decreased by 1. This is the only way that
+  `n_orderable_ops` can decrease.
 
 ### Broadcasting
 
@@ -383,7 +375,7 @@ length-1 Series, and pandas does its own broadcasting of scalars.
 
 Narwhals triggers a broadcast in these situations:
 
-- In `select` when some values are `ExprKind.TRANSFORM` and others aren't, e.g.
+- In `select` when some values preserve length and others don't, e.g.
   `df.select('a', nw.col('b').mean())`.
 - In `with_columns`, all new columns get broadcasted to the length of the dataframe.
 - In n-ary operations between expressions, such as `nw.col('a') + nw.col('a').mean()`.
diff --git a/mkdocs.yml b/mkdocs.yml
index 93043750..156abb92 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -107,7 +107,7 @@ plugins:
 - search: 
     # See https://squidfunk.github.io/mkdocs-material/plugins/search/#configuration
     enabled: true
-    separator: '[\s\-,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;'
+    separator: '\.'
 - mkdocstrings:
     handlers:
       python:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index f2e5b5c0..2f0134ea 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -9,7 +9,6 @@ import pyarrow.compute as pc
 from narwhals._arrow.series import ArrowSeries
 from narwhals._compliant import EagerExpr
 from narwhals._expression_parsing import evaluate_output_names_and_aliases
-from narwhals._expression_parsing import is_scalar_like
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.utils import Implementation
 from narwhals.utils import generate_temporary_column_name
@@ -143,7 +142,7 @@ class ArrowExpr(EagerExpr["ArrowDataFrame", ArrowSeries]):
 
     def over(self, partition_by: Sequence[str], order_by: Sequence[str] | None) -> Self:
         assert self._metadata is not None  # noqa: S101
-        if partition_by and not is_scalar_like(self._metadata.kind):
+        if partition_by and not self._metadata.is_scalar_like:
             msg = "Only aggregation or literal operations are supported in grouped `over` context for PyArrow."
             raise NotImplementedError(msg)
 
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index f1a30e44..2f66acf6 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -15,6 +15,7 @@ from typing import cast
 
 from narwhals.dependencies import is_narwhals_series
 from narwhals.dependencies import is_numpy_array
+from narwhals.exceptions import InvalidOperationError
 from narwhals.exceptions import LengthChangingExprError
 from narwhals.exceptions import MultiOutputExpressionError
 from narwhals.exceptions import ShapeError
@@ -33,6 +34,7 @@ if TYPE_CHECKING:
     from narwhals._compliant.typing import EagerNamespaceAny
     from narwhals._compliant.typing import EvalNames
     from narwhals.expr import Expr
+    from narwhals.series import Series
     from narwhals.typing import IntoExpr
     from narwhals.typing import NonNestedLiteral
     from narwhals.typing import _1DArray
@@ -47,6 +49,13 @@ def is_expr(obj: Any) -> TypeIs[Expr]:
     return isinstance(obj, Expr)
 
 
+def is_series(obj: Any) -> TypeIs[Series[Any]]:
+    """Check whether `obj` is a Narwhals Expr."""
+    from narwhals.series import Series
+
+    return isinstance(obj, Series)
+
+
 def combine_evaluate_output_names(
     *exprs: CompliantExpr[CompliantFrameT, Any],
 ) -> EvalNames[CompliantFrameT]:
@@ -115,72 +124,79 @@ def evaluate_output_names_and_aliases(
 
 
 class ExprKind(Enum):
-    """Describe which kind of expression we are dealing with.
-
-    Commutative composition rules are:
-    - LITERAL vs LITERAL -> LITERAL
-    - FILTRATION vs (LITERAL | AGGREGATION) -> FILTRATION
-    - FILTRATION vs (FILTRATION | TRANSFORM | WINDOW) -> raise
-    - (TRANSFORM | WINDOW) vs (...) -> TRANSFORM
-    - AGGREGATION vs (LITERAL | AGGREGATION) -> AGGREGATION
-    """
+    """Describe which kind of expression we are dealing with."""
 
     LITERAL = auto()
     """e.g. `nw.lit(1)`"""
 
     AGGREGATION = auto()
-    """e.g. `nw.col('a').mean()`"""
+    """Reduces to a single value, not affected by row order, e.g. `nw.col('a').mean()`"""
 
-    TRANSFORM = auto()
-    """preserves length, e.g. `nw.col('a').round()`"""
+    ORDERABLE_AGGREGATION = auto()
+    """Reduces to a single value, affected by row order, e.g. `nw.col('a').arg_max()`"""
 
-    WINDOW = auto()
-    """transform in which last node is order-dependent
+    ELEMENTWISE = auto()
+    """Preserves length, can operate without context for surrounding rows, e.g. `nw.col('a').abs()`."""
 
-    examples:
-    - `nw.col('a').cum_sum()`
-    - `(nw.col('a')+1).cum_sum()`
+    ORDERABLE_WINDOW = auto()
+    """Depends on the rows around it and on their order, e.g. `diff`."""
 
-    non-examples:
-    - `nw.col('a').cum_sum()+1`
-    - `nw.col('a').cum_sum().mean()`
-    """
+    UNORDERABLE_WINDOW = auto()
+    """Depends on the rows around it but not on their order, e.g. `rank`."""
 
     FILTRATION = auto()
-    """e.g. `nw.col('a').drop_nulls()`"""
+    """Changes length, not affected by row order, e.g. `drop_nulls`."""
 
-    def preserves_length(self) -> bool:
-        return self in {ExprKind.TRANSFORM, ExprKind.WINDOW}
+    ORDERABLE_FILTRATION = auto()
+    """Changes length, affected by row order, e.g. `tail`."""
 
-    def is_window(self) -> bool:
-        return self is ExprKind.WINDOW
+    NARY = auto()
+    """Results from the combination of multiple expressions."""
 
-    def is_filtration(self) -> bool:
-        return self is ExprKind.FILTRATION
+    OVER = auto()
+    """Results from calling `.over` on expression."""
+
+    UNKNOWN = auto()
+    """Based on the information we have, we can't determine the ExprKind."""
 
+    @property
     def is_scalar_like(self) -> bool:
-        return is_scalar_like(self)
+        return self in {ExprKind.LITERAL, ExprKind.AGGREGATION}
+
+    @property
+    def is_orderable_window(self) -> bool:
+        return self in {ExprKind.ORDERABLE_WINDOW, ExprKind.ORDERABLE_AGGREGATION}
+
+    @classmethod
+    def from_expr(cls, obj: Expr) -> ExprKind:
+        meta = obj._metadata
+        if meta.is_literal:
+            return ExprKind.LITERAL
+        if meta.is_scalar_like:
+            return ExprKind.AGGREGATION
+        if meta.is_elementwise:
+            return ExprKind.ELEMENTWISE
+        return ExprKind.UNKNOWN
 
     @classmethod
     def from_into_expr(
         cls, obj: IntoExpr | NonNestedLiteral | _1DArray, *, str_as_lit: bool
     ) -> ExprKind:
         if is_expr(obj):
-            return obj._metadata.kind
+            return cls.from_expr(obj)
         if (
             is_narwhals_series(obj)
             or is_numpy_array(obj)
             or (isinstance(obj, str) and not str_as_lit)
         ):
-            return ExprKind.TRANSFORM
+            return ExprKind.ELEMENTWISE
         return ExprKind.LITERAL
 
 
 def is_scalar_like(
-    kind: ExprKind,
-) -> TypeIs[Literal[ExprKind.AGGREGATION, ExprKind.LITERAL]]:
-    # Like ExprKind.is_scalar_like, but uses TypeIs for better type checking.
-    return kind in {ExprKind.AGGREGATION, ExprKind.LITERAL}
+    obj: ExprKind,
+) -> TypeIs[Literal[ExprKind.LITERAL, ExprKind.AGGREGATION]]:
+    return obj.is_scalar_like
 
 
 class ExpansionKind(Enum):
@@ -210,140 +226,250 @@ class ExpansionKind(Enum):
         raise AssertionError(msg)  # pragma: no cover
 
 
-class WindowKind(Enum):
-    """Describe what kind of window the expression contains."""
-
-    NONE = auto()
-    """e.g. `nw.col('a').abs()`, no windows."""
-
-    CLOSEABLE = auto()
-    """e.g. `nw.col('a').cum_sum()` - can be closed if immediately followed by `over(order_by=...)`."""
-
-    UNCLOSEABLE = auto()
-    """e.g. `nw.col('a').cum_sum().abs()` - the window function (`cum_sum`) wasn't immediately followed by
-    `over(order_by=...)`, and so the window is uncloseable.
-
-    Uncloseable windows can be used freely in `nw.DataFrame`, but not in `nw.LazyFrame` where
-    row-order is undefined."""
-
-    CLOSED = auto()
-    """e.g. `nw.col('a').cum_sum().over(order_by='i')`."""
-
-    def is_open(self) -> bool:
-        return self in {WindowKind.UNCLOSEABLE, WindowKind.CLOSEABLE}
-
-    def is_closed(self) -> bool:
-        return self is WindowKind.CLOSED
-
-    def is_uncloseable(self) -> bool:
-        return self is WindowKind.UNCLOSEABLE
-
-
 class ExprMetadata:
-    __slots__ = ("_expansion_kind", "_kind", "_window_kind")
+    __slots__ = (
+        "expansion_kind",
+        "has_windows",
+        "is_elementwise",
+        "is_literal",
+        "is_scalar_like",
+        "last_node",
+        "n_orderable_ops",
+        "preserves_length",
+    )
 
     def __init__(
         self,
-        kind: ExprKind,
-        /,
-        *,
-        window_kind: WindowKind,
         expansion_kind: ExpansionKind,
+        last_node: ExprKind,
+        *,
+        has_windows: bool = False,
+        n_orderable_ops: int = 0,
+        preserves_length: bool = True,
+        is_elementwise: bool = True,
+        is_scalar_like: bool = False,
+        is_literal: bool = False,
     ) -> None:
-        self._kind: ExprKind = kind
-        self._window_kind = window_kind
-        self._expansion_kind = expansion_kind
+        if is_literal:
+            assert is_scalar_like  # noqa: S101  # debug assertion
+        if is_elementwise:
+            assert preserves_length  # noqa: S101  # debug assertion
+        self.expansion_kind = expansion_kind
+        self.last_node = last_node
+        self.has_windows = has_windows
+        self.n_orderable_ops = n_orderable_ops
+        self.is_elementwise = is_elementwise
+        self.preserves_length = preserves_length
+        self.is_scalar_like = is_scalar_like
+        self.is_literal = is_literal
 
     def __init_subclass__(cls, /, *args: Any, **kwds: Any) -> Never:  # pragma: no cover
         msg = f"Cannot subclass {cls.__name__!r}"
         raise TypeError(msg)
 
-    def __repr__(self) -> str:
-        return f"ExprMetadata(kind: {self._kind}, window_kind: {self._window_kind}, expansion_kind: {self._expansion_kind})"
+    def __repr__(self) -> str:  # pragma: no cover
+        return (
+            f"ExprMetadata(\n"
+            f"  expansion_kind: {self.expansion_kind},\n"
+            f"  last_node: {self.last_node},\n"
+            f"  has_windows: {self.has_windows},\n"
+            f"  n_orderable_ops: {self.n_orderable_ops},\n"
+            f"  is_elementwise: {self.is_elementwise},\n"
+            f"  preserves_length: {self.preserves_length},\n"
+            f"  is_scalar_like: {self.is_scalar_like},\n"
+            f"  is_literal: {self.is_literal},\n"
+            ")"
+        )
 
     @property
-    def kind(self) -> ExprKind:
-        return self._kind
+    def is_filtration(self) -> bool:
+        return not self.preserves_length and not self.is_scalar_like
 
-    @property
-    def window_kind(self) -> WindowKind:
-        return self._window_kind
+    def with_aggregation(self) -> ExprMetadata:
+        if self.is_scalar_like:
+            msg = "Can't apply aggregations to scalar-like expressions."
+            raise InvalidOperationError(msg)
+        return ExprMetadata(
+            self.expansion_kind,
+            ExprKind.AGGREGATION,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops,
+            preserves_length=False,
+            is_elementwise=False,
+            is_scalar_like=True,
+            is_literal=False,
+        )
 
-    @property
-    def expansion_kind(self) -> ExpansionKind:
-        return self._expansion_kind
+    def with_orderable_aggregation(self) -> ExprMetadata:
+        if self.is_scalar_like:
+            msg = "Can't apply aggregations to scalar-like expressions."
+            raise InvalidOperationError(msg)
+        return ExprMetadata(
+            self.expansion_kind,
+            ExprKind.ORDERABLE_AGGREGATION,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops + 1,
+            preserves_length=False,
+            is_elementwise=False,
+            is_scalar_like=True,
+            is_literal=False,
+        )
+
+    def with_elementwise_op(self) -> ExprMetadata:
+        return ExprMetadata(
+            self.expansion_kind,
+            ExprKind.ELEMENTWISE,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops,
+            preserves_length=self.preserves_length,
+            is_elementwise=self.is_elementwise,
+            is_scalar_like=self.is_scalar_like,
+            is_literal=self.is_literal,
+        )
+
+    def with_unorderable_window(self) -> ExprMetadata:
+        if self.is_scalar_like:
+            msg = "Can't apply unorderable window (`rank`, `is_unique`) to scalar-like expression."
+            raise InvalidOperationError(msg)
+        return ExprMetadata(
+            self.expansion_kind,
+            ExprKind.UNORDERABLE_WINDOW,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops,
+            preserves_length=self.preserves_length,
+            is_elementwise=False,
+            is_scalar_like=False,
+            is_literal=False,
+        )
+
+    def with_orderable_window(self) -> ExprMetadata:
+        if self.is_scalar_like:
+            msg = "Can't apply orderable window (e.g. `diff`, `shift`) to scalar-like expression."
+            raise InvalidOperationError(msg)
+        return ExprMetadata(
+            self.expansion_kind,
+            ExprKind.ORDERABLE_WINDOW,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops + 1,
+            preserves_length=self.preserves_length,
+            is_elementwise=False,
+            is_scalar_like=False,
+            is_literal=False,
+        )
 
-    def with_kind(self, kind: ExprKind, /) -> ExprMetadata:
-        """Change metadata kind, leaving all other attributes the same."""
+    def with_ordered_over(self) -> ExprMetadata:
+        if self.has_windows:
+            msg = "Cannot nest `over` statements."
+            raise InvalidOperationError(msg)
+        if self.is_elementwise or self.is_filtration:
+            msg = (
+                "Cannot use `over` on expressions which are elementwise\n"
+                "(e.g. `abs`) or which change length (e.g. `drop_nulls`)."
+            )
+            raise InvalidOperationError(msg)
+        n_orderable_ops = self.n_orderable_ops
+        if not n_orderable_ops:
+            msg = "Cannot use `order_by` in `over` on expression which isn't orderable."
+            raise InvalidOperationError(msg)
+        if self.last_node.is_orderable_window:
+            n_orderable_ops -= 1
         return ExprMetadata(
-            kind,
-            window_kind=self._window_kind,
-            expansion_kind=self._expansion_kind,
+            self.expansion_kind,
+            ExprKind.OVER,
+            has_windows=True,
+            n_orderable_ops=n_orderable_ops,
+            preserves_length=True,
+            is_elementwise=False,
+            is_scalar_like=False,
+            is_literal=False,
         )
 
-    def with_uncloseable_window(self) -> ExprMetadata:
-        """Add uncloseable window, leaving other attributes the same."""
-        if self._window_kind is WindowKind.CLOSED:  # pragma: no cover
-            msg = "Unreachable code, please report a bug."
-            raise AssertionError(msg)
+    def with_partitioned_over(self) -> ExprMetadata:
+        if self.has_windows:
+            msg = "Cannot nest `over` statements."
+            raise InvalidOperationError(msg)
+        if self.is_elementwise or self.is_filtration:
+            msg = (
+                "Cannot use `over` on expressions which are elementwise\n"
+                "(e.g. `abs`) or which change length (e.g. `drop_nulls`)."
+            )
+            raise InvalidOperationError(msg)
         return ExprMetadata(
-            self.kind,
-            window_kind=WindowKind.UNCLOSEABLE,
-            expansion_kind=self._expansion_kind,
+            self.expansion_kind,
+            ExprKind.OVER,
+            has_windows=True,
+            n_orderable_ops=self.n_orderable_ops,
+            preserves_length=True,
+            is_elementwise=False,
+            is_scalar_like=False,
+            is_literal=False,
         )
 
-    def with_kind_and_closeable_window(self, kind: ExprKind, /) -> ExprMetadata:
-        """Change metadata kind and add closeable window.
-
-        If we already have an uncloseable window, the window stays uncloseable.
-        """
-        if self._window_kind is WindowKind.NONE:
-            window_kind = WindowKind.CLOSEABLE
-        elif self._window_kind is WindowKind.CLOSED:  # pragma: no cover
-            msg = "Unreachable code, please report a bug."
-            raise AssertionError(msg)
-        else:
-            window_kind = WindowKind.UNCLOSEABLE
+    def with_filtration(self) -> ExprMetadata:
+        if self.is_scalar_like:
+            msg = "Can't apply filtration (e.g. `drop_nulls`) to scalar-like expression."
+            raise InvalidOperationError(msg)
         return ExprMetadata(
-            kind,
-            window_kind=window_kind,
-            expansion_kind=self._expansion_kind,
+            self.expansion_kind,
+            ExprKind.FILTRATION,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops,
+            preserves_length=False,
+            is_elementwise=False,
+            is_scalar_like=False,
+            is_literal=False,
         )
 
-    def with_kind_and_uncloseable_window(self, kind: ExprKind, /) -> ExprMetadata:
-        """Change metadata kind and set window kind to uncloseable."""
+    def with_orderable_filtration(self) -> ExprMetadata:
+        if self.is_scalar_like:
+            msg = "Can't apply filtration (e.g. `drop_nulls`) to scalar-like expression."
+            raise InvalidOperationError(msg)
         return ExprMetadata(
-            kind,
-            window_kind=WindowKind.UNCLOSEABLE,
-            expansion_kind=self._expansion_kind,
+            self.expansion_kind,
+            ExprKind.ORDERABLE_FILTRATION,
+            has_windows=self.has_windows,
+            n_orderable_ops=self.n_orderable_ops + 1,
+            preserves_length=False,
+            is_elementwise=False,
+            is_scalar_like=False,
+            is_literal=False,
         )
 
     @staticmethod
-    def selector_single() -> ExprMetadata:
-        # e.g. `nw.col('a')`, `nw.nth(0)`
+    def aggregation() -> ExprMetadata:
         return ExprMetadata(
-            ExprKind.TRANSFORM,
-            window_kind=WindowKind.NONE,
-            expansion_kind=ExpansionKind.SINGLE,
+            ExpansionKind.SINGLE,
+            ExprKind.AGGREGATION,
+            is_elementwise=False,
+            preserves_length=False,
+            is_scalar_like=True,
         )
 
     @staticmethod
-    def selector_multi_named() -> ExprMetadata:
-        # e.g. `nw.col('a', 'b')`
+    def literal() -> ExprMetadata:
         return ExprMetadata(
-            ExprKind.TRANSFORM,
-            window_kind=WindowKind.NONE,
-            expansion_kind=ExpansionKind.MULTI_NAMED,
+            ExpansionKind.SINGLE,
+            ExprKind.LITERAL,
+            is_elementwise=False,
+            preserves_length=False,
+            is_literal=True,
+            is_scalar_like=True,
         )
 
+    @staticmethod
+    def selector_single() -> ExprMetadata:
+        # e.g. `nw.col('a')`, `nw.nth(0)`
+        return ExprMetadata(ExpansionKind.SINGLE, ExprKind.ELEMENTWISE)
+
+    @staticmethod
+    def selector_multi_named() -> ExprMetadata:
+        # e.g. `nw.col('a', 'b')`
+        return ExprMetadata(ExpansionKind.MULTI_NAMED, ExprKind.ELEMENTWISE)
+
     @staticmethod
     def selector_multi_unnamed() -> ExprMetadata:
         # e.g. `nw.all()`
-        return ExprMetadata(
-            ExprKind.TRANSFORM,
-            window_kind=WindowKind.NONE,
-            expansion_kind=ExpansionKind.MULTI_UNNAMED,
-        )
+        return ExprMetadata(ExpansionKind.MULTI_UNNAMED, ExprKind.ELEMENTWISE)
 
     @classmethod
     def from_binary_op(cls, lhs: Expr, rhs: IntoExpr, /) -> ExprMetadata:
@@ -360,7 +486,7 @@ class ExprMetadata:
         )
 
 
-def combine_metadata(  # noqa: C901, PLR0912, PLR0915
+def combine_metadata(  # noqa: C901, PLR0912
     *args: IntoExpr | object | None,
     str_as_lit: bool,
     allow_multi_output: bool,
@@ -376,17 +502,23 @@ def combine_metadata(  # noqa: C901, PLR0912, PLR0915
             of the inputs (e.g. `nw.sum_horizontal`).
     """
     n_filtrations = 0
-    has_transforms_or_windows = False
-    has_aggregations = False
-    has_literals = False
     result_expansion_kind = ExpansionKind.SINGLE
-    has_closeable_windows = False
-    has_uncloseable_windows = False
-    has_closed_windows = False
+    result_has_windows = False
+    result_n_orderable_ops = 0
+    # result preserves length if at least one input does
+    result_preserves_length = False
+    # result is elementwise if all inputs are elementwise
+    result_is_not_elementwise = False
+    # result is scalar-like if all inputs are scalar-like
+    result_is_not_scalar_like = False
+    # result is literal if all inputs are literal
+    result_is_not_literal = False
 
     for i, arg in enumerate(args):  # noqa: PLR1702
-        if isinstance(arg, str) and not str_as_lit:
-            has_transforms_or_windows = True
+        if (isinstance(arg, str) and not str_as_lit) or is_series(arg):
+            result_preserves_length = True
+            result_is_not_scalar_like = True
+            result_is_not_literal = True
         elif is_expr(arg):
             metadata = arg._metadata
             if metadata.expansion_kind.is_multi_output():
@@ -403,56 +535,37 @@ def combine_metadata(  # noqa: C901, PLR0912, PLR0915
                         result_expansion_kind = expansion_kind
                     else:
                         result_expansion_kind = result_expansion_kind & expansion_kind
-            kind = metadata.kind
-            if kind is ExprKind.AGGREGATION:
-                has_aggregations = True
-            elif kind is ExprKind.LITERAL:
-                has_literals = True
-            elif kind is ExprKind.FILTRATION:
+
+            if metadata.has_windows:
+                result_has_windows = True
+            result_n_orderable_ops += metadata.n_orderable_ops
+            if metadata.preserves_length:
+                result_preserves_length = True
+            if not metadata.is_elementwise:
+                result_is_not_elementwise = True
+            if not metadata.is_scalar_like:
+                result_is_not_scalar_like = True
+            if not metadata.is_literal:
+                result_is_not_literal = True
+            if metadata.is_filtration:
                 n_filtrations += 1
-            elif kind.preserves_length():
-                has_transforms_or_windows = True
-            else:  # pragma: no cover
-                msg = "unreachable code"
-                raise AssertionError(msg)
-
-            window_kind = metadata.window_kind
-            if window_kind is WindowKind.UNCLOSEABLE:
-                has_uncloseable_windows = True
-            elif window_kind is WindowKind.CLOSEABLE:
-                has_closeable_windows = True
-            elif window_kind is WindowKind.CLOSED:
-                has_closed_windows = True
-
-    if (
-        has_literals
-        and not has_aggregations
-        and not has_transforms_or_windows
-        and not n_filtrations
-    ):
-        result_kind = ExprKind.LITERAL
-    elif n_filtrations > 1:
+
+    if n_filtrations > 1:
         msg = "Length-changing expressions can only be used in isolation, or followed by an aggregation"
         raise LengthChangingExprError(msg)
-    elif n_filtrations and has_transforms_or_windows:
+    if result_preserves_length and n_filtrations:
         msg = "Cannot combine length-changing expressions with length-preserving ones or aggregations"
         raise ShapeError(msg)
-    elif n_filtrations:
-        result_kind = ExprKind.FILTRATION
-    elif has_transforms_or_windows:
-        result_kind = ExprKind.TRANSFORM
-    else:
-        result_kind = ExprKind.AGGREGATION
-
-    if has_uncloseable_windows or has_closeable_windows:
-        result_window_kind = WindowKind.UNCLOSEABLE
-    elif has_closed_windows:
-        result_window_kind = WindowKind.CLOSED
-    else:
-        result_window_kind = WindowKind.NONE
 
     return ExprMetadata(
-        result_kind, window_kind=result_window_kind, expansion_kind=result_expansion_kind
+        result_expansion_kind,
+        ExprKind.NARY,
+        has_windows=result_has_windows,
+        n_orderable_ops=result_n_orderable_ops,
+        preserves_length=result_preserves_length,
+        is_elementwise=not result_is_not_elementwise,
+        is_scalar_like=not result_is_not_scalar_like,
+        is_literal=not result_is_not_literal,
     )
 
 
@@ -463,8 +576,7 @@ def check_expressions_preserve_length(*args: IntoExpr, function_name: str) -> No
     from narwhals.series import Series
 
     if not all(
-        (is_expr(x) and x._metadata.kind.preserves_length())
-        or isinstance(x, (str, Series))
+        (is_expr(x) and x._metadata.preserves_length) or isinstance(x, (str, Series))
         for x in args
     ):
         msg = f"Expressions which aggregate or change length cannot be passed to '{function_name}'."
@@ -476,7 +588,7 @@ def all_exprs_are_scalar_like(*args: IntoExpr, **kwargs: IntoExpr) -> bool:
     # For Series input, we don't raise (yet), we let such checks happen later,
     # as this function works lazily and so can't evaluate lengths.
     exprs = chain(args, kwargs.values())
-    return all(is_expr(x) and x._metadata.kind.is_scalar_like() for x in exprs)
+    return all(is_expr(x) and x._metadata.is_scalar_like for x in exprs)
 
 
 def apply_n_ary_operation(
@@ -494,7 +606,7 @@ def apply_n_ary_operation(
         for comparand in comparands
     ]
 
-    broadcast = any(not kind.is_scalar_like() for kind in kinds)
+    broadcast = any(not kind.is_scalar_like for kind in kinds)
     compliant_exprs = (
         compliant_expr.broadcast(kind)
         if broadcast and is_compliant_expr(compliant_expr) and is_scalar_like(kind)
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 174d1eca..cabac6e8 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -1615,10 +1615,12 @@ class DataFrame(BaseFrame[DataFrameT]):
         ]
         expr_flat_keys, kinds = self._flatten_and_extract(*_keys)
 
-        if not all(kind is ExprKind.TRANSFORM for kind in kinds):
+        if not all(kind is ExprKind.ELEMENTWISE for kind in kinds):
             from narwhals.exceptions import ComputeError
 
-            msg = "Group by is not supported with keys that are not transformation expressions"
+            msg = (
+                "Group by is not supported with keys that are not elementwise expressions"
+            )
             raise ComputeError(msg)
 
         return GroupBy(self, expr_flat_keys, drop_null_keys=drop_null_keys)
@@ -2209,7 +2211,7 @@ class LazyFrame(BaseFrame[FrameT]):
             plx = self.__narwhals_namespace__()
             return plx.col(arg)
         if isinstance(arg, Expr):
-            if arg._metadata._window_kind.is_open():
+            if arg._metadata.n_orderable_ops:
                 msg = (
                     "Order-dependent expressions are not supported for use in LazyFrame.\n\n"
                     "Hints:\n"
@@ -2219,7 +2221,7 @@ class LazyFrame(BaseFrame[FrameT]):
                     "See https://narwhals-dev.github.io/narwhals/basics/order_dependence/."
                 )
                 raise OrderDependentExprError(msg)
-            if arg._metadata.kind.is_filtration():
+            if arg._metadata.is_filtration:
                 msg = (
                     "Length-changing expressions are not supported for use in LazyFrame, unless\n"
                     "followed by an aggregation.\n\n"
@@ -2917,10 +2919,12 @@ class LazyFrame(BaseFrame[FrameT]):
         _keys = [k if is_expr else col(k) for k, is_expr in zip(flat_keys, key_is_expr)]
         expr_flat_keys, kinds = self._flatten_and_extract(*_keys)
 
-        if not all(kind is ExprKind.TRANSFORM for kind in kinds):
+        if not all(kind is ExprKind.ELEMENTWISE for kind in kinds):
             from narwhals.exceptions import ComputeError
 
-            msg = "Group by is not supported with keys that are not transformation expressions"
+            msg = (
+                "Group by is not supported with keys that are not elementwise expressions"
+            )
             raise ComputeError(msg)
 
         return LazyGroupBy(self, expr_flat_keys, drop_null_keys=drop_null_keys)
diff --git a/narwhals/expr.py b/narwhals/expr.py
index e48745b8..a1abfba7 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -7,15 +7,12 @@ from typing import Iterable
 from typing import Mapping
 from typing import Sequence
 
-from narwhals._expression_parsing import ExprKind
 from narwhals._expression_parsing import ExprMetadata
-from narwhals._expression_parsing import WindowKind
 from narwhals._expression_parsing import apply_n_ary_operation
 from narwhals._expression_parsing import combine_metadata
 from narwhals._expression_parsing import extract_compliant
 from narwhals.dtypes import _validate_dtype
 from narwhals.exceptions import InvalidOperationError
-from narwhals.exceptions import LengthChangingExprError
 from narwhals.expr_cat import ExprCatNamespace
 from narwhals.expr_dt import ExprDateTimeNamespace
 from narwhals.expr_list import ExprListNamespace
@@ -65,43 +62,31 @@ class Expr:
         self._to_compliant_expr: _ToCompliant = func
         self._metadata = metadata
 
-    def _with_callable(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
-        # Instantiate new Expr keeping metadata unchanged, unless
-        # it's a WINDOW, in which case make it a TRANSFORM.
-        if self._metadata.kind.is_window():
-            # We had a window function, but it wasn't immediately followed by
-            # `over(order_by=...)` - it missed its chance, it's now forever uncloseable.
-            return self.__class__(
-                to_compliant_expr,
-                self._metadata.with_kind_and_uncloseable_window(ExprKind.TRANSFORM),
-            )
-        return self.__class__(to_compliant_expr, self._metadata)
+    def _with_elementwise_op(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
+        return self.__class__(to_compliant_expr, self._metadata.with_elementwise_op())
 
     def _with_aggregation(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
-        if self._metadata.kind.is_scalar_like():
-            msg = "Aggregations can't be applied to scalar-like expressions."
-            raise InvalidOperationError(msg)
-        return self.__class__(
-            to_compliant_expr, self._metadata.with_kind(ExprKind.AGGREGATION)
-        )
+        return self.__class__(to_compliant_expr, self._metadata.with_aggregation())
 
-    def _with_order_dependent_aggregation(
+    def _with_orderable_aggregation(
         self, to_compliant_expr: Callable[[Any], Any]
     ) -> Self:
-        if self._metadata.kind.is_scalar_like():
-            msg = "Aggregations can't be applied to scalar-like expressions."
-            raise InvalidOperationError(msg)
         return self.__class__(
-            to_compliant_expr,
-            self._metadata.with_kind_and_closeable_window(ExprKind.AGGREGATION),
+            to_compliant_expr, self._metadata.with_orderable_aggregation()
         )
 
+    def _with_orderable_window(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
+        return self.__class__(to_compliant_expr, self._metadata.with_orderable_window())
+
+    def _with_unorderable_window(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
+        return self.__class__(to_compliant_expr, self._metadata.with_unorderable_window())
+
     def _with_filtration(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
-        if self._metadata.kind.is_scalar_like():
-            msg = "Length-changing can't be applied to scalar-like expressions."
-            raise InvalidOperationError(msg)
+        return self.__class__(to_compliant_expr, self._metadata.with_filtration())
+
+    def _with_orderable_filtration(self, to_compliant_expr: Callable[[Any], Any]) -> Self:
         return self.__class__(
-            to_compliant_expr, self._metadata.with_kind(ExprKind.FILTRATION)
+            to_compliant_expr, self._metadata.with_orderable_filtration()
         )
 
     def __repr__(self) -> str:
@@ -138,7 +123,10 @@ class Expr:
             |      1  15       |
             └──────────────────┘
         """
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).alias(name))
+        # Don't use `_with_elementwise_op` so that `_metadata.last_node` is preserved.
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).alias(name), self._metadata
+        )
 
     def pipe(
         self,
@@ -199,7 +187,9 @@ class Expr:
             └──────────────────┘
         """
         _validate_dtype(dtype)
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).cast(dtype))
+        return self._with_elementwise_op(
+            lambda plx: self._to_compliant_expr(plx).cast(dtype)
+        )
 
     # --- binary ---
     def __eq__(self, other: Self | Any) -> Self:  # type: ignore[override]
@@ -396,7 +386,9 @@ class Expr:
 
     # --- unary ---
     def __invert__(self) -> Self:
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).__invert__())
+        return self._with_elementwise_op(
+            lambda plx: self._to_compliant_expr(plx).__invert__()
+        )
 
     def any(self) -> Self:
         """Return whether any of the values in the column are `True`.
@@ -526,7 +518,7 @@ class Expr:
             │ 2.428571 │
             └──────────┘
         """
-        return self._with_callable(
+        return self._with_orderable_window(
             lambda plx: self._to_compliant_expr(plx).ewm_mean(
                 com=com,
                 span=span,
@@ -676,12 +668,11 @@ class Expr:
             |2  3  6       4.0       7.0|
             └───────────────────────────┘
         """
-        return self.__class__(
+        # safest assumptions
+        return self._with_orderable_filtration(
             lambda plx: self._to_compliant_expr(plx).map_batches(
                 function=function, return_dtype=return_dtype
-            ),
-            # safest assumptions
-            self._metadata.with_kind_and_closeable_window(ExprKind.FILTRATION),
+            )
         )
 
     def skew(self) -> Self:
@@ -791,7 +782,7 @@ class Expr:
             |0          0          1|
             └───────────────────────┘
         """
-        return self._with_order_dependent_aggregation(
+        return self._with_orderable_aggregation(
             lambda plx: self._to_compliant_expr(plx).arg_min()
         )
 
@@ -814,7 +805,7 @@ class Expr:
             |0          1          0|
             └───────────────────────┘
         """
-        return self._with_order_dependent_aggregation(
+        return self._with_orderable_aggregation(
             lambda plx: self._to_compliant_expr(plx).arg_max()
         )
 
@@ -901,7 +892,7 @@ class Expr:
             |1 -2  4      2      4|
             └─────────────────────┘
         """
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).abs())
+        return self._with_elementwise_op(lambda plx: self._to_compliant_expr(plx).abs())
 
     def cum_sum(self, *, reverse: bool = False) -> Self:
         """Return cumulative sum.
@@ -933,9 +924,8 @@ class Expr:
             |4  5  6         15|
             └──────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).cum_sum(reverse=reverse),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).cum_sum(reverse=reverse)
         )
 
     def diff(self) -> Self:
@@ -980,9 +970,8 @@ class Expr:
             | └─────┴────────┘ |
             └──────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).diff(),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).diff()
         )
 
     def shift(self, n: int) -> Self:
@@ -1030,9 +1019,8 @@ class Expr:
             |└─────┴─────────┘ |
             └──────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).shift(n),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).shift(n)
         )
 
     def replace_strict(
@@ -1088,7 +1076,7 @@ class Expr:
             new = list(old.values())
             old = list(old.keys())
 
-        return self._with_callable(
+        return self._with_elementwise_op(
             lambda plx: self._to_compliant_expr(plx).replace_strict(
                 old, new, return_dtype=return_dtype
             )
@@ -1118,11 +1106,10 @@ class Expr:
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
         issue_deprecation_warning(msg, _version="1.23.0")
-        return self.__class__(
+        return self._with_orderable_window(
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
-            ),
-            self._metadata.with_uncloseable_window(),
+            )
         )
 
     # --- transform ---
@@ -1213,7 +1200,7 @@ class Expr:
             └──────────────────┘
         """
         if isinstance(other, Iterable) and not isinstance(other, (str, bytes)):
-            return self._with_callable(
+            return self._with_elementwise_op(
                 lambda plx: self._to_compliant_expr(plx).is_in(
                     to_native(other, pass_through=True)
                 ),
@@ -1258,7 +1245,7 @@ class Expr:
             str_as_lit=False,
             allow_multi_output=True,
             to_single_output=False,
-        ).with_kind(ExprKind.FILTRATION)
+        ).with_filtration()
         return self.__class__(
             lambda plx: apply_n_ary_operation(
                 plx,
@@ -1303,7 +1290,9 @@ class Expr:
             |└───────┴────────┴───────────┴───────────┘|
             └──────────────────────────────────────────┘
         """
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).is_null())
+        return self._with_elementwise_op(
+            lambda plx: self._to_compliant_expr(plx).is_null()
+        )
 
     def is_nan(self) -> Self:
         """Indicate which values are NaN.
@@ -1338,7 +1327,9 @@ class Expr:
             |└───────┴────────┴──────────┴──────────┘|
             └────────────────────────────────────────┘
         """
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).is_nan())
+        return self._with_elementwise_op(
+            lambda plx: self._to_compliant_expr(plx).is_nan()
+        )
 
     def arg_true(self) -> Self:
         """Find elements where boolean expression is True.
@@ -1446,7 +1437,7 @@ class Expr:
                 strategy=strategy,
                 limit=limit,
             ),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW)
+            self._metadata.with_orderable_window()
             if strategy is not None
             else self._metadata,
         )
@@ -1576,38 +1567,20 @@ class Expr:
             |2  4  y                    4|
             └────────────────────────────┘
         """
-        if self._metadata.kind.is_filtration():
-            msg = "`.over()` can not be used for expressions which change length."
-            raise LengthChangingExprError(msg)
-
         flat_partition_by = flatten(partition_by)
         flat_order_by = [order_by] if isinstance(order_by, str) else order_by
         if not flat_partition_by and not flat_order_by:  # pragma: no cover
             msg = "At least one of `partition_by` or `order_by` must be specified."
             raise ValueError(msg)
 
-        kind = ExprKind.TRANSFORM
-        window_kind = self._metadata.window_kind
-        if window_kind.is_closed():
-            msg = "Nested `over` statements are not allowed."
-            raise InvalidOperationError(msg)
-        if flat_order_by is not None and self._metadata.kind.is_window():
-            # debug assertion, an open window should already have been set
-            # by the window function. If it's immediately followed by `over`, then the
-            # window gets closed.
-            assert window_kind.is_open()  # noqa: S101
-        elif flat_order_by is not None and not window_kind.is_open():
-            msg = "Cannot use `order_by` in `over` on expression which isn't order-dependent."
-            raise InvalidOperationError(msg)
         current_meta = self._metadata
-        next_window_kind = (
-            WindowKind.UNCLOSEABLE if window_kind.is_uncloseable() else WindowKind.CLOSED
-        )
-        next_meta = ExprMetadata(
-            kind,
-            window_kind=next_window_kind,
-            expansion_kind=current_meta.expansion_kind,
-        )
+        if flat_order_by:
+            next_meta = current_meta.with_ordered_over()
+        elif not flat_partition_by:  # pragma: no cover
+            msg = "At least one of `partition_by` or `order_by` must be specified."
+            raise InvalidOperationError(msg)
+        else:
+            next_meta = current_meta.with_partitioned_over()
 
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).over(
@@ -1662,7 +1635,9 @@ class Expr:
             |3  1  c        False         True|
             └─────────────────────────────────┘
         """
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).is_unique())
+        return self._with_unorderable_window(
+            lambda plx: self._to_compliant_expr(plx).is_unique()
+        )
 
     def null_count(self) -> Self:
         r"""Count null values.
@@ -1722,9 +1697,8 @@ class Expr:
             |3  1  c                False                 True|
             └─────────────────────────────────────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).is_first_distinct(),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).is_first_distinct()
         )
 
     def is_last_distinct(self) -> Self:
@@ -1755,9 +1729,8 @@ class Expr:
             |3  1  c                True                True|
             └───────────────────────────────────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).is_last_distinct(),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).is_last_distinct()
         )
 
     def quantile(
@@ -1821,7 +1794,9 @@ class Expr:
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
         issue_deprecation_warning(msg, _version="1.23.0")
-        return self._with_filtration(lambda plx: self._to_compliant_expr(plx).head(n))
+        return self._with_orderable_filtration(
+            lambda plx: self._to_compliant_expr(plx).head(n)
+        )
 
     def tail(self, n: int = 10) -> Self:
         r"""Get the last `n` rows.
@@ -1881,7 +1856,7 @@ class Expr:
             |2  3.901234        3.9|
             └──────────────────────┘
         """
-        return self._with_callable(
+        return self._with_elementwise_op(
             lambda plx: self._to_compliant_expr(plx).round(decimals)
         )
 
@@ -2049,7 +2024,9 @@ class Expr:
             |└──────┴─────────────┘|
             └──────────────────────┘
         """
-        return self._with_callable(lambda plx: self._to_compliant_expr(plx).is_finite())
+        return self._with_elementwise_op(
+            lambda plx: self._to_compliant_expr(plx).is_finite()
+        )
 
     def cum_count(self, *, reverse: bool = False) -> Self:
         r"""Return the cumulative count of the non-null values in the column.
@@ -2083,9 +2060,8 @@ class Expr:
             |3     d            3                    1|
             └─────────────────────────────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).cum_count(reverse=reverse),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).cum_count(reverse=reverse)
         )
 
     def cum_min(self, *, reverse: bool = False) -> Self:
@@ -2120,9 +2096,8 @@ class Expr:
             |3  2.0        1.0                2.0|
             └────────────────────────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).cum_min(reverse=reverse),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).cum_min(reverse=reverse)
         )
 
     def cum_max(self, *, reverse: bool = False) -> Self:
@@ -2157,9 +2132,8 @@ class Expr:
             |3  2.0        3.0                2.0|
             └────────────────────────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).cum_max(reverse=reverse),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).cum_max(reverse=reverse)
         )
 
     def cum_prod(self, *, reverse: bool = False) -> Self:
@@ -2194,9 +2168,8 @@ class Expr:
             |3  2.0         6.0                 2.0|
             └──────────────────────────────────────┘
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).cum_prod(reverse=reverse),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+        return self._with_orderable_window(
+            lambda plx: self._to_compliant_expr(plx).cum_prod(reverse=reverse)
         )
 
     def rolling_sum(
@@ -2252,13 +2225,12 @@ class Expr:
             window_size=window_size, min_samples=min_samples
         )
 
-        return self.__class__(
+        return self._with_orderable_window(
             lambda plx: self._to_compliant_expr(plx).rolling_sum(
                 window_size=window_size,
                 min_samples=min_samples_int,
                 center=center,
-            ),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+            )
         )
 
     def rolling_mean(
@@ -2314,13 +2286,12 @@ class Expr:
             window_size=window_size, min_samples=min_samples
         )
 
-        return self.__class__(
+        return self._with_orderable_window(
             lambda plx: self._to_compliant_expr(plx).rolling_mean(
                 window_size=window_size,
                 min_samples=min_samples,
                 center=center,
-            ),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+            )
         )
 
     def rolling_var(
@@ -2378,11 +2349,10 @@ class Expr:
             window_size=window_size, min_samples=min_samples
         )
 
-        return self.__class__(
+        return self._with_orderable_window(
             lambda plx: self._to_compliant_expr(plx).rolling_var(
                 window_size=window_size, min_samples=min_samples, center=center, ddof=ddof
-            ),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+            )
         )
 
     def rolling_std(
@@ -2440,14 +2410,13 @@ class Expr:
             window_size=window_size, min_samples=min_samples
         )
 
-        return self.__class__(
+        return self._with_orderable_window(
             lambda plx: self._to_compliant_expr(plx).rolling_std(
                 window_size=window_size,
                 min_samples=min_samples,
                 center=center,
                 ddof=ddof,
-            ),
-            self._metadata.with_kind_and_closeable_window(ExprKind.WINDOW),
+            )
         )
 
     def rank(self, method: RankMethod = "average", *, descending: bool = False) -> Self:
@@ -2507,7 +2476,7 @@ class Expr:
             )
             raise ValueError(msg)
 
-        return self._with_callable(
+        return self._with_unorderable_window(
             lambda plx: self._to_compliant_expr(plx).rank(
                 method=method, descending=descending
             )
diff --git a/narwhals/expr_cat.py b/narwhals/expr_cat.py
index fa0b7347..29ffaec5 100644
--- a/narwhals/expr_cat.py
+++ b/narwhals/expr_cat.py
@@ -39,6 +39,6 @@ class ExprCatNamespace(Generic[ExprT]):
             │ mango  │
             └────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).cat.get_categories()
         )
diff --git a/narwhals/expr_dt.py b/narwhals/expr_dt.py
index 96d9eb2e..2468d4b6 100644
--- a/narwhals/expr_dt.py
+++ b/narwhals/expr_dt.py
@@ -43,7 +43,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 2027-12-13 │
             └────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.date()
         )
 
@@ -72,7 +72,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |1 2065-01-01  2065|
             └──────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.year()
         )
 
@@ -98,7 +98,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-06-01 00:00:00.000000,2065-01-01 00:00:00.000000]]
             month: [[6,1]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.month()
         )
 
@@ -124,7 +124,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-06-01 00:00:00.000000,2065-01-01 00:00:00.000000]]
             day: [[1,1]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.day()
         )
 
@@ -159,7 +159,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |└─────────────────────┴──────┘|
             └──────────────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.hour()
         )
 
@@ -184,7 +184,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             0 1978-01-01 01:01:00       1
             1 2065-01-01 10:20:00      20
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.minute()
         )
 
@@ -215,7 +215,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.000000]]
             second: [[1,30]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.second()
         )
 
@@ -248,7 +248,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.067000]]
             millisecond: [[0,67]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.millisecond()
         )
 
@@ -281,7 +281,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.067000]]
             microsecond: [[0,67000]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.microsecond()
         )
 
@@ -314,7 +314,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.067000]]
             nanosecond: [[0,67000000]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.nanosecond()
         )
 
@@ -341,7 +341,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |1 2020-08-03            216|
             └───────────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.ordinal_day()
         )
 
@@ -368,7 +368,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |1 2020-08-03           1|
             └────────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.weekday()
         )
 
@@ -404,7 +404,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 20m 40s      ┆ 20              │
             └──────────────┴─────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_minutes()
         )
 
@@ -440,7 +440,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 20s 40ms     ┆ 20              │
             └──────────────┴─────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_seconds()
         )
 
@@ -481,7 +481,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 20040µs      ┆ 20                   │
             └──────────────┴──────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_milliseconds()
         )
 
@@ -519,7 +519,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[10,1200]]
             a_total_microseconds: [[10,1200]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_microseconds()
         )
 
@@ -556,7 +556,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             0 2024-01-01 00:00:00.000000001                       NaN
             1 2024-01-01 00:00:00.000000002                       1.0
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_nanoseconds()
         )
 
@@ -628,7 +628,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |└─────────────────────┘|
             └───────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.to_string(format)
         )
 
@@ -659,7 +659,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             0 2024-01-01 00:00:00+05:45
             1 2024-01-02 00:00:00+05:45
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.replace_time_zone(
                 time_zone
             ),
@@ -698,7 +698,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         if time_zone is None:
             msg = "Target `time_zone` cannot be `None` in `convert_time_zone`. Please use `replace_time_zone(None)` if you want to remove the time zone."
             raise TypeError(msg)
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.convert_time_zone(
                 time_zone
             ),
@@ -743,7 +743,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
                 f"\n\nExpected one of {{'ns', 'us', 'ms'}}, got {time_unit!r}."
             )
             raise ValueError(msg)
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.timestamp(time_unit),
         )
 
@@ -790,6 +790,6 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |└─────────────────────┴─────────────────────┘|
             └─────────────────────────────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).dt.truncate(every),
         )
diff --git a/narwhals/expr_list.py b/narwhals/expr_list.py
index 78cff354..e538d012 100644
--- a/narwhals/expr_list.py
+++ b/narwhals/expr_list.py
@@ -44,6 +44,6 @@ class ExprListNamespace(Generic[ExprT]):
             |└──────────────┴───────┘|
             └────────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).list.len()
         )
diff --git a/narwhals/expr_name.py b/narwhals/expr_name.py
index aa5670f5..930d29f7 100644
--- a/narwhals/expr_name.py
+++ b/narwhals/expr_name.py
@@ -34,7 +34,7 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo").alias("alias_for_foo").name.keep()).columns
             ['foo']
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).name.keep()
         )
 
@@ -61,7 +61,7 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.map(renaming_func)).columns
             ['oof', 'RAB']
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).name.map(function)
         )
 
@@ -87,7 +87,7 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.prefix("with_prefix")).columns
             ['with_prefixfoo', 'with_prefixBAR']
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).name.prefix(prefix)
         )
 
@@ -113,7 +113,7 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.suffix("_with_suffix")).columns
             ['foo_with_suffix', 'BAR_with_suffix']
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).name.suffix(suffix)
         )
 
@@ -136,7 +136,7 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.to_lowercase()).columns
             ['foo', 'bar']
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).name.to_lowercase()
         )
 
@@ -159,6 +159,6 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.to_uppercase()).columns
             ['FOO', 'BAR']
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).name.to_uppercase()
         )
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index 87a38706..d40391dc 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -41,7 +41,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |└───────┴───────────┘|
             └─────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.len_chars()
         )
 
@@ -73,7 +73,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |1  abc abc123   abc123|
             └──────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.replace(
                 pattern, value, literal=literal, n=n
             )
@@ -104,7 +104,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |1  abc abc123      123|
             └──────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.replace_all(
                 pattern, value, literal=literal
             )
@@ -132,7 +132,7 @@ class ExprStringNamespace(Generic[ExprT]):
             ... )
             {'fruits': ['apple', '\nmango'], 'stripped': ['apple', 'mango']}
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.strip_chars(characters)
         )
 
@@ -160,7 +160,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |2   None       None|
             └───────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.starts_with(prefix)
         )
 
@@ -188,7 +188,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |2   None       None|
             └───────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.ends_with(suffix)
         )
 
@@ -221,7 +221,7 @@ class ExprStringNamespace(Generic[ExprT]):
             default_match: [[true,false,true]]
             case_insensitive_match: [[true,false,true]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.contains(
                 pattern, literal=literal
             )
@@ -253,7 +253,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |2  papaya       ya|
             └──────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
                 offset=offset, length=length
             )
@@ -288,7 +288,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |└─────────┴────────────────┘|
             └────────────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.split(by=by)
         )
 
@@ -317,7 +317,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lyrics: [["taata","taatatata","zukkyun"]]
             lyrics_head: [["taata","taata","zukky"]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(0, n)
         )
 
@@ -346,7 +346,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lyrics: [["taata","taatatata","zukkyun"]]
             lyrics_tail: [["taata","atata","kkyun"]]
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
                 offset=-n, length=None
             )
@@ -393,7 +393,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |└─────────────────────┘|
             └───────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_datetime(format=format)
         )
 
@@ -422,7 +422,7 @@ class ExprStringNamespace(Generic[ExprT]):
             |1   None      None|
             └──────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_uppercase()
         )
 
@@ -446,6 +446,6 @@ class ExprStringNamespace(Generic[ExprT]):
             |1   None      None|
             └──────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_lowercase()
         )
diff --git a/narwhals/expr_struct.py b/narwhals/expr_struct.py
index 37c62d49..cf5bd447 100644
--- a/narwhals/expr_struct.py
+++ b/narwhals/expr_struct.py
@@ -50,6 +50,6 @@ class ExprStructNamespace(Generic[ExprT]):
             |└──────────────┴──────┘|
             └───────────────────────┘
         """
-        return self._expr._with_callable(
+        return self._expr._with_elementwise_op(
             lambda plx: self._expr._to_compliant_expr(plx).struct.field(name)
         )
diff --git a/narwhals/functions.py b/narwhals/functions.py
index a3bb8625..40c5068d 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -12,10 +12,8 @@ from typing import Sequence
 from typing import TypeVar
 from typing import cast
 
-from narwhals._expression_parsing import ExpansionKind
 from narwhals._expression_parsing import ExprKind
 from narwhals._expression_parsing import ExprMetadata
-from narwhals._expression_parsing import WindowKind
 from narwhals._expression_parsing import apply_n_ary_operation
 from narwhals._expression_parsing import check_expressions_preserve_length
 from narwhals._expression_parsing import combine_metadata
@@ -1192,14 +1190,7 @@ def len_() -> Expr:
     def func(plx: Any) -> Any:
         return plx.len()
 
-    return Expr(
-        func,
-        ExprMetadata(
-            ExprKind.AGGREGATION,
-            window_kind=WindowKind.NONE,
-            expansion_kind=ExpansionKind.SINGLE,
-        ),
-    )
+    return Expr(func, ExprMetadata.aggregation())
 
 
 def sum(*columns: str) -> Expr:
@@ -1665,14 +1656,7 @@ def lit(value: NonNestedLiteral, dtype: DType | type[DType] | None = None) -> Ex
         msg = f"Nested datatypes are not supported yet. Got {value}"
         raise NotImplementedError(msg)
 
-    return Expr(
-        lambda plx: plx.lit(value, dtype),
-        ExprMetadata(
-            ExprKind.LITERAL,
-            window_kind=WindowKind.NONE,
-            expansion_kind=ExpansionKind.SINGLE,
-        ),
-    )
+    return Expr(lambda plx: plx.lit(value, dtype), ExprMetadata.literal())
 
 
 def any_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index d8653094..fbdb92ee 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -15,7 +15,6 @@ import narwhals as nw
 from narwhals import dependencies
 from narwhals import exceptions
 from narwhals import selectors
-from narwhals._expression_parsing import ExprKind
 from narwhals.dataframe import DataFrame as NwDataFrame
 from narwhals.dataframe import LazyFrame as NwLazyFrame
 from narwhals.dependencies import get_polars
@@ -350,9 +349,8 @@ class Expr(NwExpr):
         Returns:
             A new expression.
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).head(n),
-            self._metadata.with_kind_and_closeable_window(ExprKind.FILTRATION),
+        return self._with_orderable_filtration(
+            lambda plx: self._to_compliant_expr(plx).head(n)
         )
 
     def tail(self, n: int = 10) -> Self:
@@ -364,9 +362,8 @@ class Expr(NwExpr):
         Returns:
             A new expression.
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).tail(n),
-            self._metadata.with_kind_and_closeable_window(ExprKind.FILTRATION),
+        return self._with_orderable_filtration(
+            lambda plx: self._to_compliant_expr(plx).tail(n)
         )
 
     def gather_every(self, n: int, offset: int = 0) -> Self:
@@ -379,9 +376,8 @@ class Expr(NwExpr):
         Returns:
             A new expression.
         """
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset),
-            self._metadata.with_kind_and_closeable_window(ExprKind.FILTRATION),
+        return self._with_orderable_filtration(
+            lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset)
         )
 
     def unique(self, *, maintain_order: bool | None = None) -> Self:
@@ -401,10 +397,7 @@ class Expr(NwExpr):
                 "You can safely remove this argument."
             )
             warn(message=msg, category=UserWarning, stacklevel=find_stacklevel())
-        return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).unique(),
-            self._metadata.with_kind(ExprKind.FILTRATION),
-        )
+        return self._with_filtration(lambda plx: self._to_compliant_expr(plx).unique())
 
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         """Sort this column. Place null values first.
@@ -416,11 +409,10 @@ class Expr(NwExpr):
         Returns:
             A new expression.
         """
-        return self.__class__(
+        return self._with_unorderable_window(
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
-            ),
-            self._metadata.with_uncloseable_window(),
+            )
         )
 
     def arg_true(self) -> Self:
@@ -429,9 +421,8 @@ class Expr(NwExpr):
         Returns:
             A new expression.
         """
-        return self.__class__(
+        return self._with_orderable_filtration(
             lambda plx: self._to_compliant_expr(plx).arg_true(),
-            self._metadata.with_kind_and_closeable_window(ExprKind.FILTRATION),
         )
 
     def sample(
@@ -454,11 +445,10 @@ class Expr(NwExpr):
         Returns:
             A new expression.
         """
-        return self.__class__(
+        return self._with_filtration(
             lambda plx: self._to_compliant_expr(plx).sample(
                 n, fraction=fraction, with_replacement=with_replacement, seed=seed
-            ),
-            self._metadata.with_kind(ExprKind.FILTRATION),
+            )
         )
 
 
diff --git a/utils/api-completeness.md.jinja b/utils/api-completeness.md.jinja
index d79f157c..954adca9 100644
--- a/utils/api-completeness.md.jinja
+++ b/utils/api-completeness.md.jinja
@@ -1,3 +1,8 @@
+---
+search:
+  exclude: true
+---
+
 # {{ title }}
 
 {{ backend_table }}


Test patch:
diff --git a/tests/expr_and_series/arg_min_test.py b/tests/expr_and_series/arg_min_test.py
index baf9a5f8..28012f2c 100644
--- a/tests/expr_and_series/arg_min_test.py
+++ b/tests/expr_and_series/arg_min_test.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import pytest
 
 import narwhals as nw
+from tests.utils import POLARS_VERSION
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
@@ -38,3 +39,18 @@ def test_expr_arg_min_series(
     series = nw.maybe_set_index(series, index=[1, 0, 9])  # type: ignore[arg-type]
     result = series.arg_min()
     assert_equal_data({col: [result]}, {col: [expected]})
+
+
+def test_expr_arg_min_over() -> None:
+    # This is tricky. But, we may be able to support it for
+    # other backends too one day.
+    pytest.importorskip("polars")
+    import polars as pl
+
+    if POLARS_VERSION < (1, 10):
+        pytest.skip()
+
+    df = nw.from_native(pl.LazyFrame({"a": [9, 8, 7], "i": [0, 2, 1]}))
+    result = df.select(nw.col("a").arg_min().over(order_by="i"))
+    expected = {"a": [1, 1, 1]}
+    assert_equal_data(result, expected)
diff --git a/tests/expr_and_series/lit_test.py b/tests/expr_and_series/lit_test.py
index e62061cf..35278184 100644
--- a/tests/expr_and_series/lit_test.py
+++ b/tests/expr_and_series/lit_test.py
@@ -9,6 +9,7 @@ import pyarrow as pa
 import pytest
 
 import narwhals as nw
+from tests.utils import DASK_VERSION
 from tests.utils import PANDAS_VERSION
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
@@ -74,6 +75,7 @@ def test_lit_out_name(constructor: Constructor) -> None:
     [
         ("left_lit", nw.lit(1) + nw.col("a"), [2, 4, 3]),
         ("right_lit", nw.col("a") + nw.lit(1), [2, 4, 3]),
+        ("right_lit_with_abs", nw.col("a") + nw.lit(-1).abs(), [2, 4, 3]),
         ("left_lit_with_agg", nw.lit(1) + nw.col("a").mean(), [3]),
         ("right_lit_with_agg", nw.col("a").mean() - nw.lit(1), [1]),
         ("left_scalar", 1 + nw.col("a"), [2, 4, 3]),
@@ -89,6 +91,13 @@ def test_lit_operation_in_select(
     expr: nw.Expr,
     expected_result: list[int],
 ) -> None:
+    if (
+        "dask" in str(constructor)
+        and col_name == "right_lit_with_abs"
+        and DASK_VERSION < (2025,)
+    ):
+        pytest.skip()
+
     data = {"a": [1, 3, 2]}
     df_raw = constructor(data)
     df = nw.from_native(df_raw).lazy()
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index 212294ec..feabbcf6 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import re
 from contextlib import nullcontext as does_not_raise
 
 import pandas as pd
@@ -8,7 +7,7 @@ import pyarrow as pa
 import pytest
 
 import narwhals as nw
-from narwhals.exceptions import LengthChangingExprError
+from narwhals.exceptions import InvalidOperationError
 from tests.utils import DUCKDB_VERSION
 from tests.utils import PANDAS_VERSION
 from tests.utils import POLARS_VERSION
@@ -299,7 +298,7 @@ def test_over_anonymous_reduction(
 def test_over_unsupported() -> None:
     dfpd = pd.DataFrame({"a": [1, 1, 2], "b": [4, 5, 6]})
     with pytest.raises(NotImplementedError):
-        nw.from_native(dfpd).select(nw.col("a").round().over("a"))
+        nw.from_native(dfpd).select(nw.col("a").null_count().over("a"))
 
 
 def test_over_unsupported_dask() -> None:
@@ -308,7 +307,7 @@ def test_over_unsupported_dask() -> None:
 
     df = dd.from_pandas(pd.DataFrame({"a": [1, 1, 2], "b": [4, 5, 6]}))
     with pytest.raises(NotImplementedError):
-        nw.from_native(df).select(nw.col("a").round().over("a"))
+        nw.from_native(df).select(nw.col("a").null_count().over("a"))
 
 
 def test_over_shift(
@@ -392,10 +391,7 @@ def test_over_cum_reverse(
 def test_over_raise_len_change(constructor: Constructor) -> None:
     df = nw.from_native(constructor(data))
 
-    with pytest.raises(
-        LengthChangingExprError,
-        match=re.escape("`.over()` can not be used for expressions which change length."),
-    ):
+    with pytest.raises(InvalidOperationError):
         nw.from_native(df).select(nw.col("b").drop_nulls().over("a"))
 
 
diff --git a/tests/expr_and_series/str/to_datetime_test.py b/tests/expr_and_series/str/to_datetime_test.py
index 6f85d67b..f25a9c9e 100644
--- a/tests/expr_and_series/str/to_datetime_test.py
+++ b/tests/expr_and_series/str/to_datetime_test.py
@@ -36,11 +36,7 @@ def test_to_datetime(constructor: Constructor) -> None:
     )
     result_schema = result.collect_schema()
     assert isinstance(result_schema["b"], nw.Datetime)
-    if "sqlframe" in str(constructor):
-        # https://github.com/eakmanrq/sqlframe/issues/326
-        assert result_schema["b"].time_zone == "UTC"  # pyright: ignore[reportAttributeAccessIssue]
-    else:
-        assert result_schema["b"].time_zone is None  # pyright: ignore[reportAttributeAccessIssue]
+    assert result_schema["b"].time_zone is None  # pyright: ignore[reportAttributeAccessIssue]
     result_item = result.collect().item(row=0, column="b")
     assert str(result_item) == expected
 
diff --git a/tests/expr_and_series/sum_horizontal_test.py b/tests/expr_and_series/sum_horizontal_test.py
index 747a487c..387d194f 100644
--- a/tests/expr_and_series/sum_horizontal_test.py
+++ b/tests/expr_and_series/sum_horizontal_test.py
@@ -25,11 +25,11 @@ def test_sumh(constructor: Constructor, col_expr: Any) -> None:
 
 
 def test_sumh_nullable(constructor: Constructor) -> None:
-    data = {"a": [1, 8, 3], "b": [4, 5, None]}
-    expected = {"hsum": [5, 13, 3]}
+    data = {"a": [1, 8, 3], "b": [4, 5, None], "i": [0, 1, 2]}
+    expected = {"hsum": [5, 13, 3], "i": [0, 1, 2]}
 
     df = nw.from_native(constructor(data))
-    result = df.select(hsum=nw.sum_horizontal("a", "b"))
+    result = df.select(hsum=nw.sum_horizontal("a", "b"), i="i").sort("i")
     assert_equal_data(result, expected)
 
 
diff --git a/tests/expression_parsing_test.py b/tests/expression_parsing_test.py
index 3644fbf7..ba042281 100644
--- a/tests/expression_parsing_test.py
+++ b/tests/expression_parsing_test.py
@@ -3,52 +3,52 @@ from __future__ import annotations
 import pytest
 
 import narwhals as nw
-from narwhals._expression_parsing import WindowKind
+import narwhals.stable.v1 as nw_v1
 from narwhals.exceptions import InvalidOperationError
 
 
 @pytest.mark.parametrize(
     ("expr", "expected"),
     [
-        (nw.col("a"), WindowKind.NONE),
-        (nw.col("a").mean(), WindowKind.NONE),
-        (nw.col("a").cum_sum(), WindowKind.CLOSEABLE),
-        (nw.col("a").cum_sum().over(order_by="id"), WindowKind.CLOSED),
-        (nw.col("a").cum_sum().abs().over(order_by="id"), WindowKind.UNCLOSEABLE),
-        ((nw.col("a").cum_sum() + 1).over(order_by="id"), WindowKind.UNCLOSEABLE),
-        (nw.col("a").cum_sum().cum_sum().over(order_by="id"), WindowKind.UNCLOSEABLE),
-        (nw.col("a").cum_sum().cum_sum(), WindowKind.UNCLOSEABLE),
-        (nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()), WindowKind.UNCLOSEABLE),
+        (nw.col("a"), 0),
+        (nw.col("a").mean(), 0),
+        (nw.col("a").cum_sum(), 1),
+        (nw.col("a").cum_sum().over(order_by="id"), 0),
+        (nw.col("a").cum_sum().abs().over(order_by="id"), 1),
+        ((nw.col("a").cum_sum() + 1).over(order_by="id"), 1),
+        (nw.col("a").cum_sum().cum_sum().over(order_by="id"), 1),
+        (nw.col("a").cum_sum().cum_sum(), 2),
+        (nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()), 1),
         (
-            nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()).over("a"),
-            WindowKind.UNCLOSEABLE,
+            nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()).over(order_by="a"),
+            1,
         ),
         (
             nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum().over(order_by="i")),
-            WindowKind.CLOSED,
+            0,
         ),
         (
             nw.sum_horizontal(
                 nw.col("a").diff(), nw.col("a").cum_sum().over(order_by="i")
             ),
-            WindowKind.UNCLOSEABLE,
+            1,
         ),
         (
             nw.sum_horizontal(nw.col("a").diff(), nw.col("a").cum_sum()).over(
                 order_by="i"
             ),
-            WindowKind.UNCLOSEABLE,
+            2,
         ),
         (
             nw.sum_horizontal(nw.col("a").diff().abs(), nw.col("a").cum_sum()).over(
                 order_by="i"
             ),
-            WindowKind.UNCLOSEABLE,
+            2,
         ),
     ],
 )
-def test_window_kind(expr: nw.Expr, expected: WindowKind) -> None:
-    assert expr._metadata.window_kind is expected
+def test_window_kind(expr: nw.Expr, expected: int) -> None:
+    assert expr._metadata.n_orderable_ops == expected
 
 
 def test_misleading_order_by() -> None:
@@ -73,3 +73,39 @@ def test_double_agg() -> None:
 def test_filter_aggregation() -> None:
     with pytest.raises(InvalidOperationError):
         nw.col("a").mean().drop_nulls()
+
+
+def test_head_aggregation() -> None:
+    with pytest.raises(InvalidOperationError):
+        nw_v1.col("a").mean().head()
+
+
+def test_rank_aggregation() -> None:
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").mean().rank()
+
+
+def test_diff_aggregation() -> None:
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").mean().diff()
+
+
+def test_invalid_over() -> None:
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").fill_null(3).over("b")
+
+
+def test_nested_over() -> None:
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").mean().over("b").over("c")
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").mean().over("b").over("c", order_by="i")
+
+
+def test_filtration_over() -> None:
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").drop_nulls().over("b")
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").drop_nulls().over("b", order_by="i")
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").diff().drop_nulls().over("b", order_by="i")
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index 7265e133..27d4c33e 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -539,9 +539,10 @@ def test_group_by_expr(
             pytest.raises(OrderDependentExprError),
         ),  # Transform and Window
         ([nw.lit(42)], pytest.raises(ComputeError)),  # Literal
+        ([nw.lit(42).abs()], pytest.raises(ComputeError)),  # Literal
     ],
 )
-def test_group_by_raise_if_not_transform(
+def test_group_by_raise_if_not_elementwise(
     constructor: Constructor, keys: list[nw.Expr], lazy_context: Any
 ) -> None:
     data = {"a": [1, 2, 2, None], "b": [0, 1, 2, 3], "x": [1, 2, 3, 4]}