Skip to content

Commit b1b5f17

Browse files
authored
Merge pull request #196 from UnknownPlatypus/filters/default_if_none
Implement `default_if_none` builtin filter
2 parents 0046aa0 + 891c003 commit b1b5f17

File tree

4 files changed

+189
-3
lines changed

4 files changed

+189
-3
lines changed

src/filters.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub enum FilterType {
1111
Capfirst(CapfirstFilter),
1212
Center(CenterFilter),
1313
Default(DefaultFilter),
14+
DefaultIfNone(DefaultIfNoneFilter),
1415
Escape(EscapeFilter),
1516
Escapejs(EscapejsFilter),
1617
External(ExternalFilter),
@@ -60,6 +61,17 @@ impl DefaultFilter {
6061
}
6162
}
6263

64+
#[derive(Clone, Debug, PartialEq)]
65+
pub struct DefaultIfNoneFilter {
66+
pub argument: Argument,
67+
}
68+
69+
impl DefaultIfNoneFilter {
70+
pub fn new(argument: Argument) -> Self {
71+
Self { argument }
72+
}
73+
}
74+
6375
#[derive(Clone, Debug, PartialEq)]
6476
pub struct EscapeFilter;
6577

src/parse.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::filters::AddSlashesFilter;
1515
use crate::filters::CapfirstFilter;
1616
use crate::filters::CenterFilter;
1717
use crate::filters::DefaultFilter;
18+
use crate::filters::DefaultIfNoneFilter;
1819
use crate::filters::EscapeFilter;
1920
use crate::filters::EscapejsFilter;
2021
use crate::filters::ExternalFilter;
@@ -127,6 +128,10 @@ impl Filter {
127128
Some(right) => FilterType::Default(DefaultFilter::new(right)),
128129
None => return Err(ParseError::MissingArgument { at: at.into() }),
129130
},
131+
"default_if_none" => match right {
132+
Some(right) => FilterType::DefaultIfNone(DefaultIfNoneFilter::new(right)),
133+
None => return Err(ParseError::MissingArgument { at: at.into() }),
134+
},
130135
"escape" => match right {
131136
Some(right) => return Err(unexpected_argument("escape", right)),
132137
None => FilterType::Escape(EscapeFilter),

src/render/filters.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ use pyo3::types::PyType;
1010

1111
use crate::error::{AnnotatePyErr, RenderError};
1212
use crate::filters::{
13-
AddFilter, AddSlashesFilter, CapfirstFilter, CenterFilter, DefaultFilter, EscapeFilter,
14-
EscapejsFilter, ExternalFilter, FilterType, LowerFilter, SafeFilter, SlugifyFilter,
15-
UpperFilter, YesnoFilter,
13+
AddFilter, AddSlashesFilter, CapfirstFilter, CenterFilter, DefaultFilter, DefaultIfNoneFilter,
14+
EscapeFilter, EscapejsFilter, ExternalFilter, FilterType, LowerFilter, SafeFilter,
15+
SlugifyFilter, UpperFilter, YesnoFilter,
1616
};
1717
use crate::parse::Filter;
1818
use crate::render::common::gettext;
@@ -47,6 +47,7 @@ impl Resolve for Filter {
4747
FilterType::Capfirst(filter) => filter.resolve(left, py, template, context),
4848
FilterType::Center(filter) => filter.resolve(left, py, template, context),
4949
FilterType::Default(filter) => filter.resolve(left, py, template, context),
50+
FilterType::DefaultIfNone(filter) => filter.resolve(left, py, template, context),
5051
FilterType::Escape(filter) => filter.resolve(left, py, template, context),
5152
FilterType::Escapejs(filter) => filter.resolve(left, py, template, context),
5253
FilterType::External(filter) => filter.resolve(left, py, template, context),
@@ -256,6 +257,25 @@ impl ResolveFilter for DefaultFilter {
256257
}
257258
}
258259

260+
impl ResolveFilter for DefaultIfNoneFilter {
261+
fn resolve<'t, 'py>(
262+
&self,
263+
variable: Option<Content<'t, 'py>>,
264+
py: Python<'py>,
265+
template: TemplateString<'t>,
266+
context: &mut Context,
267+
) -> ResolveResult<'t, 'py> {
268+
match variable {
269+
Some(Content::Py(ref value)) if value.is_none() => {
270+
self.argument
271+
.resolve(py, template, context, ResolveFailures::Raise)
272+
}
273+
Some(left) => Ok(Some(left)),
274+
None => Ok(Some("".as_content())),
275+
}
276+
}
277+
}
278+
259279
impl ResolveFilter for EscapeFilter {
260280
fn resolve<'t, 'py>(
261281
&self,
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import pytest
2+
from django.template.base import VariableDoesNotExist
3+
4+
5+
@pytest.mark.parametrize(
6+
"value,expected",
7+
[
8+
pytest.param(None, "default_value", id="none_value"),
9+
pytest.param("", "", id="empty_string"),
10+
pytest.param(0, "0", id="zero"),
11+
pytest.param(False, "False", id="false"),
12+
pytest.param("actual_value", "actual_value", id="string_value"),
13+
pytest.param(42, "42", id="integer_value"),
14+
pytest.param(3.14, "3.14", id="float_value"),
15+
pytest.param(True, "True", id="true_value"),
16+
pytest.param([], "[]", id="empty_list"),
17+
pytest.param({}, "{}", id="empty_dict"),
18+
],
19+
)
20+
def test_default_if_none_with_various_values(assert_render, value, expected):
21+
template = "{{ value|default_if_none:'default_value' }}"
22+
assert_render(template=template, context={"value": value}, expected=expected)
23+
24+
25+
def test_default_if_none_with_missing_variable(assert_render):
26+
template = "{{ missing|default_if_none:'default' }}"
27+
assert_render(template=template, context={}, expected="")
28+
29+
30+
@pytest.mark.parametrize(
31+
"template,expected",
32+
[
33+
pytest.param(
34+
"{{ value|default_if_none:'DEFAULT'|lower }}", "default", id="chained_lower"
35+
),
36+
pytest.param(
37+
"{{ value|default_if_none:'default'|upper }}", "DEFAULT", id="chained_upper"
38+
),
39+
pytest.param(
40+
"{{ value|default_if_none:'<b>default</b>'|safe }}",
41+
"<b>default</b>",
42+
id="chained_safe",
43+
),
44+
],
45+
)
46+
def test_default_if_none_chained_filters(assert_render, template, expected):
47+
assert_render(template=template, context={"value": None}, expected=expected)
48+
49+
50+
def test_default_if_none_preserves_html_safe_value(assert_render):
51+
assert_render(
52+
template="{{ value|safe|default_if_none:'default' }}",
53+
context={"value": "<b>safe</b>"},
54+
expected="<b>safe</b>",
55+
)
56+
57+
58+
@pytest.mark.parametrize(
59+
"template,context,expected",
60+
[
61+
pytest.param(
62+
"{% autoescape on %}{{ value|default_if_none:'default' }}{% endautoescape %}",
63+
{"value": "<b>html</b>"},
64+
"&lt;b&gt;html&lt;/b&gt;",
65+
id="autoescape_html_value",
66+
),
67+
pytest.param(
68+
"{% autoescape on %}{{ value|default_if_none:default }}{% endautoescape %}",
69+
{"value": None, "default": "<b>default</b>"},
70+
"&lt;b&gt;default&lt;/b&gt;",
71+
id="autoescape_html_default_variable",
72+
),
73+
pytest.param(
74+
"{% autoescape off %}{{ value|default_if_none:'default' }}{% endautoescape %}",
75+
{"value": "<b>html</b>"},
76+
"<b>html</b>",
77+
id="autoescape_html_value",
78+
),
79+
pytest.param(
80+
"{% autoescape off %}{{ value|default_if_none:default }}{% endautoescape %}",
81+
{"value": None, "default": "<b>default</b>"},
82+
"<b>default</b>",
83+
id="autoescape_html_default_variable",
84+
),
85+
pytest.param(
86+
"{{ value|default_if_none:'<b>default</b>'|escape }}",
87+
{"value": None},
88+
"<b>default</b>",
89+
id="explicit_default_str_literal_never_escaped",
90+
),
91+
pytest.param(
92+
"{{ value|default_if_none:default|escape }}",
93+
{"value": None, "default": "<b>default</b>"},
94+
"&lt;b&gt;default&lt;/b&gt;",
95+
id="explicit_escape_html_default_variable",
96+
),
97+
pytest.param(
98+
"{% autoescape on %}{{ value|default_if_none:default|safe }}{% endautoescape %}",
99+
{"value": None, "default": "<b>default</b>"},
100+
"<b>default</b>",
101+
id="safe_prevents_escape_default",
102+
),
103+
],
104+
)
105+
def test_default_if_none_html_escaping(assert_render, template, context, expected):
106+
assert_render(template=template, context=context, expected=expected)
107+
108+
109+
def test_default_if_none_with_forloop_variable(assert_render):
110+
template = "{% for x in items %}{{ x|default_if_none:forloop.counter }}{% endfor %}"
111+
assert_render(
112+
template=template, context={"items": [None, "a", None]}, expected="1a3"
113+
)
114+
115+
116+
def test_default_if_none_missing_argument(assert_parse_error):
117+
template = "{{ value|default_if_none }}"
118+
django_message = "default_if_none requires 2 arguments, 1 provided"
119+
rusty_message = """\
120+
× Expected an argument
121+
╭────
122+
1 │ {{ value|default_if_none }}
123+
· ───────┬───────
124+
· ╰── here
125+
╰────
126+
"""
127+
assert_parse_error(
128+
template=template, django_message=django_message, rusty_message=rusty_message
129+
)
130+
131+
132+
def test_default_if_none_missing_variable_argument(assert_render_error):
133+
django_message = "Failed lookup for key [missing] in [{'True': True, 'False': False, 'None': None}, {'value': None}]"
134+
rusty_message = """\
135+
× Failed lookup for key [missing] in {"False": False, "None": None, "True":
136+
│ True, "value": None}
137+
╭────
138+
1 │ {{ value|default_if_none:missing }}
139+
· ───┬───
140+
· ╰── key
141+
╰────
142+
"""
143+
assert_render_error(
144+
template="{{ value|default_if_none:missing }}",
145+
context={"value": None},
146+
exception=VariableDoesNotExist,
147+
django_message=django_message,
148+
rusty_message=rusty_message,
149+
)

0 commit comments

Comments
 (0)