Skip to content

Add filters |default, |assigned_or and |defined_or #425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

Kijewski
Copy link
Member

@Kijewski Kijewski commented May 1, 2025

Also, enum Pluralize<S, P> is renamed into enum Either<L, R> and exported.

Cc @joshka.

Resolves #424.

@Kijewski
Copy link
Member Author

Kijewski commented May 1, 2025

I am not sure if DefaultUnwrappable is a good name.

Also, it would be possible to implement the trait for integers (testing if != 0), and Strings (testing if != "").

@Kijewski Kijewski force-pushed the pr-default branch 2 times, most recently from 4af0d79 to 90c531c Compare May 1, 2025 11:54
GuillaumeGomez
GuillaumeGomez previously approved these changes May 1, 2025
@GuillaumeGomez
Copy link
Collaborator

Btw, no clue about a better for DefaultUnwrappable so I'm fine with it.

</li><li>

```jinja
{{ variable_or_expression | default(default_value, true) }}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sort of parameter without a name makes it difficult to understand a template worth reading the template docs. I'd been inclined to either:

  1. Replace a naked bool parameter with an enum that self documents (that may look verbose here), or
  2. Replace a bool with a second named method. E.g. default_if_undefined might be a good name for the false branch. Alternatively default_if_none / err

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a line that {{ variable_or_expression | default(default_value, boolean = true) }} works, too.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still prefer explicit over implicit a bit. This is based on an assumption that the direction of askama is a jinja-like template library with natural rust idioms rather than a jinja compatible template library that adds rust conveniences. I'm not sure whether that assumption is correct however (intuitively I'd lean towards the former as being more useful)

As a single data point. Having not seen the jinja syntax for the default filter prior, even with the parameter named boolean in the CoPilot suggestion rather than the bare true, I still had no idea from reading it what that parameter meant or controlled seeing it for the first time. This suggests to me that it is poorly named in jinja, and something that we could make better pretty easily with good naming here.

@joshka
Copy link

joshka commented May 1, 2025

Also, it would be possible to implement the trait for integers (testing if != 0), and Strings (testing if != "").

Empty string seems reasonable as that's rationally returned when a string is not entered somewhere, but most of the time a zero value seems like it would be only 0 if you're using that as a flag value, which is not very rusty, so I'd perhaps implement default_if_empty for strings (and str, cow perhaps), but not default_if_zero unless there's an explicit obvious use case where it makes sense.

@Kijewski
Copy link
Member Author

Kijewski commented May 1, 2025

  • Renamed trait to DefaultFilterable (I think that's better)
  • Implemented for Cow<'_, T> (I forgot about taat, good call!)
  • Implemented for Wrapping<T> & Saturating<T>
  • Implemented for NonZero* (infallably Ok(Some())) [just so you don't have to update the code if you use NonZeroU32 and u32]

I think implementing the type for integers is fine and useful. In many real-world cases you will find 0 being used as a poor-man's NULL. And unless boolean = true, it does not matter either way.

DefaultFilterable is not recursive: Some(0) returns 0; only the Option is evaluated, not the contained value.

Comment on lines -333 to +334
pub fn pluralize<C, S, P>(count: C, singular: S, plural: P) -> Result<Pluralize<S, P>, C::Error>
pub fn pluralize<C, S, P>(count: C, singular: S, plural: P) -> Result<Either<S, P>, C::Error>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this break semver (in a way that matters) for any consumers?
If so, would be worth keeping as Pluralize rather than version bumping the lib?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, version bumping will be needed since we plan to extend {% call %} blocks. So it's fine.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type was unreachable before, so its name did not matter.

fn as_filtered(&self) -> Result<Option<Self::Filtered<'_>>, Self::Error>;
}

const _: () = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a syntax I've seen before, what's the rationale behind it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To prevent impacting the upper scope.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In here I mostly use it like a #region. Every DefaultFilterable is in this scope and one click hides everything.

Copy link

@joshka joshka Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. I know that often there's a tendency for larger module sizes in some Rust projects. This seems to be a symptom of that. I tend to address the same core problem (strongly related code that isn't in the outer scope) by using smaller narrower module (files) and liberal re-exports instead. This keeps the unit tests fairly close to the code it's testing, makes it easy to navigate and find code, etc. Obv. this is subjective, but maybe something to think about in future.

So a soft suggestion to extract this to filters/default.rs or something.

</li><li>

```jinja
{{ variable_or_expression | default(default_value, true) }}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still prefer explicit over implicit a bit. This is based on an assumption that the direction of askama is a jinja-like template library with natural rust idioms rather than a jinja compatible template library that adds rust conveniences. I'm not sure whether that assumption is correct however (intuitively I'd lean towards the former as being more useful)

As a single data point. Having not seen the jinja syntax for the default filter prior, even with the parameter named boolean in the CoPilot suggestion rather than the bare true, I still had no idea from reading it what that parameter meant or controlled seeing it for the first time. This suggests to me that it is poorly named in jinja, and something that we could make better pretty easily with good naming here.

default_value: None,
},
&FilterArgument {
name: "boolean",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not naming it "do_unwrap" or something along the line to better match what it's doing instead?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question still open. :)

@Kijewski
Copy link
Member Author

Kijewski commented May 2, 2025

So, what do we want to do with the argument "boolean"? It's the name and semantics Jinja uses. The question is: How compatible do we want to be?

If we don't care to be 100% compatible (and we don't), then yes, two filters would be the better option:

  1. I would then call the boolean = false option {{ var | defined_or(default_value) }}.
  2. And the boolean = true option {{ expr | assigned_or(default_value) }}.

Fun fact minijinja does not implement |default either.

@Kijewski
Copy link
Member Author

Kijewski commented May 2, 2025

Or, what about both options?

  • {{ var | default(default_value, [false]) }}{{ var | defined_or(default_value) }}
  • {{ expr | default(default_value, [boolean =] true) }}{{ expr | assigned_or(default_value) }}

The filter |default will work as in jinja, same semantics, name argument naming; but you could also use filters with a (hopefully) better naming scheme.

@GuillaumeGomez
Copy link
Collaborator

Having two filters for the same thing sounds good to me. 👍

Just please improve the argument name. We can add an error for this specific case to tell that for better naming, askama is using X name instead for this named argument.

What do you think @joshka?

@joshka
Copy link

joshka commented May 3, 2025

Supporting jinja syntax probably makes it easier to bring templates from elsewhere (and allows LLMs to fall into the pit of success by suggesting code which works even if it's sub optimal). So while I dislike the syntax, if there's a better syntax available like suggested above, then I've no complaints about also providing compatible syntax like this. I'd even go so far as to suggest that the parameter name is fine in that case (alternatively, if there's some future ability to alias it to give it an additional more descriptive name then that would work too)

@Kijewski Kijewski changed the title Add filter |default Add filters |default, |assigned_or and |defined_or Jun 1, 2025
@Kijewski Kijewski force-pushed the pr-default branch 4 times, most recently from eef03df to 826194d Compare June 1, 2025 21:35
@Kijewski
Copy link
Member Author

Kijewski commented Jun 1, 2025

I added |assigned_or and |defined_or as (better) alternatives to |defined.

I don't think that it would be a good idea to implement |defined with other arguments than in Jinja.

  • EITHER we keep it for Jinja compatibility exactly like in jinja
  • OR we drop it, because the other two filters are better
  • OR we re-implement the |default filter to compile_error! with a message to use the other filters.

@joshka
Copy link

joshka commented Jun 1, 2025

The other names are good.

I like the idea of making it possible to do something useful if someone tries jinja syntax. That makes a lot of sense as it makes it easy to fall into a pit of success.

But I also like the idea of making this a compile error, but wonder if it might be possible to cause a deprecation warning to be emitted for default instead? Are there cases where a template might want to need default|undefined type logic in askama? I.e. is there some case where a template would call another template but intentionally not provide some field?

@Kijewski
Copy link
Member Author

Kijewski commented Jun 1, 2025

Blocked by #471.


Are there cases where a template might want to need default|undefined type logic in askama? I.e. is there some case where a template would call another template but intentionally not provide some field?

I could not think of a use case. In python, the boolean argument can be evaluated at runtime, but with our pre-compiled code this is not possible.

Right now, a proc-macro can only emit fatal errors, no warnings. There are open RFCs to add compile_warning!-like macros, though. But I guess we could always add a call to ↓ to fake a deprecation message.

#[deprecated(note = "use `|defined_or` or `|assigned_or` instead of `|default`")]
fn dont_use_default() {}

Most warnings that come from proc-macros get suppressed, though. I would need to test if deprecation message are one of them.

Also, `enum Pluralize<S, P>` is renamed into `enum Either<L, R>` and
exported.
@Kijewski
Copy link
Member Author

Kijewski commented Jun 6, 2025

We can't assign a span to the warning (→ #420). Without the span, such a warning is kinda useless IMHO. I think the current implementation is fine as it is. :) We can always add a warning in a subsequent PR. @GuillaumeGomez, @joshka, what do you think? Good enough to be merged?

@GuillaumeGomez
Copy link
Collaborator

Output looks ok to me.

Copy link

@joshka joshka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some extra rview notes having gone through this in a bit more detail.

fn as_filtered(&self) -> Result<Option<Self::Filtered<'_>>, Self::Error>;
}

const _: () = {
Copy link

@joshka joshka Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. I know that often there's a tendency for larger module sizes in some Rust projects. This seems to be a symptom of that. I tend to address the same core problem (strongly related code that isn't in the outer scope) by using smaller narrower module (files) and liberal re-exports instead. This keeps the unit tests fairly close to the code it's testing, makes it easy to navigate and find code, etc. Obv. this is subjective, but maybe something to think about in future.

So a soft suggestion to extract this to filters/default.rs or something.

label = "`{Self}` is not `|assigned_or` filterable",
message = "`{Self}` is not `|assigned_or` filterable"
)]
pub trait DefaultFilterable {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be worth adding a unit tests for the various implementations of this that captures each implementation. E.g.:

assert_eq!(0_u8.as_filtered(), Ok(None));
assert_eq!(1_u8.as_filtered(), Ok(Some(1));


```jinja
{% let greeting = Some("Hello") %}
{{ greeting.as_ref() | default("Hi", true) }}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be

Suggested change
{{ greeting.as_ref() | default("Hi", true) }}
{{ greeting.as_ref() | assigned_or("Hi") }}

Comment on lines +34 to +35
{{ variable_or_expression | assigned_or(fallback) }}
{{ variable_or_expression | assigned_or(fallback) }}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicate line - needs to be fixed or state what's going on here.

Comment on lines +113 to +115
This filter works like the Jinja filter of the same name.
If the second argument is not an boolean `true`, then the filter behaves like [`|defined_or`][#defined_or].
If it is supplied and `true`, then the filter behaves like [`|assigned_or`][#assigned_or].
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should maybe describe how this works without having to link to the jinja / the other filters.

If it is supplied and `true`, then the filter behaves like [`|assigned_or`][#assigned_or].

**This filter exists for compatibility with Jinja.
You should use [`|defined_or`][#defined_or] and [`|assigned_or`][#assigned_or] directly instead.**
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
You should use [`|defined_or`][#defined_or] and [`|assigned_or`][#assigned_or] directly instead.**
Askama provides [`|defined_or`][#defined_or] and [`|assigned_or`][#assigned_or] which both better express the intention and should generally usually be used instead of this filter.**

"var | default }}"
--> tests/ui/default.rs:10:35
|
10 | #[template(ext = "html", source = "{{ var | default }}")]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually suggests a bit of a jinja / rust impedance mismatch here. In Rust, we have a few different operations like map_or_default and unwrap_or_default. Intuitively, var | default when the type of var implements the Default trait seems like it would be obvious that it should return default. I know that a user could call .unwrap_or_default() or whatever there, but I'm curious whether it might be worth extending this to allow the obvious intuitive thing there. (I'm not fully certain of this, either way, WDYT?)

@Kijewski
Copy link
Member Author

@joshka, thank you for your suggestions and proofreading! I will have a look tomorrow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add filter to simplify handling of Option fields
3 participants