Proposal: xor function

The xor operator emulation for postgres is in the process of being corrected to behave like a true binary operator, meaning that 1 ^ 1 ^ 1 will be true for Postgres.

In order to keep what some regard as “true” xor behaviour between nary operands, do we want to keep the current Postgres emulation as a separate database function? I agree that we shouldn’t provide every possible conceived utility function so wanted to ask here first.

If so what should it be called “xor()” or another suggestion “exactly_one()”?

@nessita @felixxm Hey just wanted to confirm: it’s too late to sneak this in since 5.0 will be changing the way the xor operator works?

I think this is the kind of tiny, unlikely-to-be-popular utility that’s better to share via a code snippet, perhaps in a forum or blog post. We’ve resisted adding complete coverage of database functions and I think this would fit in the same bucket.

1 Like

Thanks I agree but it’s good to get closure on that thread :+1:

It’s an unfortunate change for my team as we use the old behaviour a lot but have never had the need for the new “return true if an odd number of operands are true” behaviour… oh well :man_shrugging:

Sorry @shangxiao for not answering sooner! I’m just catching up with email after the feature freeze and alpha release. I believe is too late now :clown_face:, but if consensus is reached, I’m happy to help you push this forward!

Personally, I did expect the xor to be exactly one until we had the enlightening conversation in the PR, and I think it would be handy to have such functionality in the ORM. Though regarding naming, that’s a tough one! I asked chatGPT about this and it gave me many confusing answers, so I’m not sure there is a “standard name” for this operation.

1 Like

Sorry @shangxiao

All good I knew you 2 were super busy :green_heart:

I think I’ll just add a gist or something to share with people (or maybe even a django-extensions extension like I suggested to the other person :smile:). A doc note or a section in “topics” about combining xor (^) expressions isn’t what people may expect could also be nice, along with an example function to copy-paste.

Re naming; My opinion is that XOR is fine – as a function that takes n arguments – nary XOR should be exclusive as the name suggests :thinking:

Hey, I just wanted to share my implementation of the n-ary XOR function.

I do think that it would be nice to put this back in the framework (maybe not this implementation), as I don’t think the implementation is trivial. The use case of ‘only one field is set’ using a CheckContraint is a real use case that I find on many of my projects.
Moreover, most of my colleagues weren’t aware of the behavior of XOR with more than 2 operands.

def only_one(**kwargs):
    """
    This functions returns an Expression which is true if and only if one the 
    provided conditions is True.
    
    For example : `only_one(foo=True, bar__isnull=False, foobar="Titi")`
    returns :
    
    ```
    (Q(foo=True) & ~Q(bar__isnull=False) & ~Q(foobar="Titi")) |
        (~Q(foo=True) & Q(bar__isnull=False) & ~Q(foobar="Titi")) |
        (~Q(foo=True) & ~Q(bar__isnull=False) & Q(foobar="Titi"))
    ```
    
    """

    def rotate(source_list, amount):
        """
        This function performs list rotation. For example: 
        rotate([1, 2, 3], 1) => [3, 1, 2].
        
        The implementation is from https://stackoverflow.com/a/9457923/6327520
        """
        return source_list[-amount:] + source_list[:-amount]

    result = None

    subexpression_lists = [
        rotate(list(kwargs.items()), rotate_amount) for rotate_amount in range(len(kwargs))
    ]

    for valid_expression, *other_expression in subexpression_lists:
        final_expression = Q(valid_expression)
        for expression in other_expression:
            final_expression &= ~Q(expression)

        if result is None:
            result = final_expression
        else:
            result |= final_expression

    return result

@shangxiao Did you end up sharing your implementation ? :smile:

Welcome @gbip !

On the surface, that looks like a lot of excess conditional values being evaluated, but I know the query planner has a lot of flexibility in how that actually ends up being calculated. It would be interesting to see the output of an explain analyze from the queries being generated.

I took a slightly different approach. I use the Case / When operators to ensure each condition was only evaluated once.

The simplified version is:

def only_one(**kwargs):
    clauses= [
        Case(When(**{k:Value(v)}, then=Value(1)),default=Value(0))
        for k, v in kwargs.items()
    ]
    return reduce(__add__, clauses)

Which could be used in an annotation clause:

test = only_one(foo=True, bar__isnull=False, foobar="Titi")
queryset = queryset.annotate(number_true=test).filter(number_true=1)

(I describe this as the “simplified” method, because the full implementation is implemented as a model manager method, with additional conditions and tests to allow for field references (F objects) on the right-hand side of the conditions.)

1 Like

I did! :slight_smile: