Opened 6 months ago

Last modified 3 weeks ago

#35459 assigned Cleanup/optimization

Case.extra is undocumented, untested, and can hide potential issues

Reported by: Baptiste Mispelon Owned by: Priyank Panchal
Component: Database layer (models, ORM) Version: 5.0
Severity: Normal Keywords:
Cc: Tim Graham Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: yes
Easy pickings: no UI/UX: no

Description

I came across this today while accidentally doing something like the following:

MyModel.objects.annotate(x=Case(When(somefield=123), then=456, default=789))

The query ran without errors and produced results but they were slightly wrong. It took me longer than I'd like to admit to realize that I should have written:

MyModel.objects.annotate(x=Case(When(somefield=123, then=456), default=789))

(in case you hadn't seen the difference, the then kwarg was passed to Case instead of When).

Once I figured out what was going on, I was surprised that Django had accepted the then argument for Case. I would have expected a TypeError.

The documentation [1] does mention that the signature is class Case(*cases, **extra), but doesn't explain what happens to **extra, and even later refers to "the default keyword argument" which seems inconsistent.

To top it all of, it seems that the feature is untested. I removed **extra from Case.__init__ and Case.as_sql(), ran the full test suite (under sqlite), and got zero errors:

  • django/db/models/expressions.py

    diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py
    index 4ee22420d9..34308fad9c 100644
    a b class Case(SQLiteNumericMixin, Expression):  
    15621562    template = "CASE %(cases)s ELSE %(default)s END"
    15631563    case_joiner = " "
    15641564
    1565     def __init__(self, *cases, default=None, output_field=None, **extra):
     1565    def __init__(self, *cases, default=None, output_field=None):
    15661566        if not all(isinstance(case, When) for case in cases):
    15671567            raise TypeError("Positional arguments must all be When objects.")
    15681568        super().__init__(output_field)
    15691569        self.cases = list(cases)
    15701570        self.default = self._parse_expressions(default)[0]
    1571         self.extra = extra
    15721571
    15731572    def __str__(self):
    15741573        return "CASE %s, ELSE %r" % (
    class Case(SQLiteNumericMixin, Expression):  
    16101609        connection.ops.check_expression_support(self)
    16111610        if not self.cases:
    16121611            return compiler.compile(self.default)
    1613         template_params = {**self.extra, **extra_context}
     1612        template_params = {**extra_context}
    16141613        case_parts = []
    16151614        sql_params = []
    16161615        default_sql, default_params = compiler.compile(self.default)

As far as I can tell, this feature has been present since Case was introduced in #24031 (65246de7b1d70d25831ab394c4f4a75813f629fe).

I would be tempted to drop it considering the way it silently swallows unknown kwargs. Am I missing something?

[1] https://docs.djangoproject.com/en/dev/ref/models/conditional-expressions/#case

Change History (16)

comment:1 by Sarah Boyce, 6 months ago

Cc: Tim Graham added
Triage Stage: UnreviewedAccepted
Type: BugCleanup/optimization

So I think it's related to #25759 and extra is documented in Func.
We might want a similar note for Subquery and Case and tests are always welcome 👍

comment:2 by amns13, 5 months ago

Owner: changed from nobody to amns13
Status: newassigned

comment:3 by Priyank Panchal, 3 months ago

Has patch: set
Owner: changed from amns13 to Priyank Panchal

comment:4 by Priyank Panchal, 3 months ago

I'm happy to receive any guidance on what needs to be improved.

comment:5 by Tim Graham, 3 months ago

I'm not sure that removing the functionality is the correct solution, but I have to look at this more closely.

in reply to:  5 comment:6 by Priyank Panchal, 3 months ago

Replying to Tim Graham:

I'm not sure that removing the functionality is the correct solution, but I have to look at this more closely.

Thank you for your response. It would be helpful if you could suggest an approach for these tickets.

comment:7 by Jacob Walls, 3 months ago

I think this ticket was accepted to add test coverage and improve the documentation. **extra is documented in Func, but in Case the ticket author reports the documentation being unclear, and in Subquery it's not documented at all, so those two need doc edits and tests.

The documentation [1] does mention that the signature is class Case(*cases, **extra), but doesn't explain what happens to extra, and even later refers to "the default keyword argument" which seems inconsistent.

The key/value pairs get interpolated into the SQL template, which by default includes the template param default (hence why the doc'd example provides default), but the SQL template is a class variable and could have arbitrary params if subclassed. This could be clarified.

comment:8 by Jacob Walls, 3 months ago

Has patch: unset

I would be tempted to drop it considering the way it silently swallows unknown kwargs. Am I missing something?

We could consider deprecating the current behavior of accepting unused keyword args, but I think that should go through a forum post first.

comment:9 by Priyank Panchal, 3 months ago

https://github.com/django/django/pull/18419/files
I have updated the PR. I would appreciate any suggestions you might have.

comment:10 by Jacob Walls, 2 months ago

Has patch: set

comment:11 by Jacob Walls, 2 months ago

Patch needs improvement: set

comment:12 by Jacob Walls, 2 months ago

Patch needs improvement: unset

comment:13 by Tim Graham, 2 months ago

The proposed test doesn't seem to be a compelling use case:

Subquery(
    Employee.objects.all().values("salary"),
    salary=20,
    template="(SELECT salary FROM (%(subquery)s) _subquery "
    "WHERE salary = %(salary)s)",
)

If the programmer provides a custom template, it seems they can just as easily interpolate parameters into that template beforehand without relying on Subquery to do it.

I'm not sure if it was correct to include **extra in the original patch for Case considering it was undocumented and untested.

Sarah's comment 1 references #25759 but that's about adding **extra_context to as_sql(), not **extra in __init__(). While Func.__init__() accepts **extra, its subclasses do so inconsistently (and when it does, the use case seems to be setting output_field). Further, Case and Subquery inherit from BaseExpression (which doesn't accept **extra), not from Func.

comment:14 by Jacob Walls, 2 months ago

Thanks, Tim. Before I saw the test case I wasn't aware that template was an allowed kwarg to Case and Subquery. I thought it had to be a class attribute, which is why I thought there was a use case for interpolating params at query time. But if you can provide the template dynamically, I agree the use case isn't compelling. Given that, deprecating **extra here makes sense to me.

Regardless, should we accept a unit test for providing a template kwarg to Case and Subquery? I don't see one in the suite.

comment:15 by Sarah Boyce, 3 weeks ago

Patch needs improvement: set

Marking "Patch needs improvement" until a decision is made on whether to deprecate **extra in Case and Subquery

comment:16 by Tim Graham, 3 weeks ago

I think **extra could be removed without a deprecation since it's undocumented and doesn't appear to be useful, however, __init__() would need template=None adding to its signature if we want to continue allowing that. (Currently, template is pulled from extra: template_params.get("template", self.template) in as_sql().) I'm not sure if it's useful. As Jacob pointed out, it's undocumented and untested.

Note: See TracTickets for help on using tickets.
Back to Top